From 90e2000e9bf72e47883fa3c76f48353ef42c5f9f Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 1 Jul 2024 08:25:14 +0700 Subject: [PATCH 001/341] [Antora] Make partial for server configure section & clean format --- .../distributed/configure/batchsizes.adoc | 33 +- .../distributed/configure/blobstore.adoc | 182 +-------- .../configure/collecting-contacts.adoc | 39 +- .../configure/collecting-events.adoc | 69 +--- .../pages/distributed/configure/dns.adoc | 54 +-- .../distributed/configure/domainlist.adoc | 44 +-- .../distributed/configure/droplists.adoc | 32 +- .../pages/distributed/configure/dsn.adoc | 219 +---------- .../distributed/configure/extensions.adoc | 61 +-- .../distributed/configure/healthcheck.adoc | 24 +- .../pages/distributed/configure/imap.adoc | 182 +-------- .../pages/distributed/configure/index.adoc | 88 +---- .../pages/distributed/configure/jmap.adoc | 185 +-------- .../pages/distributed/configure/jmx.adoc | 66 +--- .../pages/distributed/configure/jvm.adoc | 104 +---- .../distributed/configure/listeners.adoc | 157 +------- .../configure/mailetcontainer.adoc | 96 +---- .../pages/distributed/configure/mailets.adoc | 151 +------ .../configure/mailrepositorystore.adoc | 40 +- .../pages/distributed/configure/matchers.adoc | 167 +------- .../distributed/configure/opensearch.adoc | 322 +-------------- .../pages/distributed/configure/pop3.adoc | 78 +--- .../pages/distributed/configure/queue.adoc | 18 +- .../pages/distributed/configure/rabbitmq.adoc | 172 +------- .../configure/recipientrewritetable.adoc | 19 +- .../pages/distributed/configure/redis.adoc | 46 +-- .../remote-delivery-error-handling.adoc | 119 +----- .../pages/distributed/configure/search.adoc | 17 +- .../pages/distributed/configure/sieve.adoc | 93 +---- .../distributed/configure/smtp-hooks.adoc | 371 +----------------- .../pages/distributed/configure/smtp.adoc | 317 +-------------- .../pages/distributed/configure/spam.adoc | 192 +-------- .../pages/distributed/configure/ssl.adoc | 248 +----------- .../pages/distributed/configure/tika.adoc | 50 +-- .../configure/usersrepository.adoc | 135 +------ .../pages/distributed/configure/vault.adoc | 40 +- .../pages/distributed/configure/webadmin.adoc | 101 +---- .../partials/configure/batchsizes.adoc | 31 ++ .../servers/partials/configure/blobstore.adoc | 173 ++++++++ .../configure/collecting-contacts.adoc | 38 ++ .../partials/configure/collecting-events.adoc | 68 ++++ .../servers/partials/configure/dns.adoc | 52 +++ .../partials/configure/domainlist.adoc | 42 ++ .../servers/partials/configure/droplists.adoc | 30 ++ .../servers/partials/configure/dsn.adoc | 217 ++++++++++ .../partials/configure/extensions.adoc | 60 +++ .../configure/forCoreComponentsPartial.adoc | 15 + .../configure/forExtensionsPartial.adoc | 14 + .../configure/forProtocolsPartial.adoc | 15 + .../forStorageDependenciesPartial.adoc | 11 + .../partials/configure/healthcheck.adoc | 22 ++ .../servers/partials/configure/imap.adoc | 179 +++++++++ .../servers/partials/configure/jmap.adoc | 181 +++++++++ .../servers/partials/configure/jmx.adoc | 64 +++ .../servers/partials/configure/jvm.adoc | 102 +++++ .../servers/partials/configure/listeners.adoc | 156 ++++++++ .../partials/configure/mailetcontainer.adoc | 95 +++++ .../servers/partials/configure/mailets.adoc | 146 +++++++ .../configure/mailrepositorystore.adoc | 34 ++ .../servers/partials/configure/matchers.adoc | 166 ++++++++ .../partials/configure/opensearch.adoc | 310 +++++++++++++++ .../servers/partials/configure/pop3.adoc | 74 ++++ .../servers/partials/configure/queue.adoc | 16 + .../servers/partials/configure/rabbitmq.adoc | 162 ++++++++ .../configure/recipientrewritetable.adoc | 15 + .../servers/partials/configure/redis.adoc | 28 ++ .../remote-delivery-error-handling.adoc | 117 ++++++ .../servers/partials/configure/search.adoc | 15 + .../servers/partials/configure/sieve.adoc | 89 +++++ .../partials/configure/smtp-hooks.adoc | 364 +++++++++++++++++ .../servers/partials/configure/smtp.adoc | 315 +++++++++++++++ .../servers/partials/configure/spam.adoc | 191 +++++++++ .../servers/partials/configure/ssl.adoc | 253 ++++++++++++ .../configure/systemPropertiesPartial.adoc | 23 ++ .../servers/partials/configure/tika.adoc | 48 +++ .../partials/configure/usersrepository.adoc | 126 ++++++ .../servers/partials/configure/vault.adoc | 29 ++ .../servers/partials/configure/webadmin.adoc | 104 +++++ 78 files changed, 4324 insertions(+), 4197 deletions(-) create mode 100644 docs/modules/servers/partials/configure/batchsizes.adoc create mode 100644 docs/modules/servers/partials/configure/blobstore.adoc create mode 100644 docs/modules/servers/partials/configure/collecting-contacts.adoc create mode 100644 docs/modules/servers/partials/configure/collecting-events.adoc create mode 100644 docs/modules/servers/partials/configure/dns.adoc create mode 100644 docs/modules/servers/partials/configure/domainlist.adoc create mode 100644 docs/modules/servers/partials/configure/droplists.adoc create mode 100644 docs/modules/servers/partials/configure/dsn.adoc create mode 100644 docs/modules/servers/partials/configure/extensions.adoc create mode 100644 docs/modules/servers/partials/configure/forCoreComponentsPartial.adoc create mode 100644 docs/modules/servers/partials/configure/forExtensionsPartial.adoc create mode 100644 docs/modules/servers/partials/configure/forProtocolsPartial.adoc create mode 100644 docs/modules/servers/partials/configure/forStorageDependenciesPartial.adoc create mode 100644 docs/modules/servers/partials/configure/healthcheck.adoc create mode 100644 docs/modules/servers/partials/configure/imap.adoc create mode 100644 docs/modules/servers/partials/configure/jmap.adoc create mode 100644 docs/modules/servers/partials/configure/jmx.adoc create mode 100644 docs/modules/servers/partials/configure/jvm.adoc create mode 100644 docs/modules/servers/partials/configure/listeners.adoc create mode 100644 docs/modules/servers/partials/configure/mailetcontainer.adoc create mode 100644 docs/modules/servers/partials/configure/mailets.adoc create mode 100644 docs/modules/servers/partials/configure/mailrepositorystore.adoc create mode 100644 docs/modules/servers/partials/configure/matchers.adoc create mode 100644 docs/modules/servers/partials/configure/opensearch.adoc create mode 100644 docs/modules/servers/partials/configure/pop3.adoc create mode 100644 docs/modules/servers/partials/configure/queue.adoc create mode 100644 docs/modules/servers/partials/configure/rabbitmq.adoc create mode 100644 docs/modules/servers/partials/configure/recipientrewritetable.adoc create mode 100644 docs/modules/servers/partials/configure/redis.adoc create mode 100644 docs/modules/servers/partials/configure/remote-delivery-error-handling.adoc create mode 100644 docs/modules/servers/partials/configure/search.adoc create mode 100644 docs/modules/servers/partials/configure/sieve.adoc create mode 100644 docs/modules/servers/partials/configure/smtp-hooks.adoc create mode 100644 docs/modules/servers/partials/configure/smtp.adoc create mode 100644 docs/modules/servers/partials/configure/spam.adoc create mode 100644 docs/modules/servers/partials/configure/ssl.adoc create mode 100644 docs/modules/servers/partials/configure/systemPropertiesPartial.adoc create mode 100644 docs/modules/servers/partials/configure/tika.adoc create mode 100644 docs/modules/servers/partials/configure/usersrepository.adoc create mode 100644 docs/modules/servers/partials/configure/vault.adoc create mode 100644 docs/modules/servers/partials/configure/webadmin.adoc diff --git a/docs/modules/servers/pages/distributed/configure/batchsizes.adoc b/docs/modules/servers/pages/distributed/configure/batchsizes.adoc index 4d6123e468e..be7e6bfb1c2 100644 --- a/docs/modules/servers/pages/distributed/configure/batchsizes.adoc +++ b/docs/modules/servers/pages/distributed/configure/batchsizes.adoc @@ -1,34 +1,5 @@ = Distributed James Server — batchsizes.properties :navtitle: batchsizes.properties -This files allow to define the amount of data that should be fetched 'at once' when interacting with the mailbox. This is -needed as IMAP can generate some potentially large requests. - -Increasing these values tend to fasten individual requests, at the cost of enabling potential higher load. - -Consult this link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/batchsizes.properties[example] -to get some examples and hints. - -.batchsizes.properties content -|=== -| Property name | explanation - -| fetch.metadata -| Optional, defaults to 200. How many messages should be read in a batch when using FetchType.MetaData - -| fetch.headers -| Optional, defaults to 200. How many messages should be read in a batch when using FetchType.Header - -| fetch.body -| Optional, defaults to 100. How many messages should be read in a batch when using FetchType.Body - -| fetch.full -| Optional, defaults to 50. How many messages should be read in a batch when using FetchType.Full - -| copy -| Optional, defaults to 200. How many messages should be copied in a batch. - -| move -| Optional, defaults to 200. How many messages should be moved in a batch. - -|=== \ No newline at end of file +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +include::partial$configure/batchsizes.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/blobstore.adoc b/docs/modules/servers/pages/distributed/configure/blobstore.adoc index 0ebcf516d5d..84673e86b45 100644 --- a/docs/modules/servers/pages/distributed/configure/blobstore.adoc +++ b/docs/modules/servers/pages/distributed/configure/blobstore.adoc @@ -1,6 +1,9 @@ = Distributed James Server — blobstore.properties :navtitle: blobstore.properties +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +:pages-path: distributed + == BlobStore This file is optional. If omitted, the *cassandra* blob store will be used. @@ -12,7 +15,7 @@ You can choose the underlying implementation of BlobStore to fit with your James It could be the implementation on top of Cassandra or file storage service S3 compatible like Openstack Swift and AWS S3. -Consult link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/blob.properties[blob.properties] +Consult link:{sample-configuration-prefix-url}/blob.properties[blob.properties] in GIT to get some examples and hints. === Implementation choice @@ -22,7 +25,7 @@ in GIT to get some examples and hints. * cassandra: use cassandra based BlobStore * objectstorage: use Swift/AWS S3 based BlobStore * file: (experimental) use directly the file system. Useful for legacy architecture based on shared ISCI SANs and/or - distributed file system with no object store available. +distributed file system with no object store available. WARNING: JAMES-3591 Cassandra is not made to store large binary content, its use will be suboptimal compared to Alternatives (namely S3 compatible BlobStores backed by for instance S3, MinIO or Ozone) @@ -41,7 +44,7 @@ NOTE: If you are upgrading from James 3.5 or older, the deduplication was enable Deduplication requires a garbage collector mechanism to effectively drop blobs. A first implementation based on bloom filters can be used and triggered using the WebAdmin REST API. See -xref:distributed/operate/webadmin.adoc#_running_blob_garbage_collection[Running blob garbage collection]. +xref:{pages-path}/operate/webadmin.adoc#_running_blob_garbage_collection[Running blob garbage collection]. In order to avoid concurrency issues upon garbage collection, we slice the blobs in generation, the two more recent generations are not garbage collected. @@ -52,54 +55,6 @@ but deleted blobs will live longer. Duration, defaults on 30 days, the default u *deduplication.gc.generation.family*: Every time the duration is changed, this integer counter must be incremented to avoid conflicts. Defaults to 1. -=== Encryption choice - -Data can be optionally encrypted with a symmetric key using AES before being stored in the blobStore. As many user relies -on third party for object storage, a compromised third party will not escalate to a data disclosure. Of course, a -performance price have to be paid, as encryption takes resources. - -*encryption.aes.enable* : Optional boolean, defaults to false. - -If AES encryption is enabled, then the following properties MUST be present: - - - *encryption.aes.password* : String - - *encryption.aes.salt* : Hexadecimal string - -The following properties CAN be supplied: - - - *encryption.aes.private.key.algorithm* : String, defaulting to PBKDF2WithHmacSHA512. Previously was -PBKDF2WithHmacSHA1. - -WARNING: Once chosen this choice can not be reverted, all the data is either clear or encrypted. Mixed encryption -is not supported. - -Here is an example of how you can generate the above values (be mindful to customize the byte lengths in order to add -enough entropy. - -.... -# Password generation -openssl rand -base64 64 - -# Salt generation -generate salt with : openssl rand -hex 16 -.... - -AES blob store supports the following system properties that could be configured in `jvm.properties`: - -.... -# Threshold from which we should buffer the blob to a file upon encrypting -# Unit supported: K, M, G, default to no unit -james.blob.aes.file.threshold.encrypt=100K - -# Threshold from which we should buffer the blob to a file upon decrypting -# Unit supported: K, M, G, default to no unit -james.blob.aes.file.threshold.decrypt=256K - -# Maximum size of a blob. Larger blobs will be rejected. -# Unit supported: K, M, G, default to no unit -james.blob.aes.blob.max.size=100M -.... - === Cassandra BlobStore Cache A Cassandra cache can be enabled to reduce latency when reading small blobs frequently. @@ -124,127 +79,4 @@ Supported units: bytes, Kib, MiB, GiB, TiB Maximum size of stored objects expressed in bytes. |=== -=== Object storage configuration - -==== AWS S3 Configuration - -.blobstore.properties S3 related properties -|=== -| Property name | explanation - -| objectstorage.s3.endPoint -| S3 service endpoint - -| objectstorage.s3.region -| S3 region - -| objectstorage.s3.accessKeyId -| https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html#access-keys-and-secret-access-keys[S3 access key id] - -| objectstorage.s3.secretKey -| https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html#access-keys-and-secret-access-keys[S3 access key secret] - -| objectstorage.s3.http.concurrency -| Allow setting the number of concurrent HTTP requests allowed by the Netty driver. - -| objectstorage.s3.truststore.path -| optional: Verify the S3 server certificate against this trust store file. - -| objectstorage.s3.truststore.type -| optional: Specify the type of the trust store, e.g. JKS, PKCS12 - -| objectstorage.s3.truststore.secret -| optional: Use this secret/password to access the trust store; default none - -| objectstorage.s3.truststore.algorithm -| optional: Use this specific trust store algorithm; default SunX509 - -| objectstorage.s3.trustall -| optional: boolean. Defaults to false. Cannot be set to true with other trustore options. Wether James should validate -S3 endpoint SSL certificates. - -| objectstorage.s3.read.timeout -| optional: HTTP read timeout. duration, default value being second. Leaving it empty relies on S3 driver defaults. - -| objectstorage.s3.write.timeout -| optional: HTTP write timeout. duration, default value being second. Leaving it empty relies on S3 driver defaults. - -| objectstorage.s3.connection.timeout -| optional: HTTP connection timeout. duration, default value being second. Leaving it empty relies on S3 driver defaults. - -| objectstorage.s3.in.read.limit -| optional: Object read in memory will be rejected if they exceed the size limit exposed here. Size, exemple `100M`. -Supported units: K, M, G, defaults to B if no unit is specified. If unspecified, big object won't be prevented -from being loaded in memory. This settings complements protocol limits. - -| objectstorage.s3.upload.retry.maxAttempts -| optional: Integer. Default is zero. This property specifies the maximum number of retry attempts allowed for failed upload operations. - -| objectstorage.s3.upload.retry.backoffDurationMillis -| optional: Long (Milliseconds). Default is 10 (miliseconds). -Only takes effect when the "objectstorage.s3.upload.retry.maxAttempts" property is declared. -This property determines the duration (in milliseconds) to wait between retry attempts for failed upload operations. -This delay is known as backoff. The jitter factor is 0.5 - -|=== - -==== Buckets Configuration - -.Bucket configuration -|=== -| Property name | explanation - -| objectstorage.bucketPrefix -| Bucket is a concept in James and similar to Containers in Swift or Buckets in AWS S3. -BucketPrefix is the prefix of bucket names in James BlobStore - -| objectstorage.namespace -| BlobStore default bucket name. Most of blobs storing in BlobStore are inside the default bucket. -Unless a special case like storing blobs of deleted messages. -|=== - -== Blob Export - -Blob Exporting is the mechanism to help James to export a blob from an user to another user. -It is commonly used to export deleted messages (consult configuring deleted messages vault). -The deleted messages are transformed into a blob and James will export that blob to the target user. - -This configuration helps you choose the blob exporting mechanism fit with your James setup and it is only applicable with Guice products. - -Consult https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/blob.properties[blob.properties] -in GIT to get some examples and hints. - -Configuration for exporting blob content: - -.blobstore.properties content -|=== -| blob.export.implementation - -| localFile: Local File Exporting Mechanism (explained below). Default: localFile - -| linshare: LinShare Exporting Mechanism (explained below) -|=== - -=== Local File Blob Export Configuration - -For each request, this mechanism retrieves the content of a blob and save it to a distinct local file, then send an email containing the absolute path of that file to the target mail address. - -Note: that absolute file path is the file location on James server. Therefore, if there are two or more James servers connected, it should not be considered an option. - -*blob.export.localFile.directory*: The directory URL to store exported blob data in files, and the URL following -http://james.apache.org/server/3/apidocs/org/apache/james/filesystem/api/FileSystem.html[James File System scheme]. -Default: file://var/blobExporting - -=== LinShare Blob Export Configuration - -Instead of exporting blobs in local file system, using https://www.linshare.org[LinShare] -helps you upload your blobs and people you have been shared to can access those blobs by accessing to -LinShare server and download them. - -This way helps you to share via whole network as long as they can access to LinShare server. - -To get an example or details explained, visit https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/blob.properties[blob.properties] - -*blob.export.linshare.url*: The URL to connect to LinShare - -*blob.export.linshare.token*: The authentication token to connect to LinShare +include::partial$configure/blobstore.adoc[] diff --git a/docs/modules/servers/pages/distributed/configure/collecting-contacts.adoc b/docs/modules/servers/pages/distributed/configure/collecting-contacts.adoc index ed00b04d243..418700ad921 100644 --- a/docs/modules/servers/pages/distributed/configure/collecting-contacts.adoc +++ b/docs/modules/servers/pages/distributed/configure/collecting-contacts.adoc @@ -1,39 +1,4 @@ = Contact collection -== Motivation - -Many modern applications combines email and contacts. - -We want recipients of emails sent by a user to automatically be added to this user contacts, for convenience. This -should even be performed when a user sends emails via SMTP for example using thunderbird. - -== Design - -The idea is to send AMQP messages holding information about mail envelope for a traitment via a tierce application. - -== Configuration - -We can achieve this goal by combining simple mailets building blocks. - -Here is a sample pipeline achieving aforementioned objectives : - -.... - - extractedContacts - - - amqp://${env:JAMES_AMQP_USERNAME}:${env:JAMES_AMQP_PASSWORD}@${env:JAMES_AMQP_HOST}:${env:JAMES_AMQP_PORT} - collector:email - extractedContacts - - -.... - -A sample message looks like: - -.... -{ - "userEmail": "sender@james.org", - "emails": ["to@james.org"] -} -.... \ No newline at end of file +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +include::partial$configure/collecting-contacts.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/collecting-events.adoc b/docs/modules/servers/pages/distributed/configure/collecting-events.adoc index f103a76a23d..0d8532bf178 100644 --- a/docs/modules/servers/pages/distributed/configure/collecting-events.adoc +++ b/docs/modules/servers/pages/distributed/configure/collecting-events.adoc @@ -1,69 +1,4 @@ = Event collection -== Motivation - -Many calendar application do add events invitation received by email directly in ones calendar. - -Such behaviours requires the calendar application to be aware of the ICalendar related emails a user received. - -== Design - -The idea is to write a portion of mailet pipeline extracting Icalendar attachments and to hold them as attachments that -can later be sent to other applications over AMQP to be treated in an asynchronous, decoupled fashion. - -== Configuration - -We can achieve this goal by combining simple mailets building blocks. - -Here is a sample pipeline achieving aforementioned objectives : - -.... - - - text/calendar - rawIcalendar - - - rawIcalendar - - - rawIcalendar - icalendar - - - icalendar - - - icalendar - icalendarAsJson - rawIcalendar - - - amqp://${env:JAMES_AMQP_USERNAME}:${env:JAMES_AMQP_PASSWORD}@${env:JAMES_AMQP_HOST}:${env:JAMES_AMQP_PORT} - james:events - icalendarAsJson - - -.... - -A sample message looks like: - -.... -{ - "ical": "RAW_DATA_AS_TEXT_FOLLOWING_ICS_FORMAT", - "sender": "other@james.apache.org", - "recipient": "any@james2.apache.org", - "replyTo": "other@james.apache.org", - "uid": "f1514f44bf39311568d640727cff54e819573448d09d2e5677987ff29caa01a9e047feb2aab16e43439a608f28671ab7c10e754ce92be513f8e04ae9ff15e65a9819cf285a6962bc", - "dtstamp": "20170106T115036Z", - "method": "REQUEST", - "sequence": "0", - "recurrence-id": null -} -.... - -The following pipeline positions the X-MEETING-UID in the Header in order for mail user agent to correlate events with this mail. -The sample look like: -``` -X-MEETING-UID: f1514f44bf39311568d640727cff54e819573448d09d2e5677987ff29caa01a9e047feb2aab16e43439a608f28671ab7c10e754ce92be513f8e04ae9ff15e65a9819cf285a6962bc -``` \ No newline at end of file +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +include::partial$configure/collecting-events.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/dns.adoc b/docs/modules/servers/pages/distributed/configure/dns.adoc index ecc0c80ce38..1954a4b6b35 100644 --- a/docs/modules/servers/pages/distributed/configure/dns.adoc +++ b/docs/modules/servers/pages/distributed/configure/dns.adoc @@ -1,55 +1,5 @@ = Distributed James Server — dnsservice.xml :navtitle: dnsservice.xml -Consult this link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/dnsservice.xml[example] -to get some examples and hints. - -Specifies DNS Server information for use by various components inside Apache James Server. - -DNS Transport services are controlled by a configuration block in -the dnsservice.xml. This block affects SMTP remote delivery. - -The dnsservice tag defines the boundaries of the configuration -block. It encloses all the relevant configuration for the DNS server. -The behavior of the DNS service is controlled by the attributes and -children of this tag. - -.dnsservice.xml content -|=== -| Property name | explanation - -| servers -| Information includes a list of DNS Servers to be used by James. These are -specified by the server elements, each of which is a child element of the -servers element. Each server element is the IP address of a single DNS server. -The server elements can have multiple server children. Enter ip address of your DNS server, one IP address per server -element. If no DNS servers are found and you have not specified any below, 127.0.0.1 will be used - -| autodiscover -| true or false - If you use autodiscover and add DNS servers manually a combination of all the DNS servers will be used. -If autodiscover is true, James will attempt to autodiscover the DNS servers configured on your underlying system. -Currently, this works if the OS has a unix-like /etc/resolv.xml, -or the system is Windows based with ipconfig or winipcfg. Change autodiscover to false if you would like to turn off autodiscovery -and set the DNS servers manually in the servers section - -| authoritative -| *true/false* - This tag specifies whether or not -to require authoritative (non-cached) DNS records; to only accept DNS responses that are -authoritative for the domain. It is primarily useful in an intranet/extranet environment. -This should always be *false* unless you understand the implications. - -| maxcachesize -| Maximum number of entries to maintain in the DNS cache (typically 50000) - -| negativeCacheTTL -| Sets the maximum length of time that negative records will be stored in the DNS negative cache in -seconds (a negative record means the name has not been found in the DNS). Values for this cache -can be positive meaning the time in seconds before retrying to resolve the name, zero meaning no -cache or a negative value meaning infinite caching. - -| singleIPperMX -| true or false (default) - Specifies if Apache James Server must try a single server for each multihomed mx host - -| verbose -| Turn on general debugging statements -|=== +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +include::partial$configure/dns.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/domainlist.adoc b/docs/modules/servers/pages/distributed/configure/domainlist.adoc index 53b9b0f4c46..ad5cbafffea 100644 --- a/docs/modules/servers/pages/distributed/configure/domainlist.adoc +++ b/docs/modules/servers/pages/distributed/configure/domainlist.adoc @@ -1,45 +1,5 @@ = Distributed James Server — domainlist.xml :navtitle: domainlist.xml -Consult this link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/domainlist.xml[example] -to get some examples and hints. - -This configuration block is defined by the *domainlist* tag. - -.domainlist.xml content -|=== -| Property name | explanation - -| domainnames -| Domainnames identifies the DNS namespace served by this instance of James. -These domainnames are used for both matcher/mailet processing and SMTP auth -to determine when a mail is intended for local delivery - Only applicable for XMLDomainList. The entries mentionned here will be created upon start. - -|autodetect -|true or false - If autodetect is true, James wil attempt to discover its own host name AND -use any explicitly specified servernames. -If autodetect is false, James will use only the specified domainnames. Defaults to false. - -|autodetectIP -|true or false - If autodetectIP is not false, James will also allow add the IP address for each servername. -The automatic IP detection is to support RFC 2821, Sec 4.1.3, address literals. Defaults to false. - -|defaultDomain -|Set the default domain which will be used if an email is send to a recipient without a domain part. -If no defaultdomain is set the first domain of the DomainList gets used. If the default is not yet contained by the Domain List, the domain will be created upon start. - -|read.cache.enable -|Experimental. Boolean, defaults to false. -Whether or not to cache domainlist.contains calls. Enable a faster execution however writes will take time -to propagate. - -|read.cache.expiracy -|Experimental. String (duration), defaults to 10 seconds (10s). Supported units are ms, s, m, h, d, w, month, y. -Expiracy of the cache. Longer means less reads are performed to the backend but writes will take longer to propagate. -Low values (a few seconds) are advised. - - -|=== - -To override autodetected domainnames simply add explicit domainname elements. -In most cases this will be necessary. By default, the domainname 'localhost' is specified. This can be removed, if required. +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +include::partial$configure/domainlist.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/droplists.adoc b/docs/modules/servers/pages/distributed/configure/droplists.adoc index 375b6156b7f..500aee7a5df 100644 --- a/docs/modules/servers/pages/distributed/configure/droplists.adoc +++ b/docs/modules/servers/pages/distributed/configure/droplists.adoc @@ -1,32 +1,6 @@ = Distributed James Server — DropLists :navtitle: DropLists -The DropList, also known as the mail blacklist, is a collection of -domains and email addresses that are denied from sending emails within the system. -It is disabled by default. -To enable it, modify the `droplists.properties` file and include the `IsInDropList` matcher in the `mailetcontainer.xml`. -To disable it, adjust the `droplists.properties` file and remove the `IsInDropList` matcher from the `mailetcontainer.xml`. - -.droplists.properties content -|=== -| Property name | explanation - -| enabled -| Boolean. Governs whether DropLists should be enabled. Defaults to `false`. -|=== - -== Enabling Matcher - -Plug the `IsInDropList` matcher within `mailetcontainer.xml` : - -.... - - transport - -.... - -== DropList management - -DropList management, including adding and deleting entries, is performed through the WebAdmin REST API. - -See xref:distributed/operate/webadmin.adoc#_administrating_droplists[WebAdmin DropLists]. \ No newline at end of file +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +:pages-path: distributed +include::partial$configure/droplists.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/dsn.adoc b/docs/modules/servers/pages/distributed/configure/dsn.adoc index 714324b6405..8085aaa0dab 100644 --- a/docs/modules/servers/pages/distributed/configure/dsn.adoc +++ b/docs/modules/servers/pages/distributed/configure/dsn.adoc @@ -1,218 +1,7 @@ = Distributed James Server — Delivery Submission Notifications :navtitle: ESMTP DSN setup -DSN introduced in link:https://tools.ietf.org/html/rfc3461[RFC-3461] allows a SMTP sender to demand status messages, -defined in link:https://tools.ietf.org/html/rfc3464[RFC-3464] to be sent back to the `Return-Path` upon delivery -progress. - -DSN support is not enabled by default, as it needs specific configuration of the -xref:distributed/configure/mailetcontainer.adoc[mailetcontainer.xml] to be specification compliant. - -To enable it you need to: - -- Add DSN SMTP hooks as part of the SMTP server stack -- Configure xref:distributed/configure/mailetcontainer.adoc[mailetcontainer.xml] to generate DSN bounces when needed - -== Enabling DSN in SMTP server stack - -For this simply add the `DSN hooks` in the handler chain in `smtpserver.xml` : - -.... - - <...> - - - - - - <...> - - - -.... - -== Enabling DSN generation as part of mail processing - -For the below conditions to be matched we assume you follow -xref:distributed/configure/remote-delivery-error-handling.adoc[RemoteDelivery error handling for MXs], which is a -requirement for detailed RemoteDelivery error and delay handling on top of the Distributed server. - -Here is a sample xref:distributed/configure/mailetcontainer.adoc[mailetcontainer.xml] achieving the following DSN generation: - -- Generate a generic `delivered` notification if LocalDelivery succeeded, if requested -- Generate a generic `failed` notification in case of local errors, if requested -- Generate a specific `failed` notification in case of a non existing local user, if requested -- Generate a specific `failed` notification in case of an address rewriting loop, if requested -- Generate a `failed` notification in case of remote permanent errors, if requested. We blame the remote server... -- Generate a `delayed` notification in case of temporary remote errors we are about to retry, if requested. We blame the remote server... -- Generate a `failed` notification in case of temporary remote errors we are not going to retry (failed too many time), if requested. We blame the remote server... - -.... - - - - - \ - - - - - - - - - - [FAILED] - true - Hi. This is the James mail server at [machine]. -I'm afraid I wasn't able to deliver your message to the following addresses. -This is a permanent error; I've given up. Sorry it didn't work out. Below -I include the list of recipients, and the reason why I was unable to deliver -your message. - failed - 5.0.0 - - - cassandra://var/mail/error/ - - - - - - - - false - - - - [SUCCESS] - true - Hi. This is the James mail server at [machine]. -I successfully delivered your message to the following addresses. -Note that it indicates your recipients received the message but do -not imply they read it. - delivered - 2.0.0 - - - - - - - - outgoing - 0 - 0 - 10 - true - - remote-delivery-error - - - - [FAILED] - true - Hi. This is the James mail server at [machine]. -I'm afraid I wasn't able to deliver your message to the following addresses. -This is a permanent error; I've given up. Sorry it didn't work out. -The remote server we should relay this mail to keep on failing. -Below I include the list of recipients, and the reason why I was unable to deliver -your message. - failed - 5.0.0 - - - cassandra://var/mail/error/remote-delivery/permanent/ - - - - - - - - - - - - - - - [FAILED] - true - Hi. This is the James mail server at [machine]. -I'm afraid I wasn't able to deliver your message to the following addresses. -This is a permanent error; I've given up. Sorry it didn't work out. -The remote server we should relay this mail to returns a permanent error. -Below I include the list of recipients, and the reason why I was unable to deliver -your message. - failed - 5.0.0 - - - - [DELAYED] - true - Hi. This is the James mail server at [machine]. -I'm afraid I wasn't able to deliver your message to the following addresses yet. -This is a temporary error: I will keep on trying. -Below I include the list of recipients, and the reason why I was unable to deliver -your message. - delayed - 4.0.0 - - - - - - - - [FAILED] - true - Hi. This is the James mail server at [machine]. -I'm afraid I wasn't able to deliver your message to the following addresses. -This is a permanent error; I've given up. Sorry it didn't work out. -The following addresses do not exist here. Sorry. - failed - 5.0.0 - - - cassandra://var/mail/address-error/ - - - - - - - cassandra://var/mail/relay-denied/ - Warning: You are sending an e-mail to a remote server. You must be authenticated to perform such an operation - - - - - - cassandra://var/mail/rrt-error/ - true - - - - [FAILED] - true - Hi. This is the James mail server at [machine]. -I'm afraid I wasn't able to deliver your message to the following addresses. -This is a permanent error; I've given up. Sorry it didn't work out. -The following addresses is caught in a rewriting loop. An admin should come and fix it (you likely want to report it). -Once resolved the admin should be able to resume the processing of your email. -Below I include the list of recipients, and the reason why I was unable to deliver -your message. - failed - 5.1.6/defaultStatus> - - - - -.... - -== Limitations - -The out of the box tooling do not allow generating `relayed` DSN notification as RemoteDelivery misses a success -callback. \ No newline at end of file +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +:pages-path: distributed +:mailet-repository-path-prefix: cassandra +include::partial$configure/dsn.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/extensions.adoc b/docs/modules/servers/pages/distributed/configure/extensions.adoc index a2b496a4453..95f754529c2 100644 --- a/docs/modules/servers/pages/distributed/configure/extensions.adoc +++ b/docs/modules/servers/pages/distributed/configure/extensions.adoc @@ -1,61 +1,6 @@ = Distributed James Server — extensions.properties :navtitle: extensions.properties -This files enables an operator to define additional bindings used to instantiate others extensions - -*guice.extension.module*: come separated list of fully qualified class name. These classes need to implement Guice modules. - -Here is an example of such a class : - -.... -public class MyServiceModule extends AbstractModule { - @Override - protected void configure() { - bind(MyServiceImpl.class).in(Scopes.SINGLETON); - bind(MyService.class).to(MyServiceImpl.class); - } -} -.... - -Recording it in extensions.properties : - -.... -guice.extension.module=com.project.MyServiceModule -.... - -Enables to inject MyService into your extensions. - - -*guice.extension.tasks*: come separated list of fully qualified class name. - -The extension can rely on the Task manager to supervise long-running task execution (progress, await, cancellation, scheduling...). -These extensions need to implement Task extension modules. - -Here is an example of such a class : - -.... -public class RspamdTaskExtensionModule implements TaskExtensionModule { - - @Inject - public RspamdTaskExtensionModule() { - } - - @Override - public Set> taskDTOModules() { - return Set.of(...); - } - - @Override - public Set> taskAdditionalInformationDTOModules() { - return Set.of(...); - } -} -.... - -Recording it in extensions.properties : - -.... -guice.extension.tasks=com.project.RspamdTaskExtensionModule -.... - -Read xref:customization:index.adoc#_defining_custom_injections_for_your_extensions[this page] for more details. +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +:pages-path: distributed +include::partial$configure/extensions.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/healthcheck.adoc b/docs/modules/servers/pages/distributed/configure/healthcheck.adoc index 37c01f8c818..82a147ea2c6 100644 --- a/docs/modules/servers/pages/distributed/configure/healthcheck.adoc +++ b/docs/modules/servers/pages/distributed/configure/healthcheck.adoc @@ -1,25 +1,5 @@ = Distributed James Server — healthcheck.properties :navtitle: healthcheck.properties -Consult this link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/healthcheck.properties[example] -to get some examples and hints. - -Use this configuration to define the initial delay and period for the PeriodicalHealthChecks. It is only applicable with Guice products. - -.healthcheck.properties content -|=== -| Property name | explanation - -| healthcheck.period -| Define the period between two periodical health checks (default: 60s). Units supported are (ms - millisecond, s - second, m - minute, h - hour, d - day). Default unit is millisecond. - -| reception.check.user -| User to be using for running the "mail reception" health check. The user must exist. -If not specified, the mail reception check is a noop. - -| reception.check.timeout -| Period after which mail reception is considered faulty. Defaults to one minute. - -| additional.healthchecks -| List of fully qualified HealthCheck class names in addition to James' default healthchecks. Default to empty list. -|=== \ No newline at end of file +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +include::partial$configure/healthcheck.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/imap.adoc b/docs/modules/servers/pages/distributed/configure/imap.adoc index 96ac8c43af6..79c6a9d93a3 100644 --- a/docs/modules/servers/pages/distributed/configure/imap.adoc +++ b/docs/modules/servers/pages/distributed/configure/imap.adoc @@ -1,182 +1,6 @@ = Distributed James Server — imapserver.xml :navtitle: imapserver.xml -Consult this link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/imapserver.xml[example] -to get some examples and hints. - -The IMAP4 service is controlled by a configuration block in the imap4server.xml. -The imap4server tag defines the boundaries of the configuration block. It encloses -all the relevant configuration for the IMAP4 server. The behavior of the IMAP4 service is -controlled by the attributes and children of this tag. - -This tag has an optional boolean attribute - *enabled* - that defines whether the service is active or not. -The value defaults to "true" if not present. - -The standard children of the imapserver tag are: - -.imapserver.xml content -|=== -| Property name | explanation - -| bind -| Configure this to bind to a specific inetaddress. This is an optional integer value. This value is the port on which this IMAP4 server is configured -to listen. If the tag or value is absent then the service -will bind to all network interfaces for the machine If the tag or value is omitted, the value will default to the standard IMAP4 port -port 143 is the well-known/IANA registered port for IMAP -port 993 is the well-known/IANA registered port for IMAPS ie over SSL/TLS - -| connectionBacklog -| Number of connection backlog of the server (maximum number of queued connection requests) - -| compress -| true or false - Use or don't use COMPRESS extension. Defaults to false. - -| maxLineLength -| Maximal allowed line-length before a BAD response will get returned to the client -This should be set with caution as a to high value can make the server a target for DOS (Denial of Service)! - -| inMemorySizeLimit -| Optional. Size limit before we will start to stream to a temporary file. -Defaults to 10MB. Must be a positive integer, optionally with a unit: B, K, M, G. - -| literalSizeLimit -| Optional. Maximum size of a literal (IMAP APPEND). -Defaults to 0 (unlimited). Must be a positive integer, optionally with a unit: B, K, M, G. - -| plainAuthDisallowed -| Deprecated. Should use `auth.plainAuthEnabled`, `auth.requireSSL` instead. -Whether to enable Authentication PLAIN if the connection is not encrypted via SSL or STARTTLS. Defaults to `true`. - -| auth.plainAuthEnabled -| Whether to enable Authentication PLAIN/ LOGIN command. Defaults to `true`. - -| auth.requireSSL -| true or false. Defaults to `true`. Whether to require SSL to authenticate. If this is required, the IMAP server will disable authentication on unencrypted channels. - -| auth.oidc.oidcConfigurationURL -| Provide OIDC url address for information to user. Only configure this when you want to authenticate IMAP server using a OIDC provider. - -| auth.oidc.jwksURL -| Provide url to get OIDC's JSON Web Key Set to validate user token. Only configure this when you want to authenticate IMAP server using a OIDC provider. - -| auth.oidc.claim -| Claim string uses to identify user. E.g: "email_address". Only configure this when you want to authenticate IMAP server using a OIDC provider. - -| auth.oidc.scope -| An OAuth scope that is valid to access the service (RF: RFC7628). Only configure this when you want to authenticate IMAP server using a OIDC provider. - -| timeout -| Default to 30 minutes. After this time, inactive channels that have not performed read, write, or both operation for a while -will be closed. Negative value disable this behaviour. - -| enableIdle -| Default to true. If enabled IDLE commands will generate a server heartbeat on a regular period. - -| idleTimeInterval -| Defaults to 120. Needs to be a strictly positive integer. - -| idleTimeIntervalUnit -| Default to SECONDS. Needs to be a parseable TimeUnit. - -| disabledCaps -| Implemented server capabilities NOT to advertise to the client. Coma separated list. Defaults to no disabled capabilities. - -| jmxName -| The name given to the configuration - -| tls -| Set to true to support STARTTLS or SSL for the Socket. -To use this you need to copy sunjce_provider.jar to /path/james/lib directory. To create a new keystore execute: -`keytool -genkey -alias james -keyalg RSA -storetype PKCS12 -keystore /path/to/james/conf/keystore`. -Please note that each IMAP server exposed on different port can specify its own keystore, independently from any other -TLS based protocols. - -| handler.helloName -| This is the name used by the server to identify itself in the IMAP4 -protocol. If autodetect is TRUE, the server will discover its -own host name and use that in the protocol. If discovery fails, -the value of 'localhost' is used. If autodetect is FALSE, James -will use the specified value. - -| connectiontimeout -| Connection timeout in seconds - -| connectionLimit -| Set the maximum simultaneous incoming connections for this service - -| connectionLimitPerIP -| Set the maximum simultaneous incoming connections per IP for this service - -| concurrentRequests -| Maximum number of IMAP requests executed simultaneously. Past that limit requests are queued. Defaults to 20. -Negative values deactivate this feature, leading to unbounded concurrency. - -| maxQueueSize -| Upper bound to the IMAP throttler queue. Upon burst, requests that cannot be queued are rejected and not executed. -Integer, defaults to 4096, must be positive, 0 means no queue. - -| proxyRequired -| Enables proxy support for this service for incoming connections. HAProxy's protocol -(https://www.haproxy.org/download/2.7/doc/proxy-protocol.txt) is used and might be compatible -with other proxies (e.g. traefik). If enabled, it is *required* to initiate the connection -using HAProxy's proxy protocol. - -| bossWorkerCount -| Set the maximum count of boss threads. Boss threads are responsible for accepting incoming IMAP connections -and initializing associated resources. Optional integer, by default, boss threads are not used and this responsibility is being dealt with -by IO threads. - -| ioWorkerCount -| Set the maximum count of IO threads. IO threads are responsible for receiving incoming IMAP messages and framing them -(split line by line). IO threads also take care of compression and SSL encryption. Their tasks are short-lived and non-blocking. -Optional integer, defaults to 2 times the count of CPUs. - -| ignoreIDLEUponProcessing -| true or false - Allow disabling the heartbeat handler. Defaults to true. - -| useEpoll -| true or false - If true uses native EPOLL implementation for Netty otherwise uses NIO. Defaults to false. - -| gracefulShutdown -| true or false - If true attempts a graceful shutdown, which is safer but can take time. Defaults to true. - -| highWriteBufferWaterMark -| Netty's write buffer high watermark configuration. Unit supported: none, K, M. Netty defaults applied. - -| lowWriteBufferWaterMark -| Netty's write buffer low watermark configuration. Unit supported: none, K, M. Netty defaults applied. -|=== - -== OIDC setup -James IMAP support XOAUTH2 authentication mechanism which allow authenticating against a OIDC providers. -Please configure `auth.oidc` part to use this. - -We do supply an link:https://github.com/apache/james-project/tree/master/examples/oidc[example] of such a setup. -It uses the Keycloak OIDC provider, but usage of similar technologies is definitely doable. - -== Extending IMAP - -IMAP decoders, processors and encoder can be customized. xref:customization:imap.adoc[Read more]. - -Check this link:https://github.com/apache/james-project/tree/master/examples/custom-imap[example]. - -The following configuration properties are available for extensions: - -.imapserver.xml content -|=== -| Property name | explanation - -| imapPackages -| Configure (union) of IMAP packages. IMAP packages bundles decoders (parsing IMAP commands) processors and encoders, -thus enable implementing new IMAP commands or replace existing IMAP processors. List of FQDNs, which can be located in -James extensions. - -| additionalConnectionChecks -| Configure (union) of additional connection checks. ConnectionCheck will check if the connection IP is secure or not. -| customProperties -| Properties for custom extension. Each tag is a property entry, and holds a string under the form key=value. -|=== - -== Mail user agents auto-configuration - -Check this example on link:https://github.com/apache/james-project/tree/master/examples/imap-autoconf[Mail user agents auto-configuration]. +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +:pages-path: distributed +include::partial$configure/imap.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/index.adoc b/docs/modules/servers/pages/distributed/configure/index.adoc index 8a99ac9a4d3..76c4453c387 100644 --- a/docs/modules/servers/pages/distributed/configure/index.adoc +++ b/docs/modules/servers/pages/distributed/configure/index.adoc @@ -9,85 +9,15 @@ or rely on reasonable defaults. The following configuration files are exposed: -== For protocols +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +:xref-base: distributed/configure +:server-name: Distributed James Server -By omitting these files, the underlying protocols will be disabled. +include::partial$configure/forProtocolsPartial.adoc[] -** xref:distributed/configure/imap.adoc[*imapserver.xml*] allows configuration for the IMAP protocol link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/imapserver.xml[example] -** xref:distributed/configure/jmap.adoc[*jmap.properties*] allows to configure the JMAP protocol link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/jmap.properties[example] -** xref:distributed/configure/jmx.adoc[*jmx.properties*] allows configuration of JMX being used by the Command Line Interface link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/jmx.properties[example] -** xref:distributed/configure/smtp.adoc#_lmtp_configuration[*lmtpserver.xml*] allows configuring the LMTP protocol link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/lmtpserver.xml[example] -** *managesieveserver.xml* allows configuration for ManagedSieve (unsupported) link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/managesieveserver.xml[example] -** xref:distributed/configure/pop3.adoc[*pop3server.xml*] allows configuration for the POP3 protocol (experimental) link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/pop3server.xml[example] -** xref:distributed/configure/smtp.adoc[*smtpserver.xml*] allows configuration for the SMTP protocol link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/smtpserver.xml[example] -*** xref:distributed/configure/smtp-hooks.adoc[This page] list SMTP hooks that can be used out of the box with the Distributed Server. -** xref:distributed/configure/webadmin.adoc[*webadmin.properties*] enables configuration for the WebAdmin protocol link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/webadmin.properties[example] -** xref:distributed/configure/ssl.adoc[This page] details SSL & TLS configuration. -** xref:distributed/configure/sieve.adoc[This page] details Sieve setup and how to enable ManageSieve. +include::partial$configure/forStorageDependenciesPartial.adoc[] +** xref:distributed/configure/cassandra.adoc[*cassandra.properties*] allows to configure the Cassandra driver link:{sample-configuration-prefix-url}/sample-configuration/cassandra.properties[example] -== For storage dependencies - -Except specific documented cases, these files are required, at least to establish a connection with the storage components. - -** xref:distributed/configure/blobstore.adoc[*blobstore.properties*] allows to configure the BlobStore link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/blob.properties[example] -** xref:distributed/configure/cassandra.adoc[*cassandra.properties*] allows to configure the Cassandra driver link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/cassandra.properties[example] -** xref:distributed/configure/opensearch.adoc[*opensearch.properties*] allows to configure OpenSearch driver link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/opensearch.properties[example] -** xref:distributed/configure/rabbitmq.adoc[*rabbitmq.properties*] allows configuration for the RabbitMQ driver link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/rabbitmq.properties[example] -** xref:distributed/configure/redis.adoc[*redis.properties*] allows configuration for the Redis driver link:https://github.com/apache/james-project/blob/fabfdf4874da3aebb04e6fe4a7277322a395536a/server/mailet/rate-limiter-redis/redis.properties[example], that is used by optional -distributed rate limiting component. -** xref:distributed/configure/tika.adoc[*tika.properties*] allows configuring Tika as a backend for text extraction link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/tika.properties[example] - -== For core components - -By omitting these files, sane default values are used. - -** xref:distributed/configure/batchsizes.adoc[*batchsizes.properties*] allows to configure mailbox read batch sizes link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/batchsizes.properties[example] -** xref:distributed/configure/dns.adoc[*dnsservice.xml*] allows to configure DNS resolution link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/dnsservice.xml[example] -** xref:distributed/configure/domainlist.adoc[*domainlist.xml*] allows to configure Domain storage link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/domainlist.xml[example] -** xref:distributed/configure/healthcheck.adoc[*healthcheck.properties*] allows to configure periodical healthchecks link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/healthcheck.properties[example] -** xref:distributed/configure/mailetcontainer.adoc[*mailetcontainer.xml*] allows configuring mail processing link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/mailetcontainer.xml[example] -*** xref:distributed/configure/mailets.adoc[This page] list matchers that can be used out of the box with the Distributed Server. -*** xref:distributed/configure/matchers.adoc[This page] list matchers that can be used out of the box with the Distributed Server. -** xref:distributed/configure/mailrepositorystore.adoc[*mailrepositorystore.xml*] enables registration of allowed MailRepository protcols and link them to MailRepository implementations link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/mailrepositorystore.xml[example] -** xref:distributed/configure/recipientrewritetable.adoc[*recipientrewritetable.xml*] enables advanced configuration for the Recipient Rewrite Table component link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/recipientrewritetable.xml[example] -*** xref:distributed/configure/matchers.adoc[This page] allows choosing the indexing technology. -** xref:distributed/configure/usersrepository.adoc[*usersrepository.xml*] allows configuration of user storage link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/usersrepository.xml[example] - -== For extensions - -By omitting these files, no extra behaviour is added. - -** xref:distributed/configure/vault.adoc[*deletedMessageVault.properties*] allows to configure the DeletedMessageVault link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/deletedMessageVault.properties[example] -** xref:distributed/configure/listeners.adoc[*listeners.xml*] enables configuration of Mailbox Listeners link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/listeners.xml[example] -** xref:distributed/configure/extensions.adoc[*extensions.properties*] allows to extend James behaviour by loading your extensions in it link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/extensions.properties[example] -** xref:distributed/configure/jvm.adoc[*jvm.properties*] lets you specify additional system properties without cluttering your command line -** xref:distributed/configure/spam.adoc[This page] documents Anti-Spam setup with SpamAssassin, Rspamd. -** xref:distributed/configure/remote-delivery-error-handling.adoc[This page] proposes a simple strategy for RemoteDelivery error handling. -** xref:distributed/configure/collecting-contacts.adoc[This page] documents contact collection -** xref:distributed/configure/collecting-events.adoc[This page] documents event collection -** xref:distributed/configure/dsn.adoc[this page] specified how to support SMTP Delivery Submission Notification (link:https://tools.ietf.org/html/rfc3461[RFC-3461]) -** xref:distributed/configure/droplists.adoc[This page] allows configuring drop lists. - -== System properties - -Some tuning can be done via system properties. This includes: - -.System properties -|=== -| Property name | explanation - -| james.message.memory.threshold -| (Optional). String (size, integer + size units, example: `12 KIB`, supported units are bytes KIB MIB GIB TIB). Defaults to 100KIB. -This governs the threshold MimeMessageInputStreamSource relies on for storing MimeMessage content on disk. -Below, data is stored in memory. Above data is stored on disk. -Lower values will lead to longer processing time but will minimize heap memory usage. Modern SSD hardware -should however support a high throughput. Higher values will lead to faster single mail processing at the cost -of higher heap usage. - - -| james.message.usememorycopy -|Optional. Boolean. Defaults to false. Recommended value is false. -Should MimeMessageWrapper use a copy of the message in memory? Or should bigger message exceeding james.message.memory.threshold -be copied to temporary files? - -|=== \ No newline at end of file +include::partial$configure/forCoreComponentsPartial.adoc[] +include::partial$configure/forExtensionsPartial.adoc[] +include::partial$configure/systemPropertiesPartial.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/jmap.adoc b/docs/modules/servers/pages/distributed/configure/jmap.adoc index 9d7611ba130..65fb94ab6ef 100644 --- a/docs/modules/servers/pages/distributed/configure/jmap.adoc +++ b/docs/modules/servers/pages/distributed/configure/jmap.adoc @@ -1,184 +1,7 @@ = Distributed James Server — jmap.properties :navtitle: jmap.properties -https://jmap.io/[JMAP] is intended to be a new standard for email clients to connect to mail -stores. It therefore intends to primarily replace IMAP + SMTP submission. It is also designed to be more -generic. It does not replace MTA-to-MTA SMTP transmission. - -Consult this link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/jmap.properties[example] -to get some examples and hints. - -.jmap.properties content -|=== -| Property name | explanation - -| enabled -| true/false. Governs whether JMAP should be enabled - -| jmap.port -| Optional. Defaults to 80. The port this server will be listening on. This value must be a valid -port, ranging between 1 and 65535 (inclusive) - -| tls.keystoreURL -| Keystore to be used for generating authentication tokens for password authentication mechanism. -This should not be the same keystore than the ones used by TLS based protocols. - -| tls.secret -| Password used to read the keystore - -| jwt.publickeypem.url -| Optional. Coma separated list of RSA public keys URLs to validate JWT tokens allowing requests to bypass authentication. -Defaults to an empty list. - -| url.prefix -| Optional. Configuration urlPrefix for JMAP routes. Default value: http://localhost. - -| websocket.url.prefix -| Optional. URL for JMAP WebSocket route. Default value: ws://localhost - -| email.send.max.size -| Optional. Configuration max size for message created in RFC-8621. -Default value: None. Supported units are B (bytes) K (KB) M (MB) G (GB). - -| max.size.attachments.per.mail -| Optional. Defaults to 20MB. RFC-8621 `maxSizeAttachmentsPerEmail` advertised to JMAP client as part of the -`urn:ietf:params:jmap:mail` capability. This needs to be at least 33% lower than `email.send.max.size` property -(in order to account for text body, headers, base64 encoding and MIME structures). -JMAP clients would use this property in order not to create too big emails. -Default value: None. Supported units are B (bytes) K (KB) M (MB) G (GB). - -| upload.max.size -| Optional. Configuration max size for each upload file in new JMAP-RFC-8621. -Default value: 30M. Supported units are B (bytes) K (KB) M (MB) G (GB). - -| upload.quota.limit -| Optional. Configure JMAP upload quota for total existing uploads' size per user. User exceeding the upload quota would result in old uploads being cleaned up. -Default value: 200M. Supported units are B (bytes) K (KB) M (MB) G (GB). - -| view.email.query.enabled -| Optional boolean. Defaults to false. Should simple Email/query be resolved against a Cassandra projection, or should we resolve them against OpenSearch? -This enables a higher resilience, but the projection needs to be correctly populated. - -| user.provisioning.enabled -| Optional boolean. Defaults to true. Governs whether authenticated users that do not exist locally should be created in the users repository. - -| authentication.strategy.rfc8621 -| Optional List[String] with delimiter `,` . Specify which authentication strategies system admin want to use for JMAP RFC-8621 server. -The implicit package name is `org.apache.james.jmap.http`. If you have a custom authentication strategy outside this package, you have to specify its FQDN. -If no authentication strategy is specified, JMAP RFC-8621 server will fallback to default strategies: -`JWTAuthenticationStrategy`, `BasicAuthenticationStrategy`. - -| jmap.version.default -| Optional string. Defaults to `rfc-8621`. Allowed values: rfc-8621 -Which version of the JMAP protocol should be served when none supplied in the Accept header. - -| dynamic.jmap.prefix.resolution.enabled -| Optional boolean. Defaults to false. Supported Jmap session endpoint returns dynamic prefix in response. -When its config is true, and the HTTP request to Jmap session endpoint has a `X-JMAP-PREFIX` header with the value `http://new-domain/prefix`, -then `apiUrl, downloadUrl, uploadUrl, eventSourceUrl, webSocketUrl` in response will be changed with a new prefix. Example: The `apiUrl` will be "http://new-domain/prefix/jmap". -If the HTTP request to Jmap session endpoint has the `X-JMAP-WEBSOCKET-PREFIX` header with the value `ws://new-domain/prefix`, -then `capabilities."urn:ietf:params:jmap:websocket".url` in response will be "ws://new-domain/prefix/jmap/ws". - -| webpush.prevent.server.side.request.forgery -| Optional boolean. Prevent server side request forgery by preventing calls to the private network ranges. Defaults to true, can be disabled for testing. - -| cassandra.filter.projection.activated -|Optional boolean. Defaults to false. Casandra backends only. Whether to use or not the Cassandra projection -for JMAP filters. This projection optimizes reads, but needs to be correctly populated. Turning it on on -systems with filters already defined would result in those filters to be not read. - -| delay.sends.enabled -| Optional boolean. Defaults to false. Whether to support or not the delay send with JMAP protocol. - -| disabled.capabilities -| Optional, defaults to empty. Coma separated list of JMAP capabilities to reject. -This allows to prevent users from using some specific JMAP extensions. - -| email.get.full.max.size -| Optional, default value is 5. The max number of items for EmailGet full reads. - -| get.max.size -| Optional, default value is 500. The max number of items for /get methods. - -| set.max.size -| Optional, default value is 500. The max number of items for /set methods. -|=== - -== Wire tapping - -Enabling *TRACE* on `org.apache.james.jmap.wire` enables reactor-netty wiretap, logging of -all incoming and outgoing requests, outgoing requests. This will log also potentially sensible information -like authentication credentials. - -== OIDC set up - -The use of `XUserAuthenticationStrategy` allow delegating the authentication responsibility to a third party system, -which could be used to set up authentication against an OIDC provider. - -We do supply an link:https://github.com[example] of such a setup. It combines the link:https://www.keycloak.org/[Keycloack] -OIDC provider with the link:https://www.krakend.io/[Krackend] API gateway, but usage of similar technologies is definitely doable. - -== Generating a JWT key pair - -Apache James can alternatively be configured to check the validity of JWT tokens itself. No revocation mechanism is -supported in such a setup, and the `sub` claim is used to identify the user. The key configuration is static. - -This requires the `JWTAuthenticationStrategy` authentication strategy to be used. - -The Distributed server enforces the use of RSA-SHA-256. - -One can use OpenSSL to generate a JWT key pair : - - # private key - openssl genrsa -out rs256-4096-private.rsa 4096 - # public key - openssl rsa -in rs256-4096-private.rsa -pubout > rs256-4096-public.pem - -The private key can be used to generate JWT tokens, for instance -using link:https://github.com/vandium-io/jwtgen[jwtgen]: - - jwtgen -a RS256 -p rs256-4096-private.rsa 4096 -c "sub=bob@domain.tld" -e 3600 -V - -This token can then be passed as `Bearer` of the `Authorization` header : - - curl -H "Authorization: Bearer $token" -XPOST http://127.0.0.1:80/jmap -d '...' - -The public key can be referenced as `jwt.publickeypem.url` of the `jmap.properties` configuration file. - -== Annotated specification - -The [annotated documentation](https://github.com/apache/james-project/tree/master/server/protocols/jmap-rfc-8621/doc/specs/spec) -presents the limits of the JMAP RFC-8621 implementation part of the Apache James project. We furthermore implement -[JSON Meta Application Protocol (JMAP) Subprotocol for WebSocket](https://tools.ietf.org/html/rfc8887). - -Some methods / types are not yet implemented, some implementations are naive, and the PUSH is not supported yet. - -Users are invited to read these limitations before using actively the JMAP RFC-8621 implementation, and should ensure their -client applications only uses supported operations. - -Contributions enhancing support are furthermore welcomed. - -The list of tested JMAP clients are: - - - Experiments had been run on top of [LTT.RS](https://github.com/iNPUTmice/lttrs-android). Version in the Accept - headers needs to be explicitly set to `rfc-8621`. [Read more](https://github.com/linagora/james-project/pull/4089). - -== JMAP auto-configuration - -link:https://datatracker.ietf.org/doc/html/rfc8620[RFC-8620] defining JMAP core RFC defines precisely service location. - -James already redirects `http://jmap.domain.tld/.well-known/jmap` to the JMAP session. - -You can further help your clients by publishing extra SRV records. - -Eg: - ----- -_jmap._tcp.domain.tld. 3600 IN SRV 0 1 443 jmap.domain.tld. ----- - -== JMAP reverse-proxy set up - -James implementation adds the value of `X-Real-IP` header as part of the logging MDC. - -This allows for reverse proxies to cary other the IP address of the client down to the JMAP server for diagnostic purpose. \ No newline at end of file +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +:server-name: Distributed James Server +:backend-name: Cassandra +include::partial$configure/jmap.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/jmx.adoc b/docs/modules/servers/pages/distributed/configure/jmx.adoc index 04e88db20ce..486a90ca727 100644 --- a/docs/modules/servers/pages/distributed/configure/jmx.adoc +++ b/docs/modules/servers/pages/distributed/configure/jmx.adoc @@ -1,67 +1,5 @@ = Distributed James Server — jmx.properties :navtitle: jmx.properties -== Disclaimer - -JMX poses several security concerns and had been leveraged to conduct arbitrary code execution. -This threat is mitigated by not allowing remote connections to JMX, setting up authentication and pre-authentication filters. -However, we recommend to either run James in isolation (docker / own virtual machine) or disable JMX altogether.
- -James JMX endpoint provides command line utilities and exposes a few metrics, also available on the metric endpoint.

- -== Configuration - -This is used to configure the JMX MBean server via which all management is achieved. - -Consult this link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/jmx.properties[example] -in GIT to get some examples and hints. - -.jmx.properties content -|=== -| Property name | explanation - -| jmx.enabled -| Boolean. Should the JMX server be enabled? Defaults to `true`. - -| jmx.address -|The IP address (host name) the MBean Server will bind/listen to. - -| jmx.port -| The port number the MBean Server will bind/listen to. -|=== - -To access from a remote location, it has been reported that `-Dcom.sun.management.jmxremote.ssl=false` is needed as -a JVM argument. - -== JMX Security - -In order to set up JMX authentication, we need to put `jmxremote.password` and `jmxremote.access` file -to `/conf` directory. - -- `jmxremote.password`: define the username and password, that will be used by the client (here is james-cli) - -File's content example: -``` -james-admin pass1 -``` - -- `jmxremote.access`: define the pair of username and access permission - -File's content example: -``` -james-admin readwrite -``` - -When James runs with option `-Djames.jmx.credential.generation=true`, James will automatically generate `jmxremote.password` if the file does not exist. -Then the default username is `james-admin` and a random password. This option defaults to true. - -=== James-cli - -When the JMX server starts with authentication configuration, it will require the client need provide username/password for bypass. -To do that, we need set arguments `-username` and `-password` for the command request. - -Command example: -``` -james-cli -h 127.0.0.1 -p 9999 -username james-admin -password pass1 listdomains -``` - +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +include::partial$configure/jmx.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/jvm.adoc b/docs/modules/servers/pages/distributed/configure/jvm.adoc index 170869594b9..cbb3998dc41 100644 --- a/docs/modules/servers/pages/distributed/configure/jvm.adoc +++ b/docs/modules/servers/pages/distributed/configure/jvm.adoc @@ -1,105 +1,5 @@ = Distributed James Server — jvm.properties :navtitle: jvm.properties -This file may contain any additional system properties for tweaking JVM execution. When you normally would add a command line option `-Dmy.property=whatever`, you can put it in this file as `my.property=whatever` instead. These properties will be added as system properties on server start. - -Note that in some rare cases this might not work, -when a property affects very early JVM start behaviour. - -For testing purposes, you may specify a different file path via the command line option `-Dextra.props=/some/other/jvm.properties`. - -== Control the threshold memory -This governs the threshold MimeMessageInputStreamSource relies on for storing MimeMessage content on disk. - -In `jvm.properties` ----- -james.message.memory.threshold=12K ----- - -(Optional). String (size, integer + size units, example: `12 KIB`, supported units are bytes KIB MIB GIB TIB). Defaults to 100KIB. - -== Enable the copy of message in memory -Should MimeMessageWrapper use a copy of the message in memory? Or should bigger message exceeding james.message.memory.threshold -be copied to temporary files? - ----- -james.message.usememorycopy=true ----- - -Optional. Boolean. Defaults to false. Recommended value is false. - -== Running resource leak detection -It is used to detect a resource not be disposed of before it's garbage-collected. - -In `jvm.properties` ----- -james.lifecycle.leak.detection.mode=advanced ----- - -Allowed mode values are: none, simple, advanced, testing - -The purpose of each mode is introduced in `config-system.xml` - -== Disabling host information in protocol MDC logging context - -Should we add the host in the MDC logging context for incoming IMAP, SMTP, POP3? Doing so, a DNS resolution -is attempted for each incoming connection, which can be costly. Remote IP is always added to the logging context. - - -In `jvm.properties` ----- -james.protocols.mdc.hostname=false ----- - -Optional. Boolean. Defaults to true. - -== Change the encoding type used for the blobId - -By default, the blobId is encoded in base64 url. The property `james.blob.id.hash.encoding` allows to change the encoding type. -The support value are: base16, hex, base32, base32Hex, base64, base64Url. - -Ex in `jvm.properties` ----- -james.blob.id.hash.encoding=base16 ----- - -Optional. String. Defaults to base64Url. - -== JMAP Quota draft compatibility - -Some JMAP clients depend on the JMAP Quota draft specifications. The property `james.jmap.quota.draft.compatibility` allows -to enable JMAP Quota draft compatibility for those clients and allow them a time window to adapt to the RFC-9245 JMAP Quota. - -Optional. Boolean. Default to false. - -Ex in `jvm.properties` ----- -james.jmap.quota.draft.compatibility=true ----- -To enable the compatibility. - -== Enable S3 metrics - -James supports extracting some S3 client-level metrics e.g. number of connections being used, time to acquire an S3 connection, total time to finish a S3 request... - -The property `james.s3.metrics.enabled` allows to enable S3 metrics collection. Please pay attention that enable this -would impact a bit on S3 performance. - -Optional. Boolean. Default to true. - -Ex in `jvm.properties` ----- -james.s3.metrics.enabled=false ----- -To disable the S3 metrics. - -== Reactor Stream Prefetch - -Prefetch to use in Reactor to stream convertions (S3 => InputStream). Default to 1. -Higher values will tend to block less often at the price of higher memory consumptions. - -Ex in `jvm.properties` ----- -# james.reactor.inputstream.prefetch=4 ----- - +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +include::partial$configure/jvm.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/listeners.adoc b/docs/modules/servers/pages/distributed/configure/listeners.adoc index 57d7772fba3..d0cf02d8482 100644 --- a/docs/modules/servers/pages/distributed/configure/listeners.adoc +++ b/docs/modules/servers/pages/distributed/configure/listeners.adoc @@ -1,77 +1,9 @@ = Distributed James Server — listeners.xml :navtitle: listeners.xml -Distributed James relies on an event bus system to enrich mailbox capabilities. Each -operation performed on the mailbox will trigger related events, that can -be processed asynchronously by potentially any James node on a -distributed system. - -Mailbox listeners can register themselves on this event bus system to be -called when an event is fired, allowing to do different kind of extra -operations on the system. - -Distributed James allows the user to register potentially user defined additional mailbox listeners. - -Consult this link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/listener.xml[example] -to get some examples and hints. - -== Configuration - -The controls whether to launch group mailbox listener consumption. Defaults to true. Use with caution: -never disable on standalone james servers, and ensure at least some instances do consume group mailbox listeners within a -clustered topology. - -Mailbox listener configuration is under the XML element . - -Some MailboxListener allows you to specify if you want to run them synchronously or asynchronously. To do so, -for MailboxListener that supports this, you can use the *async* attribute (optional, per mailet default) to govern the execution mode. -If *true* the execution will be scheduled in a reactor elastic scheduler. If *false*, the execution is synchronous. - -Already provided additional listeners are documented below. - -=== SpamAssassinListener - -Provides per user real-time HAM/SPAM feedback to a SpamAssassin server depending on user actions. - -This mailet is asynchronous by default, but this behaviour can be overridden by the *async* -configuration property. - -This MailboxListener is supported. - -Example: - -.... - - - - org.apache.james.mailbox.spamassassin.SpamAssassinListener - - -.... - -Please note that a `spamassassin.properties` file is needed. Read also -xref:distributed/configure/spam.adoc[this page] for extra configuration required to support this feature. - -=== RspamdListener - -Provides HAM/SPAM feedback to a Rspamd server depending on user actions. - -This MailboxListener is supported. - -Example: - -.... - - - - org.apache.james.rspamd.RspamdListener - - -.... - -Please note that a `rspamd.properties` file is needed. Read also -xref:distributed/configure/spam.adoc[this page] for extra configuration required to support this feature. - +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +:server-name: Distributed James Server +include::partial$configure/listeners.adoc[] === MailboxOperationLoggingListener @@ -81,6 +13,7 @@ This MailboxListener is supported. Example: +[source,xml] .... @@ -89,85 +22,3 @@ Example: .... - -=== QuotaThresholdCrossingListener - -Sends emails to users exceeding 80% and 99% of their quota to warn them (for instance). - -Here are the following properties you can configure: - -.QuotaThresholdCrossingListener configuration properties -|=== -| Property name | explanation - -| name -| Useful when configuring several time this listener. You might want to do so to use different rendering templates for -different occupation thresholds. - -| gracePeriod -| Period during which no more email for a given threshold should be sent. - -| subjectTemplate -| Mustache template for rendering the subject of the warning email. - -| bodyTemplate -| Mustache template for rendering the body of the warning email. - -| thresholds -| Floating number between 0 and 1 representing the threshold of quota occupation from which a mail should be sent. -Configuring several thresholds is supported. - -|=== - -Example: - -.... - - - - org.apache.james.mailbox.quota.mailing.listeners.QuotaThresholdCrossingListener - QuotaThresholdCrossingListener-upper-threshold - - - - 0.8 - - - thirst - conf://templates/QuotaThresholdMailSubject.mustache - conf://templates/QuotaThresholdMailBody.mustache - 1week/ - - - -.... - -Here are examples of templates you can use: - -* For subject template: `conf://templates/QuotaThresholdMailSubject.mustache` - -.... -Warning: Your email usage just exceeded a configured threshold -.... - -* For body template: `conf://templates/QuotaThresholdMailBody.mustache` - -.... -You receive this email because you recently exceeded a threshold related to the quotas of your email account. - -{{#hasExceededSizeThreshold}} -You currently occupy more than {{sizeThreshold}} % of the total size allocated to you. -You currently occupy {{usedSize}}{{#hasSizeLimit}} on a total of {{limitSize}} allocated to you{{/hasSizeLimit}}. - -{{/hasExceededSizeThreshold}} -{{#hasExceededCountThreshold}} -You currently occupy more than {{countThreshold}} % of the total message count allocated to you. -You currently have {{usedCount}} messages{{#hasCountLimit}} on a total of {{limitCount}} allowed for you{{/hasCountLimit}}. - -{{/hasExceededCountThreshold}} -You need to be aware that actions leading to exceeded quotas will be denied. This will result in a degraded service. -To mitigate this issue you might reach your administrator in order to increase your configured quota. You might also delete some non important emails. -.... - -This MailboxListener is supported. - diff --git a/docs/modules/servers/pages/distributed/configure/mailetcontainer.adoc b/docs/modules/servers/pages/distributed/configure/mailetcontainer.adoc index f9e1722d7fb..e996c276805 100644 --- a/docs/modules/servers/pages/distributed/configure/mailetcontainer.adoc +++ b/docs/modules/servers/pages/distributed/configure/mailetcontainer.adoc @@ -1,96 +1,6 @@ = Distributed James Server — mailetcontainer.xml :navtitle: mailetcontainer.xml -This documents explains how to configure Mail processing. Mails pass through the MailetContainer. The -MailetContainer is a Matchers (condition for executing a mailet) and Mailets (execution units that perform -actions based on incoming mail) pipeline arranged into processors (List of mailet/matcher pairs allowing -better logical organisation). You can read more about these concepts on -xref:distributed/architecture/index.adoc#_mail_processing[the mailet container feature description]. - -Apache James Server includes a number of xref:distributed/configure/mailets.adoc[Packaged Mailets] and -xref:distributed/configure/matchers.adoc[Packaged Matchers]. - -Furthermore, you can write and use with James xref:customization:mail-processing.adoc[your own mailet and matchers]. - -Consult this link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/mailetcontainer.xml[example] -to get some examples and hints. - -.mailetcontainer.xml content -|=== -| Property name | explanation - -| context.postmaster -| The body of this element is the address that the server -will consider its postmaster address. This address will be listed as the sender address -of all error messages that originate from James. Also, all messages addressed to -postmaster@, where is one of the domain names whose -mail is being handled by James, will be redirected to this email address. -Set this to the appropriate email address for error reports -If this is set to a non-local email address, the mail server -will still function, but will generate a warning on startup. - -| spooler.threads -| Number of simultaneous threads used to spool the mails. Set to zero, it disables mail processing - use with -caution. - -| spooler.errorRepository -| Mail repository to store email in after several unrecoverable errors. Mails failing processing, for which -the Mailet Container could not handle Error, will be stored there after their processing had been attempted -5 times. Note that if standard java Exception occurs, *Error handling* section below will be applied -instead. -|=== - -== The Mailet Tag - -Consider the following simple *mailet* tag:

- -.... - - spam - -.... - -The mailet tag has two required attributes, *match* and *class*. - -The *match* attribute is set to the value of the specific Matcher class to be instantiated with a an -optional argument. If present, the argument is separated from the Matcher class name by an '='. Semantic -interpretation of the argument is left to the particular mailet. - -The *class* attribute is set to the value of the Mailet class that is to be instantiated. - -Finally, the children of the *mailet* tag define the configuration that is passed to the Mailet. The -tags used in this section should have no attributes or children. The names and bodies of the elements will be passed to -the mailet as (name, value) pairs. - -So in the example above, a Matcher instance of RemoteAddrNotInNetwork would be instantiated, and the value "127.0.0.1" -would be passed to the matcher. The Mailet of the pair will be an instance of ToProcessor, and it will be passed the (name, value) -pair of ("processor", "spam"). - -== Error handling - -If an exception is encountered during the execution of a mailet or a matcher, the default behaviour is to -process the mail using the *error* processor. - -The *onMailetException* property allows you to override this behaviour. You can specify another -processor than the *error* one for handling the errors of this mailet. - -The *ignore* special value also allows to continue processing and ignore the error. - -The *propagate* special value causes the mailet container to rethrow the -exception, propagating it to the execution context. In an SMTP execution context, the spooler will then requeue -the item and automatic retries will be setted up - note that attempts will be done for each recipients. In LMTP -(if LMTP is configured to execute the mailetContainer), the entire mail transaction is reported as failed to the caller. - -Moreover, the *onMatcherException* allows you to override matcher error handling. You can -specify another processor than the *error* one for handling the errors of this mailet. The *matchall* -special value also allows you to match all recipients when there is an error. The *nomatch* -special value also allows you to match no recipients when there is an error. - -Here is a short example to illustrate this: - -.... - - deliveryError - nomatch - -.... +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +:pages-path: distributed +include::partial$configure/mailetcontainer.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/mailets.adoc b/docs/modules/servers/pages/distributed/configure/mailets.adoc index cf19932da09..2426eae0657 100644 --- a/docs/modules/servers/pages/distributed/configure/mailets.adoc +++ b/docs/modules/servers/pages/distributed/configure/mailets.adoc @@ -1,151 +1,6 @@ = Distributed James Server — Mailets :navtitle: Mailets -This documentation page lists and documents Mailet that can be used within the -Distributed Server MailetContainer in order to write your own mail processing logic with out-of-the-box components. - -== Supported mailets - -include::partial$AddDeliveredToHeader.adoc[] - -include::partial$AddFooter.adoc[] - -include::partial$AddSubjectPrefix.adoc[] - -include::partial$AmqpForwardAttribute.adoc[] - -include::partial$Bounce.adoc[] - -include::partial$ContactExtractor.adoc[] - -include::partial$ConvertTo7Bit.adoc[] - -include::partial$DeconnectionRight.adoc[] - -include::partial$DKIMSign.adoc[] - -include::partial$DKIMVerify.adoc[] - -include::partial$DSNBounce.adoc[] - -include::partial$Expires.adoc[] - -include::partial$ExtractMDNOriginalJMAPMessageId.adoc[] - -include::partial$Forward.adoc[] - -include::partial$ICalendarParser.adoc[] - -include::partial$ICALToHeader.adoc[] - -include::partial$ICALToJsonAttribute.adoc[] - -include::partial$ICSSanitizer.adoc[] - -include::partial$LocalDelivery.adoc[] - -include::partial$LDAPMatchers.adoc[] - -include::partial$LogMessage.adoc[] - -include::partial$MailAttributesListToMimeHeaders.adoc[] - -include::partial$MailAttributesToMimeHeaders.adoc[] - -include::partial$MetricsMailet.adoc[] - -include::partial$MimeDecodingMailet.adoc[] - -include::partial$NotifyPostmaster.adoc[] - -include::partial$NotifySender.adoc[] - -include::partial$Null.adoc[] - -include::partial$PostmasterAlias.adoc[] - -include::partial$RandomStoring.adoc[] - -include::partial$RecipientRewriteTable.adoc[] - -include::partial$RecipientToLowerCase.adoc[] - -include::partial$Redirect.adoc[] - -include::partial$RemoteDelivery.adoc[] - -include::partial$RemoveAllMailAttributes.adoc[] - -include::partial$RemoveMailAttribute.adoc[] - -include::partial$RemoveMimeHeader.adoc[] - -include::partial$RemoveMimeHeaderByPrefix.adoc[] - -include::partial$ReplaceContent.adoc[] - -include::partial$Resend.adoc[] - -include::partial$SetMailAttribute.adoc[] - -include::partial$SetMimeHeader.adoc[] - -include::partial$Sieve.adoc[] - -include::partial$Sign.adoc[] - -include::partial$SMIMECheckSignature.adoc[] - -include::partial$SMIMEDecrypt.adoc[] - -include::partial$SMIMESign.adoc[] - -include::partial$SpamAssassin.adoc[] - -include::partial$StripAttachment.adoc[] - -include::partial$TextCalendarBodyToAttachment.adoc[] - -include::partial$ToProcessor.adoc[] - -include::partial$ToRepository.adoc[] - -include::partial$ToSenderDomainRepository.adoc[] - -include::partial$VacationMailet.adoc[] - -include::partial$WithPriority.adoc[] - -include::partial$WithStorageDirective.adoc[] - -== Experimental mailets - -include::partial$ClamAVScan.adoc[] - -include::partial$ClassifyBounce.adoc[] - -include::partial$FromRepository.adoc[] - -include::partial$HeadersToHTTP.adoc[] - -include::partial$OnlyText.adoc[] - -include::partial$ManageSieveMailet.adoc[] - -include::partial$RecoverAttachment.adoc[] - -include::partial$SerialiseToHTTP.adoc[] - -include::partial$ServerTime.adoc[] - -include::partial$SPF.adoc[] - -include::partial$ToPlainText.adoc[] - -include::partial$ToSenderFolder.adoc[] - -include::partial$UnwrapText.adoc[] - -include::partial$UseHeaderRecipients.adoc[] - -include::partial$WrapText.adoc[] \ No newline at end of file +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +:server-name: Distributed James Server +include::partial$configure/mailets.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/mailrepositorystore.adoc b/docs/modules/servers/pages/distributed/configure/mailrepositorystore.adoc index b897530eacc..6968de99ba6 100644 --- a/docs/modules/servers/pages/distributed/configure/mailrepositorystore.adoc +++ b/docs/modules/servers/pages/distributed/configure/mailrepositorystore.adoc @@ -1,35 +1,9 @@ = Distributed James Server — mailrepositorystore.xml -A `mail repository` allows storage of a mail as part of its -processing. Standard configuration relies on the following mail -repository. - -A mail repository is identified by its *url*, constituted of a *protocol* and a *path*. - -For instance in the url `cassandra://var/mail/error/` `cassandra` is the protocol and `var/mail/error` the path. - -The *mailrepositorystore.xml* file allows registration of available protocols, and their binding to actual MailRepository -implementation. Note that extension developers can write their own MailRepository implementations, load them via the -`extensions-jars` mechanism as documented in xref:customization:index.adoc['writing your own extensions'], and finally -associated to a protocol in *mailrepositorystore.xml* for a usage in *mailetcontainer.xml*. - -== Configuration - -Consult this link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/mailrepositorystore.xml[example] -to get some examples and hints. - -.... - - cassandra - - - - cassandra - - - - -.... - -Only the *CassandraMailRepository* is available by default for the Distributed Server. Mails metadata are stored in -Cassandra while the headers and bodies are stored within the xref:distributed/architecture/index.adoc#_blobstore[BlobStore]. +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +:pages-path: distributed +:server-name: Distributed James Server +:mailet-repository-path-prefix: cassandra +:mail-repository-protocol: cassandra +:mail-repository-class: org.apache.james.mailrepository.cassandra.CassandraMailRepository +include::partial$configure/mailrepositorystore.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/matchers.adoc b/docs/modules/servers/pages/distributed/configure/matchers.adoc index 2d85fc3465c..944b9e46a7a 100644 --- a/docs/modules/servers/pages/distributed/configure/matchers.adoc +++ b/docs/modules/servers/pages/distributed/configure/matchers.adoc @@ -1,166 +1,7 @@ = Distributed James Server — Matchers :navtitle: Matchers -This documentation page lists and documents Matchers that can be used within the -Distributed Server MailetContainer in order to write your own mail processing logic with out-of-the-box components. - -== Supported matchers - -include::partial$All.adoc[] - -include::partial$AtLeastPriority.adoc[] - -include::partial$AtMost.adoc[] - -include::partial$AtMostPriority.adoc[] - -include::partial$DLP.adoc[] - -include::partial$FetchedFrom.adoc[] - -include::partial$HasAttachment.adoc[] - -include::partial$HasException.adoc[] - -include::partial$HasHeader.adoc[] - -include::partial$HasHeaderWithPrefix.adoc[] - -include::partial$HasMailAttribute.adoc[] - -include::partial$HasMailAttributeWithValue.adoc[] - -include::partial$HasMailAttributeWithValueRegex.adoc[] - -include::partial$HasMimeType.adoc[] - -include::partial$HasMimeTypeParameter.adoc[] - -include::partial$HasPriority.adoc[] - -include::partial$HostIs.adoc[] - -include::partial$HostIsLocal.adoc[] - -include::partial$IsMarkedAsSpam.adoc[] - -include::partial$IsOverQuota.adoc[] - -include::partial$IsRemoteDeliveryPermanentError.adoc[] - -include::partial$IsRemoteDeliveryTemporaryError.adoc[] - -include::partial$IsSenderInRRTLoop.adoc[] - -include::partial$IsSingleRecipient.adoc[] - -include::partial$IsSMIMEEncrypted.adoc[] - -include::partial$IsSMIMESigned.adoc[] - -include::partial$IsX509CertificateSubject.adoc[] - -include::partial$RecipientDomainIs.adoc[] - -include::partial$RecipientIs.adoc[] - -include::partial$RecipientIsLocal.adoc[] - -include::partial$RecipientIsRegex.adoc[] - -include::partial$RelayLimit.adoc[] - -include::partial$RemoteAddrInNetwork.adoc[] - -include::partial$RemoteAddrNotInNetwork.adoc[] - -include::partial$RemoteDeliveryFailedWithSMTPCode.adoc[] - -include::partial$SenderDomainIs.adoc[] - -include::partial$SenderHostIs.adoc[] - -include::partial$SenderIs.adoc[] - -include::partial$SenderIsLocal.adoc[] - -include::partial$SenderIsNull.adoc[] - -include::partial$SenderIsRegex.adoc[] - -include::partial$SentByJmap.adoc[] - -include::partial$SentByMailet.adoc[] - -include::partial$SizeGreaterThan.adoc[] - -include::partial$SMTPAuthSuccessful.adoc[] - -include::partial$SMTPAuthUserIs.adoc[] - -include::partial$SMTPIsAuthNetwork.adoc[] - -include::partial$SubjectIs.adoc[] - -include::partial$SubjectStartsWith.adoc[] - -include::partial$TooManyRecipients.adoc[] - -include::partial$UserIs.adoc[] - -include::partial$XOriginatingIpInNetwork.adoc[] - -== Experimental matchers - -include::partial$AttachmentFileNameIs.adoc[] - -include::partial$CommandForListserv.adoc[] - -include::partial$CommandListservMatcher.adoc[] - -include::partial$CompareNumericHeaderValue.adoc[] - -include::partial$FileRegexMatcher.adoc[] - -include::partial$InSpammerBlacklist.adoc[] - -include::partial$NESSpamCheck.adoc[] - -include::partial$SenderInFakeDomain.adoc[] - -== Composite matchers - -It is possible to combine together matchers in order to create a composite matcher, thus simplifying your -Mailet Container logic. - -Here are the available logical operations: - -* *And* : This matcher performs And conjunction between the two matchers: recipients needs to match both matcher in order to -match the composite matcher. -* *Or* : This matcher performs Or conjunction between the two matchers: consider it to be a union of the results. -It returns recipients from the Or composition results of the child matchers. -* *Not* : It returns recipients from the negated composition of the child Matcher(s). Consider what wasn't -in the result set of each child matcher. Of course it is easier to understand if it only -includes one matcher in the composition, the normal recommended use. -* *Xor* : It returns Recipients from the Xor composition of the child matchers. Consider it to be the inequality -operator for recipients. If any recipients match other matcher results -then the result does not include that recipient. - -Here is the syntax to adopt in *mailetcontainer.xml*: - -.... - - - - - - - - - - - - relay - - -.... \ No newline at end of file +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +:pages-path: distributed +:server-name: Distributed James Server +include::partial$configure/matchers.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/opensearch.adoc b/docs/modules/servers/pages/distributed/configure/opensearch.adoc index c46cd31e86f..2144b928508 100644 --- a/docs/modules/servers/pages/distributed/configure/opensearch.adoc +++ b/docs/modules/servers/pages/distributed/configure/opensearch.adoc @@ -1,320 +1,8 @@ = Distributed James Server — opensearch.properties :navtitle: opensearch.properties -Consult this link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/opensearch.properties[example] -to get some examples and hints. - -If you want more explanation about OpenSearch configuration, you should visit the dedicated https://opensearch.org/[documentation]. - -== OpenSearch Configuration - -This file section is used to configure the connection tp an OpenSearch cluster. - -Here are the properties allowing to do so : - -.opensearch.properties content -|=== -| Property name | explanation - -| opensearch.clusterName -| Is the name of the cluster used by James. - -| opensearch.nb.shards -| Number of shards for index provisionned by James - -| opensearch.nb.replica -| Number of replica for index provisionned by James (default: 0) - -| opensearch.index.waitForActiveShards -| Wait for a certain number of active shard copies before proceeding with the operation. Defaults to 1. -You may consult the https://www.elastic.co/guide/en/elasticsearch/reference/7.10/docs-index_.html#active-shards[documentation] for more information. - -| opensearch.retryConnection.maxRetries -| Number of retries when connecting the cluster - -| opensearch.retryConnection.minDelay -| Minimum delay between connection attempts - -| opensearch.max.connections -| Maximum count of HTTP connections allowed for the OpenSearch driver. Optional integer, if unspecified driver defaults -applies (30 connections). - -| opensearch.max.connections.per.hosts -| Maximum count of HTTP connections per host allowed for the OpenSearch driver. Optional integer, if unspecified driver defaults -applies (10 connections). - -|=== - -=== Mailbox search - -The main use of OpenSearch within the Distributed Server is indexing the mailbox content of users in order to enable -powerful and efficient full-text search of the mailbox content. - -Data indexing is performed asynchronously in a reliable fashion via a MailboxListener. - -Here are the properties related to the use of OpenSearch for Mailbox Search: - -.opensearch.properties content -|=== -| Property name | explanation - -| opensearch.index.mailbox.name -| Name of the mailbox index backed by the alias. It will be created if missing. - -| opensearch.index.name -| *Deprecated* Use *opensearch.index.mailbox.name* instead. -Name of the mailbox index backed by the alias. It will be created if missing. - -| opensearch.alias.read.mailbox.name -| Name of the alias to use by Apache James for mailbox reads. It will be created if missing. -The target of the alias is the index name configured above. - -| opensearch.alias.read.name -| *Deprecated* Use *opensearch.alias.read.mailbox.name* instead. -Name of the alias to use by Apache James for mailbox reads. It will be created if missing. -The target of the alias is the index name configured above. - -| opensearch.alias.write.mailbox.name -| Name of the alias to use by Apache James for mailbox writes. It will be created if missing. -The target of the alias is the index name configured above. - -| opensearch.alias.write.name -| *Deprecated* Use *opensearch.alias.write.mailbox.name* instead. -Name of the alias to use by Apache James for mailbox writes. It will be created if missing. -The target of the alias is the index name configured above. - -| opensearch.indexAttachments -| Indicates if you wish to index attachments or not (default: true). - -| opensearch.indexHeaders -| Indicates if you wish to index headers or not (default: true). Note that specific headers -(From, To, Cc, Bcc, Subject, Message-Id, Date, Content-Type) are still indexed in their dedicated type. -Header indexing is expensive as each header currently need to be stored as a nested document but -turning off headers indexing result in non-strict compliance with the IMAP / JMAP standards. - -| opensearch.message.index.optimize.move -| When set to true, James will attempt to reindex from the indexed message when moved. -If the message is not found, it will fall back to the old behavior (The message will be indexed from the blobStore source) -Default to false. - -| opensearch.text.fuzziness.search -| Use fuzziness on text searches. This option helps to correct user typing mistakes and makes the result a bit more flexible. - -Default to false. - -| opensearch.indexBody -| Indicates if you wish to index body or not (default: true). This can be used to decrease the performance cost associated with indexing. - -| opensearch.indexUser -| Indicates if you wish to index user or not (default: false). This can be used to have per user reports in OpenSearch Dashboards. - -|=== - -=== Quota search - -Users are indexed by quota usage, allowing operators a quick audit of users quota occupation. - -Users quota are asynchronously indexed upon quota changes via a dedicated MailboxListener. - -The following properties affect quota search : - -.opensearch.properties content -|=== -| Property name | explanation - -| opensearch.index.quota.ratio.name -| Specify the OpenSearch alias name used for quotas - -| opensearch.alias.read.quota.ratio.name -| Specify the OpenSearch alias name used for reading quotas - -| opensearch.alias.write.quota.ratio.name -| Specify the OpenSearch alias name used for writing quotas -|=== - -=== Disabling OpenSearch - -OpenSearch component can be disabled but consider it would make search feature to not work. In particular it will break JMAP protocol and SEARCH IMAP comment in an nondeterministic way. -This is controlled in the `search.properties` file via the `implementation` property (defaults -to `OpenSearch`). Setting this configuration parameter to `scanning` will effectively disable OpenSearch, no -further indexation will be done however searches will rely on the scrolling search, leading to expensive and longer -searches. Disabling OpenSearch requires no extra action, however -xref:distributed/operate/webadmin.adoc#_reindexing_all_mails[a full re-indexing]needs to be carried out when enabling OpenSearch. - -== SSL Trusting Configuration - -By default, James will use the system TrustStore to validate https server certificates, if the certificate on -ES side is already in the system TrustStore, you can leave the sslValidationStrategy property empty or set it to default. - -.opensearch.properties content -|=== -| Property name | explanation - -| opensearch.hostScheme.https.sslValidationStrategy -| Optional. Accept only *default*, *ignore*, *override*. Default is *default*. default: Use the default SSL TrustStore of the system. -ignore: Ignore SSL Validation check (not recommended). -override: Override the SSL Context to use a custom TrustStore containing ES server's certificate. - -|=== - -In some cases, you want to secure the connection from clients to ES by setting up a *https* protocol -with a self signed certificate. And you prefer to left the system ca-certificates un touch. -There are possible solutions to let the ES RestHighLevelClient to trust your self signed certificate. - -Second solution: importing a TrustStore containing the certificate into SSL context. -A certificate normally contains two parts: a public part in .crt file, another private part in .key file. -To trust the server, the client needs to be acknowledged that the server's certificate is in the list of -client's TrustStore. Basically, you can create a local TrustStore file containing the public part of a remote server -by execute this command: - -.... -keytool -import -v -trustcacerts -file certificatePublicFile.crt -keystore trustStoreFileName.jks -keypass fillThePassword -storepass fillThePassword -.... - -When there is a TrustStore file and the password to read, fill two options *trustStorePath* -and *trustStorePassword* with the TrustStore location and the password. ES client will accept -the certificate of ES service. - -.opensearch.properties content -|=== -| Property name | explanation - -| opensearch.hostScheme.https.trustStorePath -| Optional. Use it when https is configured in opensearch.hostScheme, and sslValidationStrategy is *override* -Configure OpenSearch rest client to use this trustStore file to recognize nginx's ssl certificate. -Once you chose *override*, you need to specify both trustStorePath and trustStorePassword. - -| opensearch.hostScheme.https.trustStorePassword -| Optional. Use it when https is configured in opensearch.hostScheme, and sslValidationStrategy is *override* -Configure OpenSearch rest client to use this trustStore file with the specified password. -Once you chose *override*, you need to specify both trustStorePath and trustStorePassword. - -|=== - -During SSL handshaking, the client can determine whether accept or reject connecting to a remote server by its hostname. -You can configure to use which HostNameVerifier in the client. - -.opensearch.properties content -|=== -| Property name | explanation - -| opensearch.hostScheme.https.hostNameVerifier -| Optional. Default is *default*. default: using the default hostname verifier provided by apache http client. -accept_any_hostname: accept any host (not recommended). - -|=== - -== Search overrides - -*Search overrides* allow resolution of predefined search queries against alternative sources of data -and allow bypassing OpenSearch. This is useful to handle most resynchronisation queries that -are simple enough to be resolved against Cassandra. - -Possible values are: - - `org.apache.james.mailbox.cassandra.search.AllSearchOverride` Some IMAP clients uses SEARCH ALL to fully list messages in - a mailbox and detect deletions. This is typically done by clients not supporting QRESYNC and from an IMAP perspective - is considered an optimisation as less data is transmitted compared to a FETCH command. Resolving such requests against - Cassandra is enabled by this search override and likely desirable. - - `org.apache.james.mailbox.cassandra.search.UidSearchOverride`. Same as above but restricted by ranges. - - `org.apache.james.mailbox.cassandra.search.DeletedSearchOverride`. Find deleted messages by looking up in the relevant Cassandra - table. - - `org.apache.james.mailbox.cassandra.search.DeletedWithRangeSearchOverride`. Same as above but limited by ranges. - - `org.apache.james.mailbox.cassandra.search.NotDeletedWithRangeSearchOverride`. List non deleted messages in a given range. - Lists all messages and filters out deleted message thus this is based on the following heuristic: most messages are not marked as deleted. - - `org.apache.james.mailbox.cassandra.search.UnseenSearchOverride`. List unseen messages in the corresponding cassandra projection. - -Please note that custom overrides can be defined here. `opensearch.search.overrides` allow specifying search overrides and is a -coma separated list of search override FQDNs. Default to none. - -EG: - ----- -opensearch.search.overrides=org.apache.james.mailbox.cassandra.search.AllSearchOverride,org.apache.james.mailbox.cassandra.search.DeletedSearchOverride, org.apache.james.mailbox.cassandra.search.DeletedWithRangeSearchOverride,org.apache.james.mailbox.cassandra.search.NotDeletedWithRangeSearchOverride,org.apache.james.mailbox.cassandra.search.UidSearchOverride,org.apache.james.mailbox.cassandra.search.UnseenSearchOverride ----- - -== Configure dedicated language analyzers for mailbox index - -OpenSearch supports various language analyzers out of the box: https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-lang-analyzer.html. - -James could utilize this to improve the user searching experience upon his language. - -While one could modify mailbox index mapping programmatically to customize this behavior, here we should just document a manual way to archive this without breaking our common index' mapping code. - -The idea is modifying mailbox index mappings with the target language analyzer as a JSON file, then submit it directly -to OpenSearch via cURL command to create the mailbox index before James start. Let's adapt dedicated language analyzers -where appropriate for the following fields: - -.Language analyzers propose change -|=== -| Field | Analyzer change - -| from.name -| `keep_mail_and_url` analyzer -> `keep_mail_and_url_language_a` analyzer - -| subject -| `keep_mail_and_url` analyzer -> `keep_mail_and_url_language_a` analyzer - -| to.name -| `keep_mail_and_url` analyzer -> `keep_mail_and_url_language_a` analyzer - -| cc.name -| `keep_mail_and_url` analyzer -> `keep_mail_and_url_language_a` analyzer - -| bcc.name -| `keep_mail_and_url` analyzer -> `keep_mail_and_url_language_a` analyzer - -| textBody -| `standard` analyzer -> `language_a` analyzer - -| htmlBody -| `standard` analyzer -> `language_a` analyzer - -| attachments.fileName -| `standard` analyzer -> `language_a` analyzer - -| attachments.textContent -| `standard` analyzer -> `language_a` analyzer - -|=== - -In there: - - - `keep_mail_and_url` and `standard` are our current analyzers for mailbox index. - - `language_a` analyzer: the built-in analyzer of OpenSearch. EG: `french` - - `keep_mail_and_url_language_a` analyzer: a custom of `keep_mail_and_url` analyzer with some language filters.Every language has -their own filters so please have a look at filters which your language need to add. EG which need to be added for French: ----- -"filter": { - "french_elision": { - "type": "elision", - "articles_case": true, - "articles": [ - "l", "m", "t", "qu", "n", "s", - "j", "d", "c", "jusqu", "quoiqu", - "lorsqu", "puisqu" - ] - }, - "french_stop": { - "type": "stop", - "stopwords": "_french_" - }, - "french_stemmer": { - "type": "stemmer", - "language": "light_french" - } -} ----- - -After modifying above proposed change, you should have a JSON file that contains new setting and mapping of mailbox index. Here -we provide https://github.com/apache/james-project/blob/master/mailbox/opensearch/example_french_index.json[a sample JSON for French language]. -If you want to customize that JSON file for your own language need, please make these modifications: - - - Replace the `french` analyzer with your built-in language (have a look at https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-lang-analyzer.html[built-in language analyzers]) - - Modify `keep_mail_and_url_french` analyzer' filters with your language filters, and customize the analyzer' name. - -Please change also `number_of_shards`, `number_of_replicas` and `index.write.wait_for_active_shards` values in the sample file according to your need. - -Run this cURL command with above JSON file to create `mailbox_v1` (Mailbox index' default name) index before James start: ----- -curl -X PUT ES_IP:ES_PORT/mailbox_v1 -H "Content-Type: application/json" -d @example_french_index.json ----- +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +:pages-path: distributed +:server-name: Distributed James Server +:package-tag: cassandra +include::partial$configure/opensearch.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/pop3.adoc b/docs/modules/servers/pages/distributed/configure/pop3.adoc index 43db960b86f..1179dadf079 100644 --- a/docs/modules/servers/pages/distributed/configure/pop3.adoc +++ b/docs/modules/servers/pages/distributed/configure/pop3.adoc @@ -1,77 +1,7 @@ = Distributed James Server — pop3server.xml :navtitle: pop3server.xml -Consult this link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/pop3server.xml[example] -to get some examples and hints. - -The POP3 service is controlled by a configuration block in the pop3server.xml. -The pop3server tag defines the boundaries of the configuration block. It encloses -all the relevant configuration for the POP3 server. The behavior of the POP service is -controlled by the attributes and children of this tag. - -This tag has an optional boolean attribute - *enabled* - that defines whether the service is active or not. -The value defaults to "true" if not present. - -The standard children of the pop3server tag are: - -.jmx.properties content -|=== -| Property name | explanation - -| bind -| Configure this to bind to a specific inetaddress. This is an optional integer value. -This value is the port on which this POP3 server is configured -to listen. If the tag or value is absent then the service -will bind to all network interfaces for the machine If the tag or value is omitted, -the value will default to the standard POP3 port, 11 -port 995 is the well-known/IANA registered port for POP3S ie over SSL/TLS -port 110 is the well-known/IANA registered port for Standard POP3 - -| connectionBacklog -| - -| tls -| Set to true to support STARTTLS or SSL for the Socket. -To create a new keystore execute: -`keytool -genkey -alias james -keyalg RSA -storetype PKCS12 -keystore /path/to/james/conf/keystore` -Please note that each POP3 server exposed on different port can specify its own keystore, independently from any other -TLS based protocols. Read xref:distributed/configure/ssl.adoc[SSL configuration page] for more information. - -| handler.helloName -| This is the name used by the server to identify itself in the POP3 -protocol. If autodetect is TRUE, the server will discover its -own host name and use that in the protocol. If discovery fails, -the value of 'localhost' is used. If autodetect is FALSE, James -will use the specified value. - -| handler.connectiontimeout -| Connection timeout in seconds - -| handler.connectionLimit -| Set the maximum simultaneous incoming connections for this service - -| handler.connectionLimitPerIP -| Set the maximum simultaneous incoming connections per IP for this service - -| handler.handlerchain -| This loads the core CommandHandlers. Only remove this if you really know what you are doing. - -| bossWorkerCount -| Set the maximum count of boss threads. Boss threads are responsible for accepting incoming POP3 connections -and initializing associated resources. Optional integer, by default, boss threads are not used and this responsibility is being dealt with -by IO threads. - -| ioWorkerCount -| Set the maximum count of IO threads. IO threads are responsible for receiving incoming POP3 messages and framing them -(split line by line). IO threads also take care of compression and SSL encryption. Their tasks are short-lived and non-blocking. -Optional integer, defaults to 2 times the count of CPUs. - -| maxExecutorCount -| Set the maximum count of worker threads. Worker threads takes care of potentially blocking tasks like executing POP3 requests. Optional integer, defaults to 16. - -| useEpoll -| true or false - If true uses native EPOLL implementation for Netty otherwise uses NIO. Defaults to false. - -| gracefulShutdown -| true or false - If true attempts a graceful shutdown, which is safer but can take time. Defaults to true. -|=== \ No newline at end of file +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +:pages-path: distributed +:server-name: Distributed James Server +include::partial$configure/pop3.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/queue.adoc b/docs/modules/servers/pages/distributed/configure/queue.adoc index ce2dfe2bff5..e9907a090a2 100644 --- a/docs/modules/servers/pages/distributed/configure/queue.adoc +++ b/docs/modules/servers/pages/distributed/configure/queue.adoc @@ -1,19 +1,5 @@ = Distributed James Server — queue.properties :navtitle: queue.properties -This configuration helps you configure mail queue you want to select. - -== Queue Configuration - -.queue.properties content -|=== -| Property name | explanation - -| mail.queue.choice -| Mail queue can be implemented by many type of message brokers: Pulsar, RabbitMQ,... This property will choose which mail queue you want, defaulting to RABBITMQ -|=== - -`mail.queue.choice` supports the following options: - -* You can specify the `RABBITMQ` if you want to choose RabbitMQ mail queue -* You can specify the `PULSAR` if you want to choose Pulsar mail queue +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +include::partial$configure/queue.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/rabbitmq.adoc b/docs/modules/servers/pages/distributed/configure/rabbitmq.adoc index f0871e0d5d1..3f183ed4684 100644 --- a/docs/modules/servers/pages/distributed/configure/rabbitmq.adoc +++ b/docs/modules/servers/pages/distributed/configure/rabbitmq.adoc @@ -1,137 +1,8 @@ = Distributed James Server — rabbitmq.properties :navtitle: rabbitmq.properties -This configuration helps you configure components using RabbitMQ. - -Consult this link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/rabbitmq.properties[example] -to get some examples and hints. - -== RabbitMQ Configuration - -.rabbitmq.properties content -|=== -| Property name | explanation - -| uri -| the amqp URI pointing to RabbitMQ server. If you use a vhost, specify it as well at the end of the URI. -Details about amqp URI format is in https://www.rabbitmq.com/uri-spec.html[RabbitMQ URI Specification] - -| management.uri -| the URI pointing to RabbitMQ Management Service. James need to retrieve some information about listing queues -from this service in runtime. -Details about URI format is in https://www.rabbitmq.com/management.html#usage-ui[RabbitMQ Management URI] - -| management.user -| username used to access management service - -| management.password -| password used to access management service - -| connection.pool.retries -| Configure retries count to retrieve a connection. Exponential backoff is performed between each retries. -Optional integer, defaults to 10 - -| connection.pool.min.delay.ms -| Configure initial duration (in ms) between two connection retries. Exponential backoff is performed between each retries. -Optional integer, defaults to 100 - -| channel.pool.retries -| Configure retries count to retrieve a channel. Exponential backoff is performed between each retries. -Optional integer, defaults to 3 - -| channel.pool.max.delay.ms -| Configure timeout duration (in ms) to obtain a rabbitmq channel. Defaults to 30 seconds. -Optional integer, defaults to 30 seconds. - -| channel.pool.size -| Configure the size of the channel pool. -Optional integer, defaults to 3 - -| driver.network.recovery.interval -| Optional, non-negative integer, default to 100ms. The interval (in ms) that RabbitMQ driver will automatic recovery wait before attempting to reconnect. See https://www.rabbitmq.com/client-libraries/java-api-guide#connection-recovery - -| ssl.enabled -| Is using ssl enabled -Optional boolean, defaults to false - -| ssl.management.enabled -| Is using ssl on management api enabled -Optional boolean, defaults to false - -| ssl.validation.strategy -| Configure the validation strategy used for rabbitmq connections. Possible values are default, ignore and override. -Optional string, defaults to using systemwide ssl configuration - -| ssl.truststore -| Points to the truststore (PKCS12) used for verifying rabbitmq connection. If configured then "ssl.truststore.password" must also be configured, -Optional string, defaults to systemwide truststore. "ssl.validation.strategy: override" must be configured if you want to use this - -| ssl.truststore.password -| Configure the truststore password. If configured then "ssl.truststore" must also be configured, -Optional string, defaults to empty string. "ssl.validation.strategy: override" must be configured if you want to use this - -| ssl.hostname.verifier -| Configure host name verification. Possible options are default and accept_any_hostname -Optional string, defaults to subject alternative name host verifier - -| ssl.keystore -| Points to the keystore(PKCS12) used for client certificate authentication. If configured then "ssl.keystore.password" must also be configured, -Optional string, defaults to empty string - -| ssl.keystore.password -| Configure the keystore password. If configured then "ssl.keystore" must also be configured, -Optional string, defaults to empty string - -| quorum.queues.enable -| Boolean. Whether to activate Quorum queue usage for all queues. -Quorum queues enables high availability. -False (default value) results in the usage of classic queues. - -| quorum.queues.replication.factor -| Strictly positive integer. The replication factor to use when creating quorum queues. - -| quorum.queues.delivery.limit -| Strictly positive integer. Value for x-delivery-limit queue parameter, default to none. Setting a delivery limit can -prevent RabbitMQ outage if message processing fails. Read https://www.rabbitmq.com/docs/quorum-queues#poison-message-handling - -| hosts -| Optional, default to the host specified as part of the URI. -Allow creating cluster aware connections. -A coma separated list of hosts, example: hosts=ip1:5672,ip2:5672 - -| mailqueue.publish.confirm.enabled -| Whether or not to enable publish confirms for the mail queue. Optional boolean, defaults to true. - -| event.bus.publish.confirm.enabled -| Whether or not to enable publish confirms for the event bus. Optional boolean, defaults to true. - -| event.bus.notification.durability.enabled -| Whether or not the queue backing notifications should be durable. Optional boolean, defaults to true. - -| event.bus.propagate.dispatch.error -| Whether to propagate errors back to the callers when eventbus fails to dispatch group events to RabbitMQ (then store the failed events in the event dead letters). -Optional boolean, defaults to true. - -| vhost -| Optional string. This parameter is only a workaround to support invalid URIs containing character like '_'. -You still need to specify the vhost in the uri parameter. - -|=== - -== Tuning RabbitMQ for quorum queue use - -While quorum queues are great at preserving your data and enabling High Availability, they demand more resources and -a greater care than regular RabbitMQ queues. - -See link:https://www.rabbitmq.com/docs/quorum-queues#performance-tuning[this section of RabbitMQ documentation regarding RabbitMQ quroum queue performance tunning]. - - - Provide decent amount of RAM memory to RabbitMQ. 4GB is a good start. - - Setting a delivery limit is advised as looping messages can cause extreme memory consumptions onto quorum queues. - - Set up Raft for small messages: - -.... -raft.segment_max_entries = 32768 -.... +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +include::partial$configure/rabbitmq.adoc[] == RabbitMQ MailQueue Configuration @@ -153,19 +24,19 @@ Not necessarily needed for MDA deployments, mail queue management adds significa | mailqueue.view.sliceWindow | James divides the view into slices, each slice contains data for a given period, sliceWindow parameter controls this period. This dividing of periods allows faster browsing of the mail queue. Tips for choosing sliceWindow are explained in -https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/rabbitmq.properties[rabbitmq.properties] +{sample-configuration-prefix-url}/rabbitmq.properties[rabbitmq.properties] | mailqueue.view.bucketCount | Mails in a mail queue are distributed across the underlying storage service. BucketCount describes how to be distributing mails to fit with your James setup Tips for choosing bucketCount are explained in -https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/rabbitmq.properties[rabbitmq.properties] +{sample-configuration-prefix-url}/rabbitmq.properties[rabbitmq.properties] | mailqueue.view.updateBrowseStartPace | To browse, James needs a starting point and to continuously update that point in runtime. UpdateBrowseStartPace describes the probability to update the starting point. Tips for choosing updateBrowseStartPace are explained in -https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/rabbitmq.properties[rabbitmq.properties] +{sample-configuration-prefix-url}/rabbitmq.properties[rabbitmq.properties] | mailqueue.size.metricsEnabled | By default, the metrics are disabled for the mail queue size. @@ -173,7 +44,7 @@ As computing the size of the mail queue is currently implemented on top of brows sometimes it can get too big, making it impossible for the ES reporter to handle it correctly without crashing. It can be useful then to disable it. Tips for choosing metricsEnabled are explained in -https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/rabbitmq.properties[rabbitmq.properties] +{sample-configuration-prefix-url}/rabbitmq.properties[rabbitmq.properties] | notification.queue.ttl | Configure queue ttl (in ms). References: https://www.rabbitmq.com/ttl.html#queue-ttl. @@ -181,34 +52,3 @@ This is used only on queues used to share notification patterns, are exclusive t Optional integer, defaults is 3600000. |=== - -== RabbitMQ Tasks Configuration - -Tasks are WebAdmin triggered long running jobs. RabbitMQ is used to organise their execution in a work queue, -with an exclusive consumer. - -.rabbitmq.properties content -|=== -| Property name | explanation - -| task.consumption.enabled -| Whether to enable task consumption on this node. -Disable with caution (this only makes sense in a distributed setup where other nodes consume tasks). -Defaults to true. - -Limitation: Sometimes, some tasks running on James can be very heavy and take a couple of hours to complete. -If other tasks are being triggered meanwhile on WebAdmin, they go on the TaskManagerWorkQueue and James unack them, -telling RabbitMQ it will consume them later. If they don't get consumed before the consumer timeout setup in -RabbitMQ (default being 30 minutes), RabbitMQ closes the channel on an exception. It is thus advised to declare a -longer timeout in rabbitmq.conf. More https://www.rabbitmq.com/consumers.html#acknowledgement-timeout[here]. - -| task.queue.consumer.timeout -| Task queue consumer timeout. - -Optional. Duration (support multiple time units cf `DurationParser`), defaults to 1 day. - -Required at least RabbitMQ version 3.12 to have effect. -This is used to avoid the task queue consumer (which could run very long tasks) being disconnected by RabbitMQ after the default acknowledgement timeout 30 minutes. -References: https://www.rabbitmq.com/consumers.html#acknowledgement-timeout. - -|=== \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/recipientrewritetable.adoc b/docs/modules/servers/pages/distributed/configure/recipientrewritetable.adoc index 108e09e56fc..983756ca61c 100644 --- a/docs/modules/servers/pages/distributed/configure/recipientrewritetable.adoc +++ b/docs/modules/servers/pages/distributed/configure/recipientrewritetable.adoc @@ -1,18 +1,7 @@ = Distributed James Server — recipientrewritetable.xml :navtitle: recipientrewritetable.xml -Here are explanations on the different kinds about xref:distributed/architecture/index.adoc#_recipient_rewrite_tables[recipient rewriting]. - -Consult this link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/recipientrewritetable.xml[example] -to get some examples and hints. - -.recipientrewritetable.xml -|=== -| Property name | explanation - -| recursiveMapping -| If set to false only the first mapping will get processed - Default true. - -| mappingLimit -|By setting the mappingLimit you can specify how much mapping will get processed before a bounce will send. This avoids infinity loops. Default 10. -|=== +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +:pages-path: distributed +:server-name: Distributed James Server +include::partial$configure/recipientrewritetable.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/redis.adoc b/docs/modules/servers/pages/distributed/configure/redis.adoc index 0d318b89cee..659ca53b354 100644 --- a/docs/modules/servers/pages/distributed/configure/redis.adoc +++ b/docs/modules/servers/pages/distributed/configure/redis.adoc @@ -1,47 +1,5 @@ = Distributed James Server — redis.properties :navtitle: redis.properties -This configuration helps you configure components using Redis. This so far only includes optional rate limiting component. - -Consult this link:https://github.com/apache/james-project/blob/fabfdf4874da3aebb04e6fe4a7277322a395536a/server/mailet/rate-limiter-redis/redis.properties[example] -to get some examples and hints. - -== Redis Configuration - -.redis.properties content -|=== -| Property name | explanation - -| redisURL -| the Redis URI pointing to Redis server. Compulsory. - -| redis.topology -| Redis server topology. Defaults to standalone. Possible values: standalone, cluster, master-replica - -| redis.readFrom -| The property to determine how Lettuce routes read operations to Redis server with topologies other than standalone. Defaults to master. Possible values: master, masterPreferred, replica, replicaPreferred, any - -Reference: https://github.com/redis/lettuce/wiki/ReadFrom-Settings - -| redis.ioThreads -| IO threads to be using for the underlying Netty networking resources. If unspecified driver defaults applies. - -| redis.workerThreads -| Worker threads to be using for the underlying driver. If unspecified driver defaults applies. -|=== - -== Enabling Multithreading in Redis - -Redis 6 and later versions support multithreading, but by default, Redis operates as a single-threaded process. - -On a virtual machine with multiple CPU cores, you can enhance Redis performance by enabling multithreading. This can significantly improve I/O operations, particularly for workloads with high concurrency or large data volumes. - -See link:https://redis.io/docs/latest/operate/oss_and_stack/management/config-file/[THREADED I/O section]. - -Example if you have a 4 cores CPU, you can enable the following lines in the `redis.conf` file: -.... -io-threads 3 -io-threads-do-reads yes -.... - -However, if your machine has only 1 CPU core or your Redis usage is not intensive, you will not benefit from this. \ No newline at end of file +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +include::partial$configure/redis.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/remote-delivery-error-handling.adoc b/docs/modules/servers/pages/distributed/configure/remote-delivery-error-handling.adoc index 55764e7a5d6..68efbdb38f6 100644 --- a/docs/modules/servers/pages/distributed/configure/remote-delivery-error-handling.adoc +++ b/docs/modules/servers/pages/distributed/configure/remote-delivery-error-handling.adoc @@ -1,117 +1,8 @@ = Distributed James Server — About RemoteDelivery error handling :navtitle: About RemoteDelivery error handling -The advanced server mailQueue implemented by combining RabbitMQ for messaging and Cassandra for administrative operation -does not support delays. - -Delays are an important feature for Mail Exchange servers, allowing to defer in time the retries, potentially letting the -time for the remote server to recover. Furthermore, they enable implementation of advanced features like throttling and -rate limiting of emails sent to a given domain. - -As such, the use of the distributed server as a Mail Exchange server is currently discouraged. - -However, for operators willing to inter-operate with a limited set of well-identified, trusted remote mail servers, such -limitation can be reconsidered. The main concern then become error handling for remote mail server failures. The following -document will present a well tested strategy for Remote Delivery error handling leveraging standards Mail Processing components -and mechanisms. - -== Expectations - -Such a solution should: - -- Attempt delivery a single time -- Store transient and permanent failure in different mail repositories -- After a given number of tries, transient failures should be considered permanent - -== Design - -image::remote-delivery-error-handling.png[Schema detailing the proposed solution] - -- Remote Delivery is configured for performing a single retry. -- Remote Delivery attaches the error code and if the failure is permanent/temporary when transferring failed emails to the -bounce processor. -- The specified bounce processor will categorise the failure, and store temporary and permanent failures in different -mail repositories. -- A reprocessing of the temporary delivery errors mailRepository needs to be scheduled in a recurring basis. For -instance via a CRON job calling the right webadmin endpoint. -- A counter ensures that a configured number of delivery tries is not exceeded. - -=== Limitation - -MailRepositories are not meant for transient data storage, and thus are prone to tombstone issues. - -This might be acceptable if you need to send mail to well-known peers. For instance handling your mail gateway failures. -However a Mail Exchange server doing relay on the internet would quickly hit this limitation. - -Also note that external triggering of the retry process is needed. - -== Operation - -Here is an example of configuration achieving the proposed solution: - -.... - - - - outgoing - 0 - 0 - 10 - true - - remote-delivery-error - - - - cassandra://var/mail/error/remote-delivery/permanent/ - - - - - - - cassandra://var/mail/error/remote-delivery/temporary/ - - - - cassandra://var/mail/error/remote-delivery/permanent/ - - - - cassandra://var/mail/error/ - - -.... - -Note: - -- The *relay* processor holds a RemoteDelivery mailet configured to do a single try, at most 5 times (see the AtMost matcher). -Mails exceeding the AtMost condition are considered as permanent delivery errors. Delivery errors are sent to the -*remote-delivery-error* processor. -- The *remote-delivery-error* stores temporary and permanent errors. -- Permanent relay errors are stored in `cassandra://var/mail/error/remote-delivery/permanent/`. -- Temporary relay errors are stored in `cassandra://var/mail/error/remote-delivery/temporary/`. - -In order to retry the relay of temporary failed emails, operators will have to configure a cron job for reprocessing -emails from *cassandra://var/mail/error/remote-delivery/temporary/* mailRepository into the *relay* processor. - -This can be achieved via the following webAdmin call : - -.... -curl -XPATCH 'http://ip:8000/mailRepositories/cassandra%3A%2F%2Fvar%2Fmail%2Ferror%2Fremote-delivery%2Ftemporary%2F/mails?action=reprocess&processor=relay' -.... - -See xref:distributed/operate/webadmin.adoc#_reprocessing_mails_from_a_mail_repository[the documentation]. - -Administrators need to keep a close eye on permanent errors (that might require audit, and potentially contacting the remote -service supplier). - -To do so, one should regularly audit the content of *cassandra://var/mail/error/remote-delivery/permanent/*. This can be done -via webAdmin calls: - -.... -curl -XGET 'http://ip:8000/mailRepositories/cassandra%3A%2F%2Fvar%2Fmail%2Ferror%2Fremote-delivery%2Ftemporary%2F/mails' -.... - -See xref:distributed/operate/webadmin.adoc#_listing_mails_contained_in_a_mail_repository[the documentation]. +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +:pages-path: distributed +:server-name: Distributed James Server +:mailet-repository-path-prefix: cassandra +include::partial$configure/remote-delivery-error-handling.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/search.adoc b/docs/modules/servers/pages/distributed/configure/search.adoc index 735b843bfa9..f4d5b156716 100644 --- a/docs/modules/servers/pages/distributed/configure/search.adoc +++ b/docs/modules/servers/pages/distributed/configure/search.adoc @@ -1,18 +1,5 @@ = Distributed James Server — Search configuration :navtitle: Search configuration -This configuration helps you configure the components used to back search. - -.search.properties content -|=== -| Property name | explanation - -| implementation -| The implementation to be used for search. Should be one of: - - *opensearch* : Index and search mails into OpenSearch. - - *scanning* : Do not index documents and perform scanning search, scrolling mailbox for matching contents. - This implementation can have a prohibitive cost. - - *opensearch-disabled* : Saves events to index into event dead letter. Make searches fails. - This is useful to start James without OpenSearch while still tracking messages to index for later recovery. This - can be used in order to ease delays for disaster recovery action plans. -|=== \ No newline at end of file +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +include::partial$configure/search.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/sieve.adoc b/docs/modules/servers/pages/distributed/configure/sieve.adoc index 3874f3c6c47..b3b3c4f16fa 100644 --- a/docs/modules/servers/pages/distributed/configure/sieve.adoc +++ b/docs/modules/servers/pages/distributed/configure/sieve.adoc @@ -1,92 +1,7 @@ = Sieve :navtitle: Sieve -James servers are able to evaluate and execute Sieve scripts. - -Sieve is an extensible mail filtering language. It's limited -expressiveness (no loops or variables, no tests with side -effects) allows user created scripts to be run safely on email -servers. Sieve is targeted at the final delivery phase (where -an incoming email is transferred to a user's mailbox). - -The following Sieve capabilities are supported by Apache James: - - - link:https://www.ietf.org/rfc/rfc2234.txt[RFC 2234 ABNF] - - link:https://www.ietf.org/rfc/rfc2244.txt[RFC 2244 ACAP] - - link:https://www.ietf.org/rfc/rfc2298.txt[RFC 2298 MDN] - - link:https://tools.ietf.org/html/rfc5228[RFC 5228 Sieve] - - link:https://tools.ietf.org/html/rfc4790[RFC 4790 IAPCR] - - link:https://tools.ietf.org/html/rfc5173[RFC 5173 Body Extension] - - link:https://datatracker.ietf.org/doc/html/rfc5230[RFC 5230 Vacations] - -To be correctly executed, please note that the *Sieve* mailet is required to be positioned prior the -*LocalDelivery* mailet. - -== Managing Sieve scripts - -A user willing to manage his Sieve scripts on the server can do so via several means: - -He can ask an admin to upload his script via the xref:distributed/operate/cli.adoc[CLI] - -As James supports ManageSieve (link:https://datatracker.ietf.org/doc/html/rfc5804[RFC-5804]) a user -can thus use compatible software to manage his Sieve scripts.

- -== ManageSieve protocol - -*WARNING*: ManageSieve protocol should be considered experimental. - -Consult link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/managesieveserver.xml[managesieveserver.xml] -in GIT to get some examples and hints. - -The service is controlled by a configuration block in the managesieveserver.xml. -The managesieveserver tag defines the boundaries of the configuration block. It encloses -all the relevant configuration for the ManageSieve server. The behavior of the ManageSieve service is -controlled by the attributes and children of this tag. - -This tag has an optional boolean attribute - *enabled* - that defines whether the service is active or not. -The value defaults to "false" if -not present. - -The standard children of the managesieveserver tag are: - -.managesieveserver.xml content -|=== -| Property name | explanation - -| bind -| Configure this to bind to a specific inetaddress. This is an optional integer value. This value is the port on which this ManageSieve server is configured to listen. If the tag or value is absent then the service -will bind to all network interfaces for the machine If the tag or value is omitted, the value will default to the standard ManageSieve port (port 4190 is the well-known/IANA registered port for ManageSieve.) - -| tls -| Set to true to support STARTTLS or SSL for the Socket. -To use this you need to copy sunjce_provider.jar to /path/james/lib directory. To create a new keystore execute: -`keytool -genkey -alias james -keyalg RSA -storetype PKCS12 -keystore /path/to/james/conf/keystore`. -Please note that each ManageSieve server exposed on different port can specify its own keystore, independently from any other -TLS based protocols. - -| connectionBacklog -| Number of connection backlog of the server (maximum number of queued connection requests) - -| connectiontimeout -| Connection timeout in seconds - -| connectionLimit -| Set the maximum simultaneous incoming connections for this service - -| connectionLimitPerIP -| Set the maximum simultaneous incoming connections per IP for this service - -| bossWorkerCount -| Set the maximum count of boss threads. Boss threads are responsible for accepting incoming ManageSieve connections -and initializing associated resources. Optional integer, by default, boss threads are not used and this responsibility is being dealt with -by IO threads. - -| ioWorkerCount -| Set the maximum count of IO threads. IO threads are responsible for receiving incoming ManageSieve messages and framing them -(split line by line). IO threads also take care of compression and SSL encryption. Their tasks are short-lived and non-blocking. -Optional integer, defaults to 2 times the count of CPUs. - -| maxExecutorCount -| Set the maximum count of worker threads. Worker threads takes care of potentially blocking tasks like executing ManageSieve commands. -Optional integer, defaults to 16. -|=== \ No newline at end of file +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +:pages-path: distributed +:server-name: Distributed James Server +include::partial$configure/sieve.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/smtp-hooks.adoc b/docs/modules/servers/pages/distributed/configure/smtp-hooks.adoc index 5dd48b0edce..45051231326 100644 --- a/docs/modules/servers/pages/distributed/configure/smtp-hooks.adoc +++ b/docs/modules/servers/pages/distributed/configure/smtp-hooks.adoc @@ -1,370 +1,7 @@ = Distributed James Server — SMTP Hooks :navtitle: SMTP Hooks -This documentation page lists and documents SMTP hooks that can be used within the -Distributed Server SMTP protocol stack in order to customize the way your SMTP server -behaves without of the box components. - -== DNSRBLHandler - -This command handler check against https://www.wikiwand.com/en/Domain_Name_System-based_Blackhole_List[RBL-Lists] -(Real-time Blackhole List). - -If getDetail is set to true it try to retrieve information from TXT Record -why the ip was blocked. Default to false. - -before you enable out the DNS RBL handler documented as an example below, -please take a moment to review each block in the list. -We have included some that various JAMES committers use, -but you must decide which, if any, are appropriate -for your environment. - -The mail servers hosting -@apache.org mailing lists, for example, use a -slightly different list than we have included below. -And it is likely that most JAMES committers also have -slightly different sets of lists. - -The SpamAssassin user's list would be one good place to discuss the -measured quality of various block lists. - -NOTA BENE: the domain names, below, are terminated -with '.' to ensure that they are absolute names in -DNS lookups. Under some circumstances, names that -are not explicitly absolute could be treated as -relative names, leading to incorrect results. This -has been observed on *nix and MS-Windows platforms -by users of multiple mail servers, and is not JAMES -specific. If you are unsure what this means for you, -please speak with your local system/network admins. - -This handler should be considered experimental. - -Example configuration: - -.... - - - - false - - query.bondedsender.org. - sbl-xbl.spamhaus.org. - dul.dnsbl.sorbs.net. - list.dsbl.org. - - - -.... - -== DSN hooks - -The Distributed server has optional support for DSN (link:https://tools.ietf.org/html/rfc3461[RFC-3461]) - -Please read carefully xref:distributed/configure/dsn.adoc[this page]. - -.... - - <...> - - - - - - <...> - - - -.... - -Note that a specific configuration of xref:distributed/configure/mailetcontainer.adoc[mailetcontainer.xml] is -required as well to be spec compliant. - -== MailPriorityHandler - -This handler can add a hint to the mail which tells the MailQueue which email should get processed first. - -Normally the MailQueue will just handle Mails in FIFO manner. - -Valid priority values are 1,5,9 where 9 is the highest. - -This handler should be considered experimental. - -Example configuration: - -.... - - - - - - yourdomain1 - 1 - - - yourdomain2 - 9 - - - - -.... - -== MaxRcptHandler -If activated you can limit the maximal recipients. - -This handler should be considered experimental. - -Example configuration: - -.... - - - - 10 - - -.... - -== POP3BeforeSMTPHandler - -This connect handler can be used to enable POP3 before SMTP support. - -Please note that only the ip get stored to identify an authenticated client. - -The expireTime is the time after which an ipAddress is handled as expired. - -This handler should be considered as unsupported. - -Example configuration: - -.... - - - - 1 hour - - -.... - -== ResolvableEhloHeloHandler - -Checks for resolvable HELO/EHLO before accept the HELO/EHLO. - -If checkAuthNetworks is set to true sender domain will be checked also for clients that -are allowed to relay. Default is false. - -This handler should be considered experimental. - -Example configuration: - -.... - - - - -.... - -== ReverseEqualsEhloHeloHandler - -Checks HELO/EHLO is equal the reverse of the connecting client before accept it -If checkAuthNetworks is set to true sender domain will be checked also for clients that -are allowed to relay. Default is false. - -This handler should be considered experimental. - -Example configuration: - -.... - - - - -.... - -== SetMimeHeaderHandler - -This handler allows you to add mime headers to the processed mails. - -This handler should be considered experimental. - -Example configuration: - -.... - - - - SPF-test - passed - - -.... - -== SpamAssassinHandler - -This MessageHandler could be used to check message against spamd before -accept the email. So it's possible to reject a message on smtplevel if a -configured hits amount is reached. - -This handler should be considered experimental. - -Example configuration: - -.... - - - - 127.0.0.1 - 783 - 10 - - -.... - -== SPFHandler - -This command handler can be used to reject emails with not match the SPF record of the sender domain. - -If checkAuthNetworks is set to true sender domain will be checked also for clients that -are allowed to relay. Default is false. - -This handler should be considered experimental. - -Example configuration: - -.... - - - - false - true - - -.... - -== URIRBLHandler - -This MessageHandler could be used to extract domain out of the message and check -this domains against uriRbllists. See http://www.surbl.org for more information. -The message get rejected if a domain matched. - -This handler should be considered experimental. - -Example configuration: - -.... - - - - reject - true - - multi.surbl.org - - - -.... - -== ValidRcptHandler - -With ValidRcptHandler, all email will get rejected which has no valid user. - -You need to add the recipient to the validRecipient list if you want -to accept email for a recipient which not exist on the server. - -If you want James to act as a spamtrap or honeypot, you may comment ValidRcptHandler -and implement the needed processors in spoolmanager.xml. - -This handler should be considered stable. - -Example configuration: - -.... - - - - -.... - -== ValidSenderDomainHandler - -If activated mail is only accepted if the sender contains -a resolvable domain having a valid MX Record or A Record associated! - -If checkAuthNetworks is set to true sender domain will be checked also for clients that -are allowed to relay. Default is false. - -Example configuration: - -.... - - - - -.... - -== FUTURERELEASE hooks - -The Distributed server has optional support for FUTURERELEASE (link:https://www.rfc-editor.org/rfc/rfc4865.html[RFC-4865]) - -.... - - <...> - - - - - - -.... - -== Message Transfer Priorities hooks - -The Distributed server has optional support for SMTP Extension for Message Transfer Priorities (link:https://www.rfc-editor.org/rfc/rfc6710.html[RFC-6710]) - -The SMTP server does not allow positive priorities from unauthorized sources and sets the priority to the default value (0). - -.... - - <...> - - - - - - - -.... - -== DKIM checks hooks - -Hook for verifying DKIM signatures of incoming mails. - -This hook can be restricted to specific sender domains and authenticate those emails against -their DKIM signature. Given a signed outgoing traffic this hook can use operators to accept legitimate -emails emitted by their infrastructure but redirected without envelope changes to there own domains by -some intermediate third parties. See link:https://issues.apache.org/jira/browse/JAMES-4032[JAMES-4032]. - -Supported configuration elements: - -- *forceCRLF*: Should CRLF be forced when computing body hashes. -- *onlyForSenderDomain*: If specified, the DKIM checks are applied just for the emails whose MAIL FROM specifies this domain. If unspecified, all emails are checked (default). -- *signatureRequired*: If DKIM signature is checked, the absence of signature will generate failure. Defaults to false. -- *expectedDToken*: If DKIM signature is checked, the body should contain at least one DKIM signature with this d token. If unspecified, all d tokens are considered valid (default). - -Example handlerchain configuration for `smtpserver.xml`: - -.... - - - true - apache.org - true - apache.org - - - -.... - -Would allow emails using `apache.org` as a MAIL FROM domain if, and only if they contain a -valid DKIM signature for the `apache.org` domain. \ No newline at end of file +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +:pages-path: distributed +:server-name: Distributed James Server +include::partial$configure/smtp-hooks.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/smtp.adoc b/docs/modules/servers/pages/distributed/configure/smtp.adoc index 34e22887143..85f0845c48a 100644 --- a/docs/modules/servers/pages/distributed/configure/smtp.adoc +++ b/docs/modules/servers/pages/distributed/configure/smtp.adoc @@ -1,316 +1,7 @@ = Distributed James Server — smtpserver.xml :navtitle: smtpserver.xml -== Incoming SMTP - -Consult this link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/smtpserver.xml[example] -to get some examples and hints. - -The SMTP service is controlled by a configuration block in the smptserver.xml. -The smtpserver tag defines the boundaries of the configuration block. It encloses -all the relevant configuration for the SMTP server. The behavior of the SMTP service is -controlled by the attributes and children of this tag. - -This tag has an optional boolean attribute - *enabled* - that defines whether the service is active or not. The value defaults to "true" if -not present. - -The standard children of the smtpserver tag are: - -.smtpserver.xml content -|=== -| Property name | explanation - -| bind -| A list of address:port separed by comma - This is an optional value. If present, this value is a string describing -the IP address to which this service should be bound. If the tag or value is absent then the service -will bind to all network interfaces for the machine on port 25. Port 25 is the well-known/IANA registered port for SMTP. -Port 465 is the well-known/IANA registered port for SMTP over TLS. - -| connectBacklog -|The IP address (host name) the MBean Server will bind/listen to. - -| tls -| Set to true to support STARTTLS or SSL for the Socket. -To use this you need to copy sunjce_provider.jar to /path/james/lib directory. To create a new keystore execute: -`keytool -genkey -alias james -keyalg RSA -storetype PKCS12 -keystore /path/to/james/conf/keystore`. -The algorithm is optional and only needs to be specified when using something other -than the Sun JCE provider - You could use IbmX509 with IBM Java runtime. -Please note that each SMTP/LMTP server exposed on different port can specify its own keystore, independently from any other -TLS based protocols. - -| helloName -| This is a required tag with an optional body that defines the server name -used in the initial service greeting. The tag may have an optional attribute - *autodetect*. If -the autodetect attribute is present and true, the service will use the local hostname -returned by the Java libraries. If autodetect is absent or false, the body of the tag will be used. In -this case, if nobody is present, the value "localhost" will be used. - -| connectionTimeout -| This is an optional tag with a non-negative integer body. Connection timeout in seconds. - -| connectionLimit -| Set the maximum simultaneous incoming connections for this service. - -| connectionLimitPerIP -| Set the maximum simultaneous incoming connections per IP for this service. - -| proxyRequired -| Enables proxy support for this service for incoming connections. HAProxy's protocol -(https://www.haproxy.org/download/2.7/doc/proxy-protocol.txt) is used and might be compatible -with other proxies (e.g. traefik). If enabled, it is *required* to initiate the connection -using HAProxy's proxy protocol. - -| authRequired -| (deprecated) use auth.announce instead. - -This is an optional tag with a boolean body. If true, then the server will -announce authentication after HELO command. If this tag is absent, or the value -is false then the client will not be prompted for authentication. Only simple user/password authentication is -supported at this time. Supported values: - - * true: announced only to not authorizedAddresses - - * false: don't announce AUTH. If absent, *authorizedAddresses* are set to a wildcard to accept all remote hosts. - - * announce: like true, but always announce AUTH capability to clients - -Please note that emails are only relayed if, and only if, the user did authenticate, or is in an authorized network, -regardless of this option. - -| auth.announce -| This is an optional tag. Possible values are: - -* never: Don't announce auth. - -* always: always announce AUTH capability to clients. - -* forUnauthorizedAddresses: announced only to not authorizedAddresses - -Please note that emails are only relayed if, and only if, the user did authenticate, or is in an authorized network, -regardless of this option. - -| auth.requireSSL -| This is an optional tag, defaults to true. If true, authentication is not advertised via capabilities on unencrypted -channels. - -| auth.plainAuthEnabled -| This is an optional tag, defaults to true. If false, AUTH PLAIN and AUTH LOGIN will not be exposed. This setting -can be used to enforce strong authentication mechanisms. - -| auth.oidc.oidcConfigurationURL -| Provide OIDC url address for information to user. Only configure this when you want to authenticate SMTP server using a OIDC provider. - -| auth.oidc.jwksURL -| Provide url to get OIDC's JSON Web Key Set to validate user token. Only configure this when you want to authenticate SMTP server using a OIDC provider. - -| auth.oidc.claim -| Claim string uses to identify user. E.g: "email_address". Only configure this when you want to authenticate SMTP server using a OIDC provider. - -| auth.oidc.scope -| An OAuth scope that is valid to access the service (RF: RFC7628). Only configure this when you want to authenticate SMTP server using a OIDC provider. - -| auth.oidc.introspection.url -| Optional. An OAuth introspection token URL will be called to validate the token (RF: RFC7662). -Only configure this when you want to validate the revocation token by the OIDC provider. -Note that James always verifies the signature of the token even whether this configuration is provided or not. - -| auth.oidc.introspection.auth -| Optional. Provide Authorization in header request when introspecting token. -Eg: `Basic xyz` - -| auth.oidc.userinfo.url -| Optional. An Userinfo URL will be called to validate the token (RF: OpenId.Core https://openid.net/specs/openid-connect-core-1_0.html). -Only configure this when you want to validate the revocation token by the OIDC provider. -Note that James always verifies the signature of the token even whether this configuration is provided or not. -James will ignore check token by userInfo if the `auth.oidc.introspection.url` is already configured - -| authorizedAddresses -| Authorize specific addresses/networks. - -If you use SMTP AUTH, addresses that match those specified here will -be permitted to relay without SMTP AUTH. If you do not use SMTP -AUTH, and you specify addresses here, then only addresses that match -those specified will be permitted to relay. - -Addresses may be specified as a IP address or domain name, with an -optional netmask, e.g., - -127.*, 127.0.0.0/8, 127.0.0.0/255.0.0.0, and localhost/8 are all the same - -See also the RemoteAddrNotInNetwork matcher in the transport processor. -You would generally use one OR the other approach. - -| verifyIdentity -| This is an optional tag. This options governs MAIL FROM verifications, and prevents spoofing of the MAIL FROM -envelop field. - -The following values are supported: - - - `strict`: use of a local domain in MAIL FROM requires the SMTP client to be authenticated with a matching user or one - of its aliases. It will verify that the sender address matches the address of the user or one of its alias (from user or domain aliases). - This prevents a user of your mail server from acting as someone else - - `disabled`: no check is performed and third party are free to send emails as local users. Note that relaying emails will - need third party to be authenticated thus preventing open relays. - - `relaxed`: Based on a simple heuristic to determine if the SMTP client is a MUA or a MX (use of a valid domain in EHLO), - we do act as `strict` for MUAs thus prompting them early for the need of authentication, but accept use of local MAIL FROM for - MX. Authentication can then be delayed to later, eg after DATA transaction with the DKIMHook which might allow email looping through - third party domains via mail redirection, effectively enforcing that the mail originates from our servers. See - link:https://issues.apache.org/jira/browse/JAMES-4032[JAMES-4032] for detailed explanation. - -Backward compatibility is provided and thus the following values are supported: - - - `true`: act as `strict` - - `false`: act as `disabled` - -| maxmessagesize -| This is an optional tag with a non-negative integer body. It specifies the maximum -size, in kbytes, of any message that will be transmitted by this SMTP server. It is a service-wide, as opposed to -a per user, limit. If the value is zero then there is no limit. If the tag isn't specified, the service will -default to an unlimited message size. Must be a positive integer, optionally with a unit: B, K, M, G. - -| heloEhloEnforcement -| This sets whether to enforce the use of HELO/EHLO salutation before a -MAIL command is accepted. If unspecified, the value defaults to true. - -| smtpGreeting -| This sets the SMTPGreeting which will be used when connect to the smtpserver -If none is specified a default is generated - -| handlerchain -| The configuration handler chain. See xref:distributed/configure/smtp-hooks.adoc[this page] for configuring out-of the -box extra SMTP handlers and hooks. - -| bossWorkerCount -| Set the maximum count of boss threads. Boss threads are responsible for accepting incoming SMTP connections -and initializing associated resources. Optional integer, by default, boss threads are not used and this responsibility is being dealt with -by IO threads. - -| ioWorkerCount -| Set the maximum count of IO threads. IO threads are responsible for receiving incoming SMTP messages and framing them -(split line by line). IO threads also take care of compression and SSL encryption. Their tasks are short-lived and non-blocking. -Optional integer, defaults to 2 times the count of CPUs. - -| maxExecutorCount -| Set the maximum count of worker threads. Worker threads takes care of potentially blocking tasks like executing SMTP commands. -Optional integer, defaults to 16. - -| useEpoll -| true or false - If true uses native EPOLL implementation for Netty otherwise uses NIO. Defaults to false. - -| gracefulShutdown -| true or false - If true attempts a graceful shutdown, which is safer but can take time. Defaults to true. - -| disabledFeatures -| Extended SMTP features to hide in EHLO responses. -|=== - -=== OIDC setup -James SMTP support XOAUTH2 authentication mechanism which allow authenticating against a OIDC providers. -Please configure `auth.oidc` part to use this. - -We do supply an link:https://github.com/apache/james-project/tree/master/examples/oidc[example] of such a setup. -It uses the Keycloak OIDC provider, but usage of similar technologies is definitely doable. - -== About open relays - -Authenticated SMTP is a method of securing your SMTP server. With SMTP AUTH enabled senders who wish to -relay mail through the SMTP server (that is, send mail that is eventually to be delivered to another SMTP -server) must authenticate themselves to Apache James Server before sending their message. Mail that is to be delivered -locally does not require authentication. This method ensures that spammers cannot use your SMTP server -to send unauthorized mail, while still enabling users who may not have fixed IP addresses to send their -messages. - -Mail servers that allow spammers to send unauthorized email are known as open relays. So SMTP AUTH -is a mechanism for ensuring that your server is not an open relay. - -It is extremely important that your server not be configured as an open relay. Aside from potential -costs associated with usage by spammers, connections from servers that are determined to be open relays -are routinely rejected by SMTP servers. This can severely impede the ability of your mail server to -send mail. - -At this time Apache James Server only supports simple user name / password authentication. - -As mentioned above, SMTP AUTH requires that Apache James Server be able to distinguish between mail intended -for local delivery and mail intended for remote delivery. Apache James Server makes this determination by matching the -domain to which the mail was sent against the *DomainList* component, configured by -xref:distributed/configure/domainlist.adoc[*domainlist.xml*]. - -The Distributed Server is configured out of the box so as to not serve as an open relay for spammers. This is done -by relayed emails originate from a trusted source. This includes: - -* Authenticated SMTP/JMAP users -* Mails generated by the server (eg: bounces) -* Mails originating from a trusted network as configured in *smtpserver.xml* - -If you wish to ensure that authenticated users can only send email from their own account, you may -optionally set the verifyIdentity element of the smtpserver configuration block to "true". - -=== Verification - -Verify that you have not inadvertently configured your server as an open relay. This is most easily -accomplished by using the service provided at https://mxtoolbox.com/diagnostic.aspx[mxtoolbox.com]. mxtoolbox.com will -check your mail server and inform you if it is an open relay. This tool further more verifies additional properties like: - -* Your DNS configuration, especially that you mail server IP has a valid reverse DNS entry -* That your SMTP connection is secured -* That you are not an OpenRelay -* This website also allow a quick lookup to ensure your mail server is not in public blacklists. - -Of course it is also necessary to confirm that users and log in and send -mail through your server. This can be accomplished using any standard mail client (i.e. Thunderbird, Outlook, -Eudora, Evolution). - -== LMTP Configuration - -Consult this link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/lmtpserver.xml[example] -to get some examples and hints. - -The configuration is the same of for SMTP. - -By default, it is deactivated. You can activate it alongside SMTP and bind for example on port 24. - -The default LMTP server stores directly emails in user mailboxes, without further treatment. - -However we do ship an alternative handler chain allowing to execute the mailet container, thus achieving a behaviour similar -to the default SMTP protocol. Here is how to achieve this: - -.... - - - lmtpserver - 0.0.0.0:24 - 200 - 1200 - 0 - 0 - 0 - - - - - -.... - -Note that by default the mailet container is executed with all recipients at once and do not allow per recipient -error reporting. An option splitExecution allow to execute the mailet container for each recipient separately and mitigate this -limitation at the cost of performance. - -.... - - - lmtpserver - 0.0.0.0:24 - 200 - 1200 - 0 - 0 - 0 - - - - true - - - - -.... \ No newline at end of file +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +:pages-path: distributed +:server-name: Distributed James Server +include::partial$configure/smtp.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/spam.adoc b/docs/modules/servers/pages/distributed/configure/spam.adoc index 8a7839d6048..4b7dabd7972 100644 --- a/docs/modules/servers/pages/distributed/configure/spam.adoc +++ b/docs/modules/servers/pages/distributed/configure/spam.adoc @@ -1,190 +1,8 @@ = Distributed James Server — Anti-Spam configuration :navtitle: Anti-Spam configuration -Anti-Spam system can be configured via two main different mechanisms: - -* SMTP Hooks; -* Mailets; - -== AntiSpam SMTP Hooks - -"FastFail" SMTP Hooks acts to reject before spooling -on the SMTP level. The Spam detector hook can be used as a fastfail hook, therefore -Spam filtering system must run as a server on the same machine as the Apache James Server. - -SMTP Hooks for non-existent users, DSN filter, domains with invalid MX record, -can also be configured. - -*SpamAssassinHandler* (experimental) also enables to classify the messages as spam or not -with a configurable score threshold (`0.0`, non-configurable). Only a global database is supported. Per user spam -detection is not supported by this hook. - -== AntiSpam Mailets - -James' repository provide two AntiSpam mailets: SpamAssassin and RspamdScanner. -We can select one in them for filtering spam mail. - -* *SpamAssassin and RspamdScanner* Mailet is designed to classify the messages as spam or not -with a configurable score threshold. Usually a message will only be -considered as spam if it matches multiple criteria; matching just a single test -will not usually be enough to reach the threshold. Note that this mailet is executed on a per-user basis. - -=== Rspamd - -The Rspamd extension (optional) requires an extra configuration file `rspamd.properties` to configure RSpamd connection - -.rspamd.properties content -|=== -| Property name | explanation - -| rSpamdUrl -| URL defining the Rspamd's server. Eg: http://rspamd:11334 - -| rSpamdPassword -| Password for pass authentication when request to Rspamd's server. Eg: admin - -| rspamdTimeout -| Integer. Timeout for http requests to Rspamd. Default to 15 seconds. - -| perUserBayes -| Boolean. Whether to scan/learn mails using per-user Bayes. Default to false. -|=== - -`RspamdScanner` supports the following options: - -* You can specify the `virusProcessor` if you want to enable virus scanning for mail. Upon configurable `virusProcessor` -you can specify how James process mail virus. We provide a sample Rspamd mailet and `virusProcessor` configuration: - -* You can specify the `rejectSpamProcessor`. Emails marked as `rejected` by Rspamd will be redirected to this -processor. This corresponds to emails with the highest spam score, thus delivering them to users as marked as spam -might not even be desirable. - -* The `rewriteSubject` option allows to rewritte subjects when asked by Rspamd. - -This mailet can scan mails against per-user Bayes by configure `perUserBayes` in `rspamd.properties`. This is achieved -through the use of Rspamd `Deliver-To` HTTP header. If true, Rspamd will be called for each recipient of the mail, which comes at a performance cost. If true, subjects are not rewritten. -If true `virusProcessor` and `rejectSpamProcessor` are honnered per user, at the cost of email copies. Default to false. - -Here is an example of mailet pipeline conducting out RspamdScanner execution: - -.... - - - true - virus - spam - - - Spam - - - - - - - - file://var/mail/virus/ - - - - - - all - .* - - - [VIRUS] - - - - - - - cassandra://var/mail/spam - - -.... - -==== Feedback for Rspamd -If enabled, the `RspamdListener` will base on the Mailbox event to detect the message is a spam or not, then James will send report `spam` or `ham` to Rspamd. -This listener can report mails to per-user Bayes by configure `perUserBayes` in `rspamd.properties`. -The Rspamd listener needs to explicitly be registered with xref:distributed/configure/listeners.adoc[listeners.xml]. - -Example: - -.... - - - org.apache.james.rspamd.RspamdListener - - -.... - -For more detail about how to use Rspamd's extension: `third-party/rspamd/index.md` - -Alternatively, batch reports can be triggered on user mailbox content via webAdmin. link:https://github.com/apache/james-project/tree/master/third-party/rspamd#additional-webadmin-endpoints[Read more]. - - -=== SpamAssassin -Here is an example of mailet pipeline conducting out SpamAssassin execution: - -.... - - ignore - spamassassin - 783 - - - - org.apache.james.spamassassin.status; X-JAMES-SPAMASSASSIN-STATUS - org.apache.james.spamassassin.flag; X-JAMES-SPAMASSASSIN-FLAG - - - Spam - -.... - -* *BayesianAnalysis* (unsupported) in the Mailet uses Bayesian probability to classify mail as -spam or not spam. It relies on the training data coming from the users’ judgment. -Users need to manually judge as spam and send to spam@thisdomain.com, oppositely, -if not spam they then send to not.spam@thisdomain.com. BayesianAnalysisfeeder learns -from this training dataset, and build predictive models based on Bayesian probability. -There will be a certain table for maintaining the frequency of Corpus for keywords -in the database. Every 10 mins a thread in the BayesianAnalysis will check and update -the table. Also, the correct approach is to send the original spam or non-spam -as an attachment to another message sent to the feeder in order to avoid bias from the -current sender's email header. - -==== Feedback for SpamAssassin - -If enabled, the `SpamAssassinListener` will asynchronously report users mails moved to the `Spam` mailbox as Spam, -and other mails as `Ham`, effectively populating the user database for per user spam detection. This enables a per-user -Spam categorization to be conducted out by the SpamAssassin mailet, the SpamAssassin hook being unaffected. - -The SpamAssassin listener requires an extra configuration file `spamassassin.properties` to configure SpamAssassin connection (optional): - -.spamassassin.properties content -|=== -| Property name | explanation - -| spamassassin.host -| Hostname of the SpamAssassin server. Defaults to 127.0.0.1. - -| spamassassin.port -| Port of the SpamAssassin server. Defaults to 783. -|=== - -Note that this configuration file only affects the listener, and not the hook or mailet. - -The SpamAssassin listener needs to explicitly be registered with xref:distributed/configure/listeners.adoc[listeners.xml]. - -Example: - -.... - - - org.apache.james.mailbox.spamassassin.SpamAssassinListener - true - - -.... +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +:pages-path: distributed +:server-name: Distributed James Server +:mailet-repository-path-prefix: cassandra +include::partial$configure/spam.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/ssl.adoc b/docs/modules/servers/pages/distributed/configure/ssl.adoc index b21a17fa3a8..f77590ec95a 100644 --- a/docs/modules/servers/pages/distributed/configure/ssl.adoc +++ b/docs/modules/servers/pages/distributed/configure/ssl.adoc @@ -1,247 +1,7 @@ = Distributed James Server — SSL & TLS configuration :navtitle: SSL & TLS configuration -This document explains how to enable James 3.0 servers to use Transport Layer Security (TLS) -for encrypted client-server communication. - -== Configure a Server to Use SSL/TLS - -Each of the servers xref:distributed/configure/smtp.adoc[SMTP - LMTP], -xref:distributed/configure/pop3.adoc[POP3] and xref:distributed/configure/imap.adoc[IMAP] -supports use of SSL/TLS. - -TLS (Transport Layer Security) and SSL (Secure Sockets Layer) are protocols that provide -data encryption and authentication between applications in scenarios where that data is -being sent across an insecure network, such as checking your email -(How does the Secure Socket Layer work?). The terms SSL and TLS are often used -interchangeably or in conjunction with each other (TLS/SSL), -but one is in fact the predecessor of the other — SSL 3.0 served as the basis -for TLS 1.0 which, as a result, is sometimes referred to as SSL 3.1. - -You need to add a block in the corresponding configuration file (smtpserver.xml, pop3server.xml, imapserver.xml,..) - -.... - - file://conf/keystore - PKCS12 - yoursecret - org.bouncycastle.jce.provider.BouncyCastleProvider - -.... - -Alternatively TLS keys can be supplied via PEM files: - -.... - - file://conf/private.key - file://conf/certs.self-signed.csr - -.... - -An optional secret might be specified for the private key: - -.... - - file://conf/private.key - file://conf/certs.self-signed.csr - yoursecret - -.... - -Optionally, TLS protocols and/or cipher suites can be specified explicitly (smtpserver.xml, pop3server.xml, imapserver.xml,..). -Otherwise, the default protocols and cipher suites of the used JDK will be used: -.... - - - TLSv1.2 - TLSv1.1 - TLSv1 - SSLv3 - - - TLS_AES_256_GCM_SHA384 - TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 - - -.... - -Each of these block has an optional boolean configuration element socketTLS and startTLS which is used to toggle -use of SSL or TLS for the service. - -With socketTLS (SSL/TLS in Thunderbird), all the communication is encrypted. - -With startTLS (STARTTLS in Thunderbird), the preamble is readable, but the rest is encrypted. - -.... -* OK JAMES IMAP4rev1 Server Server 192.168.1.4 is ready. -* CAPABILITY IMAP4rev1 LITERAL+ CHILDREN WITHIN STARTTLS IDLE NAMESPACE UIDPLUS UNSELECT AUTH=PLAIN -1 OK CAPABILITY completed. -2 OK STARTTLS Begin TLS negotiation now. -... rest is encrypted... -.... - -You can only enable one of the both at the same time for a service. - -It is also recommended to change the port number on which the service will listen: - -* POP3 - port 110, Secure POP3 - port 995 -* IMAP - port 143, Secure IMAP4 - port 993 -* SMTP - port 25, Secure SMTP - port 465 - -You will now need to create your certificate store and place it in the james/conf/ folder with the name you defined in the keystore tag. - -Please note `JKS` keystore format is also supported (default value if no keystore type is specified): - -.... - - file://conf/keystore - JKS - yoursecret - org.bouncycastle.jce.provider.BouncyCastleProvider - -.... - - -=== Client authentication via certificates - -When you enable TLS, you may also configure the server to require a client certificate for authentication: - -.... - - file://conf/keystore - JKS - yoursecret - - - file://conf/truststore - JKS - yoursecret - false - - -.... - -James verifies client certificates against the provided truststore. You can fill it with trusted peer certificates directly, or an issuer certificate (CA) if you trust all certificates created by it. If you omit the truststore configuration, James will use the Java default truststore instead, effectively trusting any known CA. - -James can optionally enable OCSP verifications for client certificates against Certificate Revocation List referenced -in the certificate itself. - -== Creating your own PEM keys - -The following commands can be used to create self signed PEM keys: - -.... -# Generating your private key -openssl genrsa -des3 -out private.key 2048 - -# Creating your certificates -openssl req -new -key private.key -out certs.csr - -# Signing the certificate yourself -openssl x509 -req -days 365 -in certs.csr -signkey private.key -out certs.self-signed.csr - -# Removing the password from the private key -# Not necessary if you supply the secret in the configuration -openssl rsa -in private.key -out private.nopass.key -.... - -You may then supply this TLS configuration: - -.... - - file://conf/private.nopass.key - file://conf/certs.self-signed.csr - -.... - -== Certificate Keystores - -This section gives more indication for users relying on keystores. - -=== Creating your own Certificate Keystore - -(Adapted from the Tomcat 4.1 documentation) - -James currently operates only on JKS or PKCS12 format keystores. This is Java's standard "Java KeyStore" format, and is -the format created by the keytool command-line utility. This tool is included in the JDK. - -To import an existing certificate into a JKS keystore, please read the documentation (in your JDK documentation package) -about keytool. - -To create a new keystore from scratch, containing a single self-signed Certificate, execute the following from a terminal -command line: - -.... -keytool -genkey -alias james -keyalg RSA -storetype PKCS12 -keystore your_keystore_filename -.... - -(The RSA algorithm should be preferred as a secure algorithm, and this also ensures general compatibility with other -servers and components.) - -As a suggested standard, create the keystore in the james/conf directory, with a name like james.keystore. - -After executing this command, you will first be prompted for the keystore password. - -Next, you will be prompted for general information about this Certificate, such as company, contact name, and so on. -This information may be displayed to users when importing into the certificate store of the client, so make sure that -the information provided here matches what they will expect. - -Important: in the "distinguished name", set the "common name" (CN) to the DNS name of your James server, the one -you will use to access it from your mail client (like "mail.xyz.com"). - -Finally, you will be prompted for the key password, which is the password specifically for this Certificate -(as opposed to any other Certificates stored in the same keystore file). - -If everything was successful, you now have a keystore file with a Certificate that can be used by your server. - -You MUST have only one certificate in the keystore file used by James. - -=== Installing a Certificate provided by a Certificate Authority - -(Adapted from the Tomcat 4.1 documentation - -To obtain and install a Certificate from a Certificate Authority (like verisign.com, thawte.com or trustcenter.de) -you should have read the previous section and then follow these instructions: - -==== Create a local Certificate Signing Request (CSR) - -In order to obtain a Certificate from the Certificate Authority of your choice you have to create a so called -Certificate Signing Request (CSR). That CSR will be used by the Certificate Authority to create a Certificate -that will identify your James server as "secure". To create a CSR follow these steps: - -* Create a local Certificate as described in the previous section. - -The CSR is then created with: - -.... - keytool -certreq -keyalg RSA -alias james -file certreq.csr -keystore your_keystore_filename -.... - -Now you have a file called certreq.csr. The file is encoded in PEM format. You can submit it to the Certificate Authority -(look at the documentation of the Certificate Authority website on how to do this). In return you get a Certificate. - -Now that you have your Certificate you can import it into you local keystore. First of all you may have to import a so -called Chain Certificate or Root Certificate into your keystore (the major Certificate Authorities are already in place, -so it's unlikely that you will need to perform this step). After that you can procede with importing your Certificate. - -==== Optionally Importing a so called Chain Certificate or Root Certificate - -Download a Chain Certificate from the Certificate Authority you obtained the Certificate from. - -* For Verisign.com go to: http://www.verisign.com/support/install/intermediate.html -* For Trustcenter.de go to: http://www.trustcenter.de/certservices/cacerts/en/en.htm#server -* For Thawte.com go to: http://www.thawte.com/certs/trustmap.html (seems no longer valid) - -==== Import the Chain Certificate into you keystore - -.... -keytool -import -alias root -keystore your_keystore_filename -trustcacerts -file filename_of_the_chain_certificate -.... - -And finally import your new Certificate (It must be in X509 format): - -.... -keytool -import -alias james -keystore your_keystore_filename -trustcacerts -file your_certificate_filename -.... - -See also http://www.agentbob.info/agentbob/79.html[this page] \ No newline at end of file +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +:pages-path: distributed +:server-name: Distributed James Server +include::partial$configure/ssl.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/tika.adoc b/docs/modules/servers/pages/distributed/configure/tika.adoc index fdb2cc9cf7a..604b31e4865 100644 --- a/docs/modules/servers/pages/distributed/configure/tika.adoc +++ b/docs/modules/servers/pages/distributed/configure/tika.adoc @@ -1,51 +1,5 @@ = Distributed James Server — tika.properties :navtitle: tika.properties -When using OpenSearch, you can configure an external Tika server for extracting and indexing text from attachments. -Thus you can significantly improve user experience upon text searches. - -Note: You can launch a tika server using this command line: - -.... -docker run --name tika linagora/docker-tikaserver:1.24 -.... - -Here are the different properties: - -.tika.properties content -|=== -| Property name | explanation - -| tika.enabled -| Should Tika text extractor be used? -If true, the TikaTextExtractor will be used behind a cache. -If false, the DefaultTextExtractor will be used (naive implementation only supporting text). -Defaults to false. - -| tika.host -| IP or domain name of your Tika server. The default value is 127.0.0.1 - -| tika.port -| Port of your tika server. The default value is 9998 - -| tika.timeoutInMillis -| Timeout when issuing request to the tika server. The default value is 3 seconds. - -| tika.cache.eviction.period -| A cache is used to avoid, when possible, query Tika multiple time for the same attachments. -This entry determines how long after the last read an entry vanishes. -Please note that units are supported (ms - millisecond, s - second, m - minute, h - hour, d - day). Default unit is seconds. -Default value is *1 day* - -| tika.cache.enabled -| Should the cache be used? False by default - -| tika.cache.weight.max -| Maximum weight of the cache. -A value of *0* disables the cache -Please note that units are supported (K for KB, M for MB, G for GB). Defaults is no units, so in bytes. -Default value is *100 MB*. - -| tika.contentType.blacklist -| Blacklist of content type is known-to-be-failing with Tika. Specify the list with comma separator. -|=== +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +include::partial$configure/tika.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/usersrepository.adoc b/docs/modules/servers/pages/distributed/configure/usersrepository.adoc index ff07f7929e3..d4cef0a23f7 100644 --- a/docs/modules/servers/pages/distributed/configure/usersrepository.adoc +++ b/docs/modules/servers/pages/distributed/configure/usersrepository.adoc @@ -1,136 +1,5 @@ = Distributed James Server — usersrepository.xml :navtitle: usersrepository.xml -User repositories are required to store James user information and authentication data. - -Consult this link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/usersrepository.xml[example] -to get some examples and hints. - -== The user data model - -A user has two attributes: username and password. - -A valid user should satisfy these criteria: - -* username and password cannot be null or empty -* username should not be longer than 255 characters -* username can not contain '/' -* username can not contain multiple domain delimiter('@') -* A username can have only a local part when virtualHosting is disabled. E.g.'myUser' -* When virtualHosting is enabled, a username should have a domain part, and the domain part should be concatenated -after a domain delimiter('@'). E.g. 'myuser@james.org' - -A user is always considered as lower cased, so 'myUser' and 'myuser' are the same user, and can be used as well as -recipient local part than as login for different protocols. - -== Configuration - -.usersrepository.xml content -|=== -| Property name | explanation - -| enableVirtualHosting -| true or false. Add domain support for users (default: false, except for Cassandra Users Repository) - -| administratorId -|user's name. Allow a user to access to the https://tools.ietf.org/html/rfc4616#section-2[impersonation command], -acting on the behalf of any user. - -| verifyFailureDelay -| Delay after a failed authentication attempt with an invalid user name or password. Duration string defaulting to seconds, e.g. `2`, `2s`, `2000ms`. Default `0s` (disabled). - -| algorithm -| use a specific hash algorithm to compute passwords, with optional mode `plain` (default) or `salted`; e.g. `SHA-512`, `SHA-512/plain`, `SHA-512/salted`, `PBKDF2`, `PBKDF2-SHA512` (default). -Note: When using `PBKDF2` or `PBKDF2-SHA512` one can specify the iteration count and the key size in bytes. You can specify it as part of the algorithm. EG: `PBKDF2-SHA512-2000-512` will use -2000 iterations with a key size of 512 bytes. - -| hashingMode -| specify the hashing mode to use if there is none recorded in the database: `plain` (default) for newer installations or `legacy` for older ones - -|=== - -== Configuring a LDAP - -Alternatively you can authenticate your users against a LDAP server. You need to configure -the properties for accessing your LDAP server in this file. - -Consult this link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/usersrepository.xml[example] -to get some examples and hints. - -Example: - -.... - - true - -.... - -SSL can be enabled by using `ldaps` scheme. `trustAllCerts` option can be used to trust all LDAP client certificates -(optional, defaults to false). - -Example: - -.... - - true - -.... - -Moreover, per domain base DN can be configured: - -.... -true - - ou=People,o=other.com,ou=system - - -.... - -You can connect to multiple LDAP servers for better availability by using `ldapHosts` option (fallback to `ldapHost` is supported) to specify the list of LDAP Server URL with the comma `,` delimiter. We do support different schemas for LDAP servers. - -Example: - -.... - - true - -.... - -When VirtualHosting is on, you can enable local part as login username by configure the `resolveLocalPartAttribute`. -This is the LDAP attribute that allows to retrieve the local part of users. Optional, default to empty, which disables login with local part as username. - -Example: - -.... - - true - -.... - -The "userListBase" configuration option is used to differentiate users that can login from those that are listed - as regular users. This is useful for dis-activating users, for instance. - -A different values from "userBase" can be used for setting up virtual logins, -for instance in conjunction with "resolveLocalPartAttribute". This can also be used to manage -disactivated users (in "userListBase" but not in "userBase"). - -Note that "userListBase" can not be specified on a per-domain-basis. - -=== LDAP connection pool size tuning - -Apache James offers some options for configuring the LDAP connection pool used by unboundid: - -* *poolSize*: (optional, default = 4) The maximum number of connection in the pool. Note that if the pool is exhausted, -extra connections will be created on the fly as needed. -* *maxWaitTime*: (optional, default = 1000) the number of milli seconds to wait before creating off-pool connections, -using a pool connection if released in time. This effectively smooth out traffic burst, thus in some case can help -not overloading the LDAP -* *connectionTimeout:* (optional) Sets the connection timeout on the underlying to the specified integer value -* *readTimeout:* (optional) Sets property the read timeout to the specified integer value. \ No newline at end of file +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +include::partial$configure/usersrepository.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/vault.adoc b/docs/modules/servers/pages/distributed/configure/vault.adoc index 2f7a4836a8d..97ee4a32476 100644 --- a/docs/modules/servers/pages/distributed/configure/vault.adoc +++ b/docs/modules/servers/pages/distributed/configure/vault.adoc @@ -1,38 +1,8 @@ = Distributed James Server — deletedMessageVault.properties :navtitle: deletedMessageVault.properties -Deleted Messages Vault is the component in charge of retaining messages before they are going to be deleted. -Messages stored in the Deleted Messages Vault could be deleted after exceeding their retentionPeriod (explained below). -It also supports to restore or export messages matching with defined criteria in -xref:distributed/operate/webadmin.adoc#_deleted_messages_vault[WebAdmin deleted messages vault document] by using -xref:distributed/operate/webadmin.adoc#_deleted_messages_vault[WebAdmin endpoints]. - -== Deleted Messages Vault Configuration - -Once the vault is active, James will start moving deleted messages to it asynchronously. - -The Deleted Messages Vault also stores and manages deleted messages into a BlobStore. The BlobStore can be either -based on an object storage or on Cassandra. For configuring the BlobStore the vault will use, you can look at -xref:distributed/configure/blobstore.adoc[*blobstore.properties*] BlobStore Configuration section. - -== deletedMessageVault.properties - -Consult this link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/deletedMessageVault.properties[example] -to get some examples and hints. - -.deletedMessageVault.properties content -|=== -| Property name | explanation - -| enabled -| Allows to enable or disable usage of the Deleted Message Vault. Default to false. - -| workQueueEnabled -| Enable work queue to be used with deleted message vault. Default to false. - -| retentionPeriod -| Deleted messages stored in the Deleted Messages Vault are expired after this period (default: 1 year). It can be expressed in *y* years, *d* days, *h* hours, ... - -| restoreLocation -| Messages restored from the Deleted Messages Vault are placed in a mailbox with this name (default: ``Restored-Messages``). The mailbox will be created if it does not exist yet. -|=== +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +:pages-path: distributed +:server-name: Distributed James Server +:backend-name: Cassandra +include::partial$configure/vault.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/configure/webadmin.adoc b/docs/modules/servers/pages/distributed/configure/webadmin.adoc index 767f4fca47b..13393213f99 100644 --- a/docs/modules/servers/pages/distributed/configure/webadmin.adoc +++ b/docs/modules/servers/pages/distributed/configure/webadmin.adoc @@ -1,100 +1,7 @@ = Distributed James Server — webadmin.properties :navtitle: webadmin.properties -The web administration supports for now the CRUD operations on the domains, the users, their mailboxes and their quotas, -managing mail repositories, performing cassandra migrations, and much more, as described in the following sections. - -*WARNING*: This API allows authentication only via the use of JWT. If not -configured with JWT, an administrator should ensure an attacker can not -use this API. - -By the way, some endpoints are not filtered by authentication. Those endpoints are not related to data stored in James, -for example: Swagger documentation & James health checks. - -== Configuration - -Consult this link:https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/webadmin.properties[example] -to get some examples and hints. - -.webadmin.properties content -|=== -| Property name | explanation - -| enabled -| Define if WebAdmin is launched (default: false) - -| port -| Define WebAdmin's port (default: 8080) - -| host -| Define WebAdmin's host (default: localhost, use 0.0.0.0 to listen on all addresses) - -| cors.enable -| Allow the Cross-origin resource sharing (default: false) - -| cors.origin -| Specify ths CORS origin (default: null) - -| jwt.enable -| Allow JSON Web Token as an authentication mechanism (default: false) - -| https.enable -| Use https (default: false) - -| https.keystore -| Specify a keystore file for https (default: null) - -| https.password -| Specify the keystore password (default: null) - -| https.trust.keystore -| Specify a truststore file for https (default: null) - -| https.trust.password -| Specify the truststore password (default: null) - -| jwt.publickeypem.url -| Optional. JWT tokens allow request to bypass authentication. Path to the JWT public key. -Defaults to the `jwt.publickeypem.url` value of `jmap.properties` file if unspecified -(legacy behaviour) - -| extensions.routes -| List of Routes specified as fully qualified class name that should be loaded in addition to your product routes list. Routes -needs to be on the classpath or in the ./extensions-jars folder. Read mode about -xref:customization:webadmin-routes.adoc[creating you own webadmin routes]. - -| maxThreadCount -| Maximum threads used by the underlying Jetty server. Optional. - -| minThreadCount -| Minimum threads used by the underlying Jetty server. Optional. - -|=== - -== Generating a JWT key pair - -The Distributed server enforces the use of RSA-SHA-256. - -One can use OpenSSL to generate a JWT key pair : - - # private key - openssl genrsa -out rs256-4096-private.rsa 4096 - # public key - openssl rsa -in rs256-4096-private.rsa -pubout > rs256-4096-public.pem - -The private key can be used to generate JWT tokens, for instance -using link:https://github.com/vandium-io/jwtgen[jwtgen]: - - jwtgen -a RS256 -p rs256-4096-private.rsa 4096 -c "sub=bob@domain.tld" -c "admin=true" -e 3600 -V - -This token can then be passed as `Bearer` of the `Authorization` header : - - curl -H "Authorization: Bearer $token" -XGET http://127.0.0.1:8000/domains - -The public key can be referenced as `jwt.publickeypem.url` of the `jmap.properties` configuration file. - -== Reverse-proxy set up - -WebAdmin adds the value of `X-Real-IP` header as part of the logging MDC. - -This allows for reverse proxies to cary other the IP address of the client down to the JMAP server for diagnostic purpose. +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +:pages-path: distributed +:server-name: Distributed James Server +include::partial$configure/webadmin.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/partials/configure/batchsizes.adoc b/docs/modules/servers/partials/configure/batchsizes.adoc new file mode 100644 index 00000000000..6e123c9f90b --- /dev/null +++ b/docs/modules/servers/partials/configure/batchsizes.adoc @@ -0,0 +1,31 @@ +This files allow to define the amount of data that should be fetched 'at once' when interacting with the mailbox. This is +needed as IMAP can generate some potentially large requests. + +Increasing these values tend to fasten individual requests, at the cost of enabling potential higher load. + +Consult this link:{sample-configuration-prefix-url}/batchsizes.properties[example] +to get some examples and hints. + +.batchsizes.properties content +|=== +| Property name | explanation + +| fetch.metadata +| Optional, defaults to 200. How many messages should be read in a batch when using FetchType.MetaData + +| fetch.headers +| Optional, defaults to 200. How many messages should be read in a batch when using FetchType.Header + +| fetch.body +| Optional, defaults to 100. How many messages should be read in a batch when using FetchType.Body + +| fetch.full +| Optional, defaults to 50. How many messages should be read in a batch when using FetchType.Full + +| copy +| Optional, defaults to 200. How many messages should be copied in a batch. + +| move +| Optional, defaults to 200. How many messages should be moved in a batch. + +|=== \ No newline at end of file diff --git a/docs/modules/servers/partials/configure/blobstore.adoc b/docs/modules/servers/partials/configure/blobstore.adoc new file mode 100644 index 00000000000..e928386bbbe --- /dev/null +++ b/docs/modules/servers/partials/configure/blobstore.adoc @@ -0,0 +1,173 @@ + +=== Encryption choice + +Data can be optionally encrypted with a symmetric key using AES before being stored in the blobStore. As many user relies +on third party for object storage, a compromised third party will not escalate to a data disclosure. Of course, a +performance price have to be paid, as encryption takes resources. + +*encryption.aes.enable* : Optional boolean, defaults to false. + +If AES encryption is enabled, then the following properties MUST be present: + + - *encryption.aes.password* : String + - *encryption.aes.salt* : Hexadecimal string + +The following properties CAN be supplied: + + - *encryption.aes.private.key.algorithm* : String, defaulting to PBKDF2WithHmacSHA512. Previously was +PBKDF2WithHmacSHA1. + +WARNING: Once chosen this choice can not be reverted, all the data is either clear or encrypted. Mixed encryption +is not supported. + +Here is an example of how you can generate the above values (be mindful to customize the byte lengths in order to add +enough entropy. + +.... +# Password generation +openssl rand -base64 64 + +# Salt generation +generate salt with : openssl rand -hex 16 +.... + +AES blob store supports the following system properties that could be configured in `jvm.properties`: + +.... +# Threshold from which we should buffer the blob to a file upon encrypting +# Unit supported: K, M, G, default to no unit +james.blob.aes.file.threshold.encrypt=100K + +# Threshold from which we should buffer the blob to a file upon decrypting +# Unit supported: K, M, G, default to no unit +james.blob.aes.file.threshold.decrypt=256K + +# Maximum size of a blob. Larger blobs will be rejected. +# Unit supported: K, M, G, default to no unit +james.blob.aes.blob.max.size=100M +.... + +=== Object storage configuration + +==== AWS S3 Configuration + +.blobstore.properties S3 related properties +|=== +| Property name | explanation + +| objectstorage.s3.endPoint +| S3 service endpoint + +| objectstorage.s3.region +| S3 region + +| objectstorage.s3.accessKeyId +| https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html#access-keys-and-secret-access-keys[S3 access key id] + +| objectstorage.s3.secretKey +| https://docs.aws.amazon.com/general/latest/gr/aws-sec-cred-types.html#access-keys-and-secret-access-keys[S3 access key secret] + +| objectstorage.s3.http.concurrency +| Allow setting the number of concurrent HTTP requests allowed by the Netty driver. + +| objectstorage.s3.truststore.path +| optional: Verify the S3 server certificate against this trust store file. + +| objectstorage.s3.truststore.type +| optional: Specify the type of the trust store, e.g. JKS, PKCS12 + +| objectstorage.s3.truststore.secret +| optional: Use this secret/password to access the trust store; default none + +| objectstorage.s3.truststore.algorithm +| optional: Use this specific trust store algorithm; default SunX509 + +| objectstorage.s3.trustall +| optional: boolean. Defaults to false. Cannot be set to true with other trustore options. Wether James should validate +S3 endpoint SSL certificates. + +| objectstorage.s3.read.timeout +| optional: HTTP read timeout. duration, default value being second. Leaving it empty relies on S3 driver defaults. + +| objectstorage.s3.write.timeout +| optional: HTTP write timeout. duration, default value being second. Leaving it empty relies on S3 driver defaults. + +| objectstorage.s3.connection.timeout +| optional: HTTP connection timeout. duration, default value being second. Leaving it empty relies on S3 driver defaults. + +| objectstorage.s3.in.read.limit +| optional: Object read in memory will be rejected if they exceed the size limit exposed here. Size, exemple `100M`. +Supported units: K, M, G, defaults to B if no unit is specified. If unspecified, big object won't be prevented +from being loaded in memory. This settings complements protocol limits. + +| objectstorage.s3.upload.retry.maxAttempts +| optional: Integer. Default is zero. This property specifies the maximum number of retry attempts allowed for failed upload operations. + +| objectstorage.s3.upload.retry.backoffDurationMillis +| optional: Long (Milliseconds). Default is 10 (miliseconds). +Only takes effect when the "objectstorage.s3.upload.retry.maxAttempts" property is declared. +This property determines the duration (in milliseconds) to wait between retry attempts for failed upload operations. +This delay is known as backoff. The jitter factor is 0.5 + +|=== + +==== Buckets Configuration + +.Bucket configuration +|=== +| Property name | explanation + +| objectstorage.bucketPrefix +| Bucket is a concept in James and similar to Containers in Swift or Buckets in AWS S3. +BucketPrefix is the prefix of bucket names in James BlobStore + +| objectstorage.namespace +| BlobStore default bucket name. Most of blobs storing in BlobStore are inside the default bucket. +Unless a special case like storing blobs of deleted messages. +|=== + +== Blob Export + +Blob Exporting is the mechanism to help James to export a blob from an user to another user. +It is commonly used to export deleted messages (consult configuring deleted messages vault). +The deleted messages are transformed into a blob and James will export that blob to the target user. + +This configuration helps you choose the blob exporting mechanism fit with your James setup and it is only applicable with Guice products. + +Consult {sample-configuration-prefix-url}/blob.properties[blob.properties] +in GIT to get some examples and hints. + +Configuration for exporting blob content: + +.blobstore.properties content +|=== +| blob.export.implementation + +| localFile: Local File Exporting Mechanism (explained below). Default: localFile + +| linshare: LinShare Exporting Mechanism (explained below) +|=== + +=== Local File Blob Export Configuration + +For each request, this mechanism retrieves the content of a blob and save it to a distinct local file, then send an email containing the absolute path of that file to the target mail address. + +Note: that absolute file path is the file location on James server. Therefore, if there are two or more James servers connected, it should not be considered an option. + +*blob.export.localFile.directory*: The directory URL to store exported blob data in files, and the URL following +http://james.apache.org/server/3/apidocs/org/apache/james/filesystem/api/FileSystem.html[James File System scheme]. +Default: file://var/blobExporting + +=== LinShare Blob Export Configuration + +Instead of exporting blobs in local file system, using https://www.linshare.org[LinShare] +helps you upload your blobs and people you have been shared to can access those blobs by accessing to +LinShare server and download them. + +This way helps you to share via whole network as long as they can access to LinShare server. + +To get an example or details explained, visit {sample-configuration-prefix-url}/blob.properties[blob.properties] + +*blob.export.linshare.url*: The URL to connect to LinShare + +*blob.export.linshare.token*: The authentication token to connect to LinShare diff --git a/docs/modules/servers/partials/configure/collecting-contacts.adoc b/docs/modules/servers/partials/configure/collecting-contacts.adoc new file mode 100644 index 00000000000..ed103124559 --- /dev/null +++ b/docs/modules/servers/partials/configure/collecting-contacts.adoc @@ -0,0 +1,38 @@ +== Motivation + +Many modern applications combines email and contacts. + +We want recipients of emails sent by a user to automatically be added to this user contacts, for convenience. This +should even be performed when a user sends emails via SMTP for example using thunderbird. + +== Design + +The idea is to send AMQP messages holding information about mail envelope for a traitment via a tierce application. + +== Configuration + +We can achieve this goal by combining simple mailets building blocks. + +Here is a sample pipeline achieving aforementioned objectives : + +[source,xml] +.... + + extractedContacts + + + amqp://${env:JAMES_AMQP_USERNAME}:${env:JAMES_AMQP_PASSWORD}@${env:JAMES_AMQP_HOST}:${env:JAMES_AMQP_PORT} + collector:email + extractedContacts + + +.... + +A sample message looks like: + +.... +{ + "userEmail": "sender@james.org", + "emails": ["to@james.org"] +} +.... \ No newline at end of file diff --git a/docs/modules/servers/partials/configure/collecting-events.adoc b/docs/modules/servers/partials/configure/collecting-events.adoc new file mode 100644 index 00000000000..4a3ee1f87d0 --- /dev/null +++ b/docs/modules/servers/partials/configure/collecting-events.adoc @@ -0,0 +1,68 @@ +== Motivation + +Many calendar application do add events invitation received by email directly in ones calendar. + +Such behaviours requires the calendar application to be aware of the ICalendar related emails a user received. + +== Design + +The idea is to write a portion of mailet pipeline extracting Icalendar attachments and to hold them as attachments that +can later be sent to other applications over AMQP to be treated in an asynchronous, decoupled fashion. + +== Configuration + +We can achieve this goal by combining simple mailets building blocks. + +Here is a sample pipeline achieving aforementioned objectives : + +[source,xml] +.... + + + text/calendar + rawIcalendar + + + rawIcalendar + + + rawIcalendar + icalendar + + + icalendar + + + icalendar + icalendarAsJson + rawIcalendar + + + amqp://${env:JAMES_AMQP_USERNAME}:${env:JAMES_AMQP_PASSWORD}@${env:JAMES_AMQP_HOST}:${env:JAMES_AMQP_PORT} + james:events + icalendarAsJson + + +.... + +A sample message looks like: + +.... +{ + "ical": "RAW_DATA_AS_TEXT_FOLLOWING_ICS_FORMAT", + "sender": "other@james.apache.org", + "recipient": "any@james2.apache.org", + "replyTo": "other@james.apache.org", + "uid": "f1514f44bf39311568d640727cff54e819573448d09d2e5677987ff29caa01a9e047feb2aab16e43439a608f28671ab7c10e754ce92be513f8e04ae9ff15e65a9819cf285a6962bc", + "dtstamp": "20170106T115036Z", + "method": "REQUEST", + "sequence": "0", + "recurrence-id": null +} +.... + +The following pipeline positions the X-MEETING-UID in the Header in order for mail user agent to correlate events with this mail. +The sample look like: +``` +X-MEETING-UID: f1514f44bf39311568d640727cff54e819573448d09d2e5677987ff29caa01a9e047feb2aab16e43439a608f28671ab7c10e754ce92be513f8e04ae9ff15e65a9819cf285a6962bc +``` \ No newline at end of file diff --git a/docs/modules/servers/partials/configure/dns.adoc b/docs/modules/servers/partials/configure/dns.adoc new file mode 100644 index 00000000000..e61491f20e5 --- /dev/null +++ b/docs/modules/servers/partials/configure/dns.adoc @@ -0,0 +1,52 @@ +Consult this link:{sample-configuration-prefix-url}/dnsservice.xml[example] +to get some examples and hints. + +Specifies DNS Server information for use by various components inside Apache James Server. + +DNS Transport services are controlled by a configuration block in +the dnsservice.xml. This block affects SMTP remote delivery. + +The dnsservice tag defines the boundaries of the configuration +block. It encloses all the relevant configuration for the DNS server. +The behavior of the DNS service is controlled by the attributes and +children of this tag. + +.dnsservice.xml content +|=== +| Property name | explanation + +| servers +| Information includes a list of DNS Servers to be used by James. These are +specified by the server elements, each of which is a child element of the +servers element. Each server element is the IP address of a single DNS server. +The server elements can have multiple server children. Enter ip address of your DNS server, one IP address per server +element. If no DNS servers are found and you have not specified any below, 127.0.0.1 will be used + +| autodiscover +| true or false - If you use autodiscover and add DNS servers manually a combination of all the DNS servers will be used. +If autodiscover is true, James will attempt to autodiscover the DNS servers configured on your underlying system. +Currently, this works if the OS has a unix-like /etc/resolv.xml, +or the system is Windows based with ipconfig or winipcfg. Change autodiscover to false if you would like to turn off autodiscovery +and set the DNS servers manually in the servers section + +| authoritative +| *true/false* - This tag specifies whether or not +to require authoritative (non-cached) DNS records; to only accept DNS responses that are +authoritative for the domain. It is primarily useful in an intranet/extranet environment. +This should always be *false* unless you understand the implications. + +| maxcachesize +| Maximum number of entries to maintain in the DNS cache (typically 50000) + +| negativeCacheTTL +| Sets the maximum length of time that negative records will be stored in the DNS negative cache in +seconds (a negative record means the name has not been found in the DNS). Values for this cache +can be positive meaning the time in seconds before retrying to resolve the name, zero meaning no +cache or a negative value meaning infinite caching. + +| singleIPperMX +| true or false (default) - Specifies if Apache James Server must try a single server for each multihomed mx host + +| verbose +| Turn on general debugging statements +|=== diff --git a/docs/modules/servers/partials/configure/domainlist.adoc b/docs/modules/servers/partials/configure/domainlist.adoc new file mode 100644 index 00000000000..bd693f7094b --- /dev/null +++ b/docs/modules/servers/partials/configure/domainlist.adoc @@ -0,0 +1,42 @@ +Consult this link:{sample-configuration-prefix-url}/domainlist.xml[example] +to get some examples and hints. + +This configuration block is defined by the *domainlist* tag. + +.domainlist.xml content +|=== +| Property name | explanation + +| domainnames +| Domainnames identifies the DNS namespace served by this instance of James. +These domainnames are used for both matcher/mailet processing and SMTP auth +to determine when a mail is intended for local delivery - Only applicable for XMLDomainList. The entries mentionned here will be created upon start. + +|autodetect +|true or false - If autodetect is true, James wil attempt to discover its own host name AND +use any explicitly specified servernames. +If autodetect is false, James will use only the specified domainnames. Defaults to false. + +|autodetectIP +|true or false - If autodetectIP is not false, James will also allow add the IP address for each servername. +The automatic IP detection is to support RFC 2821, Sec 4.1.3, address literals. Defaults to false. + +|defaultDomain +|Set the default domain which will be used if an email is send to a recipient without a domain part. +If no defaultdomain is set the first domain of the DomainList gets used. If the default is not yet contained by the Domain List, the domain will be created upon start. + +|read.cache.enable +|Experimental. Boolean, defaults to false. +Whether or not to cache domainlist.contains calls. Enable a faster execution however writes will take time +to propagate. + +|read.cache.expiracy +|Experimental. String (duration), defaults to 10 seconds (10s). Supported units are ms, s, m, h, d, w, month, y. +Expiracy of the cache. Longer means less reads are performed to the backend but writes will take longer to propagate. +Low values (a few seconds) are advised. + + +|=== + +To override autodetected domainnames simply add explicit domainname elements. +In most cases this will be necessary. By default, the domainname 'localhost' is specified. This can be removed, if required. diff --git a/docs/modules/servers/partials/configure/droplists.adoc b/docs/modules/servers/partials/configure/droplists.adoc new file mode 100644 index 00000000000..f08ae18a9b7 --- /dev/null +++ b/docs/modules/servers/partials/configure/droplists.adoc @@ -0,0 +1,30 @@ +The DropList, also known as the mail blacklist, is a collection of +domains and email addresses that are denied from sending emails within the system. +It is disabled by default. +To enable it, modify the `droplists.properties` file and include the `IsInDropList` matcher in the `mailetcontainer.xml`. +To disable it, adjust the `droplists.properties` file and remove the `IsInDropList` matcher from the `mailetcontainer.xml`. + +.droplists.properties content +|=== +| Property name | explanation + +| enabled +| Boolean. Governs whether DropLists should be enabled. Defaults to `false`. +|=== + +== Enabling Matcher + +Plug the `IsInDropList` matcher within `mailetcontainer.xml` : + +[source,xml] +.... + + transport + +.... + +== DropList management + +DropList management, including adding and deleting entries, is performed through the WebAdmin REST API. + +See xref:{pages-path}/operate/webadmin.adoc#_administrating_droplists[WebAdmin DropLists]. \ No newline at end of file diff --git a/docs/modules/servers/partials/configure/dsn.adoc b/docs/modules/servers/partials/configure/dsn.adoc new file mode 100644 index 00000000000..9ff0cfb3f72 --- /dev/null +++ b/docs/modules/servers/partials/configure/dsn.adoc @@ -0,0 +1,217 @@ +DSN introduced in link:https://tools.ietf.org/html/rfc3461[RFC-3461] allows a SMTP sender to demand status messages, +defined in link:https://tools.ietf.org/html/rfc3464[RFC-3464] to be sent back to the `Return-Path` upon delivery +progress. + +DSN support is not enabled by default, as it needs specific configuration of the +xref:{pages-path}/configure/mailetcontainer.adoc[mailetcontainer.xml] to be specification compliant. + +To enable it you need to: + +- Add DSN SMTP hooks as part of the SMTP server stack +- Configure xref:{pages-path}/configure/mailetcontainer.adoc[mailetcontainer.xml] to generate DSN bounces when needed + +== Enabling DSN in SMTP server stack + +For this simply add the `DSN hooks` in the handler chain in `smtpserver.xml` : + +[source,xml] +.... + + <...> + + + + + + <...> + + + +.... + +== Enabling DSN generation as part of mail processing + +For the below conditions to be matched we assume you follow +xref:{pages-path}/configure/remote-delivery-error-handling.adoc[RemoteDelivery error handling for MXs], which is a +requirement for detailed RemoteDelivery error and delay handling on top of the {server-name}. + +Here is a sample xref:{pages-path}/configure/mailetcontainer.adoc[mailetcontainer.xml] achieving the following DSN generation: + +- Generate a generic `delivered` notification if LocalDelivery succeeded, if requested +- Generate a generic `failed` notification in case of local errors, if requested +- Generate a specific `failed` notification in case of a non existing local user, if requested +- Generate a specific `failed` notification in case of an address rewriting loop, if requested +- Generate a `failed` notification in case of remote permanent errors, if requested. We blame the remote server... +- Generate a `delayed` notification in case of temporary remote errors we are about to retry, if requested. We blame the remote server... +- Generate a `failed` notification in case of temporary remote errors we are not going to retry (failed too many time), if requested. We blame the remote server... + +[subs=attributes+,xml] +---- + + + + + \ + + + + + + + + + + [FAILED] + true + Hi. This is the James mail server at [machine]. +I'm afraid I wasn't able to deliver your message to the following addresses. +This is a permanent error; I've given up. Sorry it didn't work out. Below +I include the list of recipients, and the reason why I was unable to deliver +your message. + failed + 5.0.0 + + + {mailet-repository-path-prefix}://var/mail/error/ + + + + + + + + false + + + + [SUCCESS] + true + Hi. This is the James mail server at [machine]. +I successfully delivered your message to the following addresses. +Note that it indicates your recipients received the message but do +not imply they read it. + delivered + 2.0.0 + + + + + + + + outgoing + 0 + 0 + 10 + true + + remote-delivery-error + + + + [FAILED] + true + Hi. This is the James mail server at [machine]. +I'm afraid I wasn't able to deliver your message to the following addresses. +This is a permanent error; I've given up. Sorry it didn't work out. +The remote server we should relay this mail to keep on failing. +Below I include the list of recipients, and the reason why I was unable to deliver +your message. + failed + 5.0.0 + + + {mailet-repository-path-prefix}://var/mail/error/remote-delivery/permanent/ + + + + + + + + + + + + + + + [FAILED] + true + Hi. This is the James mail server at [machine]. +I'm afraid I wasn't able to deliver your message to the following addresses. +This is a permanent error; I've given up. Sorry it didn't work out. +The remote server we should relay this mail to returns a permanent error. +Below I include the list of recipients, and the reason why I was unable to deliver +your message. + failed + 5.0.0 + + + + [DELAYED] + true + Hi. This is the James mail server at [machine]. +I'm afraid I wasn't able to deliver your message to the following addresses yet. +This is a temporary error: I will keep on trying. +Below I include the list of recipients, and the reason why I was unable to deliver +your message. + delayed + 4.0.0 + + + + + + + + [FAILED] + true + Hi. This is the James mail server at [machine]. +I'm afraid I wasn't able to deliver your message to the following addresses. +This is a permanent error; I've given up. Sorry it didn't work out. +The following addresses do not exist here. Sorry. + failed + 5.0.0 + + + {mailet-repository-path-prefix}://var/mail/address-error/ + + + + + + + {mailet-repository-path-prefix}://var/mail/relay-denied/ + Warning: You are sending an e-mail to a remote server. You must be authenticated to perform such an operation + + + + + + {mailet-repository-path-prefix}://var/mail/rrt-error/ + true + + + + [FAILED] + true + Hi. This is the James mail server at [machine]. +I'm afraid I wasn't able to deliver your message to the following addresses. +This is a permanent error; I've given up. Sorry it didn't work out. +The following addresses is caught in a rewriting loop. An admin should come and fix it (you likely want to report it). +Once resolved the admin should be able to resume the processing of your email. +Below I include the list of recipients, and the reason why I was unable to deliver +your message. + failed + 5.1.6 + + + + +---- + +== Limitations + +The out of the box tooling do not allow generating `relayed` DSN notification as RemoteDelivery misses a success +callback. \ No newline at end of file diff --git a/docs/modules/servers/partials/configure/extensions.adoc b/docs/modules/servers/partials/configure/extensions.adoc new file mode 100644 index 00000000000..6c2ae7cbaa9 --- /dev/null +++ b/docs/modules/servers/partials/configure/extensions.adoc @@ -0,0 +1,60 @@ +This files enables an operator to define additional bindings used to instantiate others extensions + +*guice.extension.module*: come separated list of fully qualified class name. These classes need to implement Guice modules. + +Here is an example of such a class : + +[source,java] +.... +public class MyServiceModule extends AbstractModule { + @Override + protected void configure() { + bind(MyServiceImpl.class).in(Scopes.SINGLETON); + bind(MyService.class).to(MyServiceImpl.class); + } +} +.... + +Recording it in extensions.properties : + +.... +guice.extension.module=com.project.MyServiceModule +.... + +Enables to inject MyService into your extensions. + + +*guice.extension.tasks*: come separated list of fully qualified class name. + +The extension can rely on the Task manager to supervise long-running task execution (progress, await, cancellation, scheduling...). +These extensions need to implement Task extension modules. + +Here is an example of such a class : + +[source,java] +.... +public class RspamdTaskExtensionModule implements TaskExtensionModule { + + @Inject + public RspamdTaskExtensionModule() { + } + + @Override + public Set> taskDTOModules() { + return Set.of(...); + } + + @Override + public Set> taskAdditionalInformationDTOModules() { + return Set.of(...); + } +} +.... + +Recording it in extensions.properties : + +.... +guice.extension.tasks=com.project.RspamdTaskExtensionModule +.... + +Read xref:{pages-path}/extending/index.adoc#_defining_custom_injections_for_your_extensions[this page] for more details. \ No newline at end of file diff --git a/docs/modules/servers/partials/configure/forCoreComponentsPartial.adoc b/docs/modules/servers/partials/configure/forCoreComponentsPartial.adoc new file mode 100644 index 00000000000..2ea8a961022 --- /dev/null +++ b/docs/modules/servers/partials/configure/forCoreComponentsPartial.adoc @@ -0,0 +1,15 @@ +== For core components + +By omitting these files, sane default values are used. + +** xref:{xref-base}/batchsizes.adoc[*batchsizes.properties*] allows to configure mailbox read batch sizes link:{sample-configuration-prefix-url}/sample-configuration/batchsizes.properties[example] +** xref:{xref-base}/dns.adoc[*dnsservice.xml*] allows to configure DNS resolution link:{sample-configuration-prefix-url}/sample-configuration/dnsservice.xml[example] +** xref:{xref-base}/domainlist.adoc[*domainlist.xml*] allows to configure Domain storage link:{sample-configuration-prefix-url}/sample-configuration/domainlist.xml[example] +** xref:{xref-base}/healthcheck.adoc[*healthcheck.properties*] allows to configure periodical healthchecks link:{sample-configuration-prefix-url}/sample-configuration/healthcheck.properties[example] +** xref:{xref-base}/mailetcontainer.adoc[*mailetcontainer.xml*] allows configuring mail processing link:{sample-configuration-prefix-url}/sample-configuration/mailetcontainer.xml[example] +*** xref:{xref-base}/mailets.adoc[This page] list matchers that can be used out of the box with the {server-name}. +*** xref:{xref-base}/matchers.adoc[This page] list matchers that can be used out of the box with the {server-name}. +** xref:{xref-base}/mailrepositorystore.adoc[*mailrepositorystore.xml*] enables registration of allowed MailRepository protcols and link them to MailRepository implementations link:{sample-configuration-prefix-url}/sample-configuration/mailrepositorystore.xml[example] +** xref:{xref-base}/recipientrewritetable.adoc[*recipientrewritetable.xml*] enables advanced configuration for the Recipient Rewrite Table component link:{sample-configuration-prefix-url}/sample-configuration/recipientrewritetable.xml[example] +*** xref:{xref-base}/matchers.adoc[This page] allows choosing the indexing technology. +** xref:{xref-base}/usersrepository.adoc[*usersrepository.xml*] allows configuration of user storage link:{sample-configuration-prefix-url}/sample-configuration/usersrepository.xml[example] diff --git a/docs/modules/servers/partials/configure/forExtensionsPartial.adoc b/docs/modules/servers/partials/configure/forExtensionsPartial.adoc new file mode 100644 index 00000000000..49720b50432 --- /dev/null +++ b/docs/modules/servers/partials/configure/forExtensionsPartial.adoc @@ -0,0 +1,14 @@ +== For extensions + +By omitting these files, no extra behaviour is added. + +** xref:{xref-base}/vault.adoc[*deletedMessageVault.properties*] allows to configure the DeletedMessageVault link:{sample-configuration-prefix-url}/sample-configuration/deletedMessageVault.properties[example] +** xref:{xref-base}/listeners.adoc[*listeners.xml*] enables configuration of Mailbox Listeners link:{sample-configuration-prefix-url}/sample-configuration/listeners.xml[example] +** xref:{xref-base}/extensions.adoc[*extensions.properties*] allows to extend James behaviour by loading your extensions in it link:{sample-configuration-prefix-url}/sample-configuration/extensions.properties[example] +** xref:{xref-base}/jvm.adoc[*jvm.properties*] lets you specify additional system properties without cluttering your command line +** xref:{xref-base}/spam.adoc[This page] documents Anti-Spam setup with SpamAssassin, Rspamd. +** xref:{xref-base}/remote-delivery-error-handling.adoc[This page] proposes a simple strategy for RemoteDelivery error handling. +** xref:{xref-base}/collecting-contacts.adoc[This page] documents contact collection +** xref:{xref-base}/collecting-events.adoc[This page] documents event collection +** xref:{xref-base}/dsn.adoc[This page] specified how to support SMTP Delivery Submission Notification (link:https://tools.ietf.org/html/rfc3461[RFC-3461]) +** xref:{xref-base}/droplists.adoc[This page] allows configuring drop lists. \ No newline at end of file diff --git a/docs/modules/servers/partials/configure/forProtocolsPartial.adoc b/docs/modules/servers/partials/configure/forProtocolsPartial.adoc new file mode 100644 index 00000000000..0999218482c --- /dev/null +++ b/docs/modules/servers/partials/configure/forProtocolsPartial.adoc @@ -0,0 +1,15 @@ +== For protocols + +By omitting these files, the underlying protocols will be disabled. + +** xref:{xref-base}/imap.adoc[*imapserver.xml*] allows configuration for the IMAP protocol link:{sample-configuration-prefix-url}imapserver.xml[example] +** xref:{xref-base}/jmap.adoc[*jmap.properties*] allows to configure the JMAP protocol link:{sample-configuration-prefix-url}jmap.properties[example] +** xref:{xref-base}/jmx.adoc[*jmx.properties*] allows configuration of JMX being used by the Command Line Interface link:{sample-configuration-prefix-url}jmx.properties[example] +** xref:{xref-base}/smtp.adoc#_lmtp_configuration[*lmtpserver.xml*] allows configuring the LMTP protocol link:{sample-configuration-prefix-url}lmtpserver.xml[example] +** *managesieveserver.xml* allows configuration for ManagedSieve (unsupported) link:{sample-configuration-prefix-url}managesieveserver.xml[example] +** xref:{xref-base}/pop3.adoc[*pop3server.xml*] allows configuration for the POP3 protocol (experimental) link:{sample-configuration-prefix-url}pop3server.xml[example] +** xref:{xref-base}/smtp.adoc[*smtpserver.xml*] allows configuration for the SMTP protocol link:{sample-configuration-prefix-url}smtpserver.xml[example] +*** xref:{xref-base}/smtp-hooks.adoc[This page] list SMTP hooks that can be used out of the box with the {server-name}. +** xref:{xref-base}/webadmin.adoc[*webadmin.properties*] enables configuration for the WebAdmin protocol link:{sample-configuration-prefix-url}webadmin.properties[example] +** xref:{xref-base}/ssl.adoc[This page] details SSL & TLS configuration. +** xref:{xref-base}/sieve.adoc[This page] details Sieve setup and how to enable ManageSieve. \ No newline at end of file diff --git a/docs/modules/servers/partials/configure/forStorageDependenciesPartial.adoc b/docs/modules/servers/partials/configure/forStorageDependenciesPartial.adoc new file mode 100644 index 00000000000..2d498aeda1c --- /dev/null +++ b/docs/modules/servers/partials/configure/forStorageDependenciesPartial.adoc @@ -0,0 +1,11 @@ +== For storage dependencies + +Except specific documented cases, these files are required, at least to establish a connection with the storage components. + +** xref:{xref-base}/blobstore.adoc[*blobstore.properties*] allows to configure the BlobStore link:{sample-configuration-prefix-url}/sample-configuration/blob.properties[example] + +** xref:{xref-base}/opensearch.adoc[*opensearch.properties*] allows to configure OpenSearch driver link:{sample-configuration-prefix-url}/sample-configuration/opensearch.properties[example] +** xref:{xref-base}/rabbitmq.adoc[*rabbitmq.properties*] allows configuration for the RabbitMQ driver link:{sample-configuration-prefix-url}/sample-configuration/rabbitmq.properties[example] +** xref:{xref-base}/redis.adoc[*redis.properties*] allows configuration for the Redis driver link:https://github.com/apache/james-project/blob/fabfdf4874da3aebb04e6fe4a7277322a395536a/server/mailet/rate-limiter-redis/redis.properties[example], that is used by optional +distributed rate limiting component. +** xref:{xref-base}/tika.adoc[*tika.properties*] allows configuring Tika as a backend for text extraction link:{sample-configuration-prefix-url}/sample-configuration/tika.properties[example] \ No newline at end of file diff --git a/docs/modules/servers/partials/configure/healthcheck.adoc b/docs/modules/servers/partials/configure/healthcheck.adoc new file mode 100644 index 00000000000..afcb1098a85 --- /dev/null +++ b/docs/modules/servers/partials/configure/healthcheck.adoc @@ -0,0 +1,22 @@ +Consult this link:{sample-configuration-prefix-url}/healthcheck.properties[example] +to get some examples and hints. + +Use this configuration to define the initial delay and period for the PeriodicalHealthChecks. It is only applicable with Guice products. + +.healthcheck.properties content +|=== +| Property name | explanation + +| healthcheck.period +| Define the period between two periodical health checks (default: 60s). Units supported are (ms - millisecond, s - second, m - minute, h - hour, d - day). Default unit is millisecond. + +| reception.check.user +| User to be using for running the "mail reception" health check. The user must exist. +If not specified, the mail reception check is a noop. + +| reception.check.timeout +| Period after which mail reception is considered faulty. Defaults to one minute. + +| additional.healthchecks +| List of fully qualified HealthCheck class names in addition to James' default healthchecks. Default to empty list. +|=== \ No newline at end of file diff --git a/docs/modules/servers/partials/configure/imap.adoc b/docs/modules/servers/partials/configure/imap.adoc new file mode 100644 index 00000000000..05decc72200 --- /dev/null +++ b/docs/modules/servers/partials/configure/imap.adoc @@ -0,0 +1,179 @@ +Consult this link:{sample-configuration-prefix-url}/imapserver.xml[example] +to get some examples and hints. + +The IMAP4 service is controlled by a configuration block in the imap4server.xml. +The imap4server tag defines the boundaries of the configuration block. It encloses +all the relevant configuration for the IMAP4 server. The behavior of the IMAP4 service is +controlled by the attributes and children of this tag. + +This tag has an optional boolean attribute - *enabled* - that defines whether the service is active or not. +The value defaults to "true" if not present. + +The standard children of the imapserver tag are: + +.imapserver.xml content +|=== +| Property name | explanation + +| bind +| Configure this to bind to a specific inetaddress. This is an optional integer value. This value is the port on which this IMAP4 server is configured +to listen. If the tag or value is absent then the service +will bind to all network interfaces for the machine If the tag or value is omitted, the value will default to the standard IMAP4 port +port 143 is the well-known/IANA registered port for IMAP +port 993 is the well-known/IANA registered port for IMAPS ie over SSL/TLS + +| connectionBacklog +| Number of connection backlog of the server (maximum number of queued connection requests) + +| compress +| true or false - Use or don't use COMPRESS extension. Defaults to false. + +| maxLineLength +| Maximal allowed line-length before a BAD response will get returned to the client +This should be set with caution as a to high value can make the server a target for DOS (Denial of Service)! + +| inMemorySizeLimit +| Optional. Size limit before we will start to stream to a temporary file. +Defaults to 10MB. Must be a positive integer, optionally with a unit: B, K, M, G. + +| literalSizeLimit +| Optional. Maximum size of a literal (IMAP APPEND). +Defaults to 0 (unlimited). Must be a positive integer, optionally with a unit: B, K, M, G. + +| plainAuthDisallowed +| Deprecated. Should use `auth.plainAuthEnabled`, `auth.requireSSL` instead. +Whether to enable Authentication PLAIN if the connection is not encrypted via SSL or STARTTLS. Defaults to `true`. + +| auth.plainAuthEnabled +| Whether to enable Authentication PLAIN/ LOGIN command. Defaults to `true`. + +| auth.requireSSL +| true or false. Defaults to `true`. Whether to require SSL to authenticate. If this is required, the IMAP server will disable authentication on unencrypted channels. + +| auth.oidc.oidcConfigurationURL +| Provide OIDC url address for information to user. Only configure this when you want to authenticate IMAP server using a OIDC provider. + +| auth.oidc.jwksURL +| Provide url to get OIDC's JSON Web Key Set to validate user token. Only configure this when you want to authenticate IMAP server using a OIDC provider. + +| auth.oidc.claim +| Claim string uses to identify user. E.g: "email_address". Only configure this when you want to authenticate IMAP server using a OIDC provider. + +| auth.oidc.scope +| An OAuth scope that is valid to access the service (RF: RFC7628). Only configure this when you want to authenticate IMAP server using a OIDC provider. + +| timeout +| Default to 30 minutes. After this time, inactive channels that have not performed read, write, or both operation for a while +will be closed. Negative value disable this behaviour. + +| enableIdle +| Default to true. If enabled IDLE commands will generate a server heartbeat on a regular period. + +| idleTimeInterval +| Defaults to 120. Needs to be a strictly positive integer. + +| idleTimeIntervalUnit +| Default to SECONDS. Needs to be a parseable TimeUnit. + +| disabledCaps +| Implemented server capabilities NOT to advertise to the client. Coma separated list. Defaults to no disabled capabilities. + +| jmxName +| The name given to the configuration + +| tls +| Set to true to support STARTTLS or SSL for the Socket. +To use this you need to copy sunjce_provider.jar to /path/james/lib directory. To create a new keystore execute: +`keytool -genkey -alias james -keyalg RSA -storetype PKCS12 -keystore /path/to/james/conf/keystore`. +Please note that each IMAP server exposed on different port can specify its own keystore, independently from any other +TLS based protocols. + +| handler.helloName +| This is the name used by the server to identify itself in the IMAP4 +protocol. If autodetect is TRUE, the server will discover its +own host name and use that in the protocol. If discovery fails, +the value of 'localhost' is used. If autodetect is FALSE, James +will use the specified value. + +| connectiontimeout +| Connection timeout in seconds + +| connectionLimit +| Set the maximum simultaneous incoming connections for this service + +| connectionLimitPerIP +| Set the maximum simultaneous incoming connections per IP for this service + +| concurrentRequests +| Maximum number of IMAP requests executed simultaneously. Past that limit requests are queued. Defaults to 20. +Negative values deactivate this feature, leading to unbounded concurrency. + +| maxQueueSize +| Upper bound to the IMAP throttler queue. Upon burst, requests that cannot be queued are rejected and not executed. +Integer, defaults to 4096, must be positive, 0 means no queue. + +| proxyRequired +| Enables proxy support for this service for incoming connections. HAProxy's protocol +(https://www.haproxy.org/download/2.7/doc/proxy-protocol.txt) is used and might be compatible +with other proxies (e.g. traefik). If enabled, it is *required* to initiate the connection +using HAProxy's proxy protocol. + +| bossWorkerCount +| Set the maximum count of boss threads. Boss threads are responsible for accepting incoming IMAP connections +and initializing associated resources. Optional integer, by default, boss threads are not used and this responsibility is being dealt with +by IO threads. + +| ioWorkerCount +| Set the maximum count of IO threads. IO threads are responsible for receiving incoming IMAP messages and framing them +(split line by line). IO threads also take care of compression and SSL encryption. Their tasks are short-lived and non-blocking. +Optional integer, defaults to 2 times the count of CPUs. + +| ignoreIDLEUponProcessing +| true or false - Allow disabling the heartbeat handler. Defaults to true. + +| useEpoll +| true or false - If true uses native EPOLL implementation for Netty otherwise uses NIO. Defaults to false. + +| gracefulShutdown +| true or false - If true attempts a graceful shutdown, which is safer but can take time. Defaults to true. + +| highWriteBufferWaterMark +| Netty's write buffer high watermark configuration. Unit supported: none, K, M. Netty defaults applied. + +| lowWriteBufferWaterMark +| Netty's write buffer low watermark configuration. Unit supported: none, K, M. Netty defaults applied. +|=== + +== OIDC setup +James IMAP support XOAUTH2 authentication mechanism which allow authenticating against a OIDC providers. +Please configure `auth.oidc` part to use this. + +We do supply an link:https://github.com/apache/james-project/tree/master/examples/oidc[example] of such a setup. +It uses the Keycloak OIDC provider, but usage of similar technologies is definitely doable. + +== Extending IMAP + +IMAP decoders, processors and encoder can be customized. xref:{pages-path}/extending/imap.adoc[Read more]. + +Check this link:https://github.com/apache/james-project/tree/master/examples/custom-imap[example]. + +The following configuration properties are available for extensions: + +.imapserver.xml content +|=== +| Property name | explanation + +| imapPackages +| Configure (union) of IMAP packages. IMAP packages bundles decoders (parsing IMAP commands) processors and encoders, +thus enable implementing new IMAP commands or replace existing IMAP processors. List of FQDNs, which can be located in +James extensions. + +| additionalConnectionChecks +| Configure (union) of additional connection checks. ConnectionCheck will check if the connection IP is secure or not. +| customProperties +| Properties for custom extension. Each tag is a property entry, and holds a string under the form key=value. +|=== + +== Mail user agents auto-configuration + +Check this example on link:https://github.com/apache/james-project/tree/master/examples/imap-autoconf[Mail user agents auto-configuration]. diff --git a/docs/modules/servers/partials/configure/jmap.adoc b/docs/modules/servers/partials/configure/jmap.adoc new file mode 100644 index 00000000000..5dbfd835078 --- /dev/null +++ b/docs/modules/servers/partials/configure/jmap.adoc @@ -0,0 +1,181 @@ +https://jmap.io/[JMAP] is intended to be a new standard for email clients to connect to mail +stores. It therefore intends to primarily replace IMAP + SMTP submission. It is also designed to be more +generic. It does not replace MTA-to-MTA SMTP transmission. + +Consult this link:{sample-configuration-prefix-url}/jmap.properties[example] +to get some examples and hints. + +.jmap.properties content +|=== +| Property name | explanation + +| enabled +| true/false. Governs whether JMAP should be enabled + +| jmap.port +| Optional. Defaults to 80. The port this server will be listening on. This value must be a valid +port, ranging between 1 and 65535 (inclusive) + +| tls.keystoreURL +| Keystore to be used for generating authentication tokens for password authentication mechanism. +This should not be the same keystore than the ones used by TLS based protocols. + +| tls.secret +| Password used to read the keystore + +| jwt.publickeypem.url +| Optional. Coma separated list of RSA public keys URLs to validate JWT tokens allowing requests to bypass authentication. +Defaults to an empty list. + +| url.prefix +| Optional. Configuration urlPrefix for JMAP routes. Default value: http://localhost. + +| websocket.url.prefix +| Optional. URL for JMAP WebSocket route. Default value: ws://localhost + +| email.send.max.size +| Optional. Configuration max size for message created in RFC-8621. +Default value: None. Supported units are B (bytes) K (KB) M (MB) G (GB). + +| max.size.attachments.per.mail +| Optional. Defaults to 20MB. RFC-8621 `maxSizeAttachmentsPerEmail` advertised to JMAP client as part of the +`urn:ietf:params:jmap:mail` capability. This needs to be at least 33% lower than `email.send.max.size` property +(in order to account for text body, headers, base64 encoding and MIME structures). +JMAP clients would use this property in order not to create too big emails. +Default value: None. Supported units are B (bytes) K (KB) M (MB) G (GB). + +| upload.max.size +| Optional. Configuration max size for each upload file in new JMAP-RFC-8621. +Default value: 30M. Supported units are B (bytes) K (KB) M (MB) G (GB). + +| upload.quota.limit +| Optional. Configure JMAP upload quota for total existing uploads' size per user. User exceeding the upload quota would result in old uploads being cleaned up. +Default value: 200M. Supported units are B (bytes) K (KB) M (MB) G (GB). + +| view.email.query.enabled +| Optional boolean. Defaults to false. Should simple Email/query be resolved against a {backend-name} projection, or should we resolve them against OpenSearch? +This enables a higher resilience, but the projection needs to be correctly populated. + +| user.provisioning.enabled +| Optional boolean. Defaults to true. Governs whether authenticated users that do not exist locally should be created in the users repository. + +| authentication.strategy.rfc8621 +| Optional List[String] with delimiter `,` . Specify which authentication strategies system admin want to use for JMAP RFC-8621 server. +The implicit package name is `org.apache.james.jmap.http`. If you have a custom authentication strategy outside this package, you have to specify its FQDN. +If no authentication strategy is specified, JMAP RFC-8621 server will fallback to default strategies: +`JWTAuthenticationStrategy`, `BasicAuthenticationStrategy`. + +| jmap.version.default +| Optional string. Defaults to `rfc-8621`. Allowed values: rfc-8621 +Which version of the JMAP protocol should be served when none supplied in the Accept header. + +| dynamic.jmap.prefix.resolution.enabled +| Optional boolean. Defaults to false. Supported Jmap session endpoint returns dynamic prefix in response. +When its config is true, and the HTTP request to Jmap session endpoint has a `X-JMAP-PREFIX` header with the value `http://new-domain/prefix`, +then `apiUrl, downloadUrl, uploadUrl, eventSourceUrl, webSocketUrl` in response will be changed with a new prefix. Example: The `apiUrl` will be "http://new-domain/prefix/jmap". +If the HTTP request to Jmap session endpoint has the `X-JMAP-WEBSOCKET-PREFIX` header with the value `ws://new-domain/prefix`, +then `capabilities."urn:ietf:params:jmap:websocket".url` in response will be "ws://new-domain/prefix/jmap/ws". + +| webpush.prevent.server.side.request.forgery +| Optional boolean. Prevent server side request forgery by preventing calls to the private network ranges. Defaults to true, can be disabled for testing. + +| cassandra.filter.projection.activated +|Optional boolean. Defaults to false. Casandra backends only. Whether to use or not the Cassandra projection +for JMAP filters. This projection optimizes reads, but needs to be correctly populated. Turning it on on +systems with filters already defined would result in those filters to be not read. + +| delay.sends.enabled +| Optional boolean. Defaults to false. Whether to support or not the delay send with JMAP protocol. + +| disabled.capabilities +| Optional, defaults to empty. Coma separated list of JMAP capabilities to reject. +This allows to prevent users from using some specific JMAP extensions. + +| email.get.full.max.size +| Optional, default value is 5. The max number of items for EmailGet full reads. + +| get.max.size +| Optional, default value is 500. The max number of items for /get methods. + +| set.max.size +| Optional, default value is 500. The max number of items for /set methods. +|=== + +== Wire tapping + +Enabling *TRACE* on `org.apache.james.jmap.wire` enables reactor-netty wiretap, logging of +all incoming and outgoing requests, outgoing requests. This will log also potentially sensible information +like authentication credentials. + +== OIDC set up + +The use of `XUserAuthenticationStrategy` allow delegating the authentication responsibility to a third party system, +which could be used to set up authentication against an OIDC provider. + +We do supply an link:https://github.com[example] of such a setup. It combines the link:https://www.keycloak.org/[Keycloack] +OIDC provider with the link:https://www.krakend.io/[Krackend] API gateway, but usage of similar technologies is definitely doable. + +== Generating a JWT key pair + +Apache James can alternatively be configured to check the validity of JWT tokens itself. No revocation mechanism is +supported in such a setup, and the `sub` claim is used to identify the user. The key configuration is static. + +This requires the `JWTAuthenticationStrategy` authentication strategy to be used. + +The {server-name} enforces the use of RSA-SHA-256. + +One can use OpenSSL to generate a JWT key pair : + + # private key + openssl genrsa -out rs256-4096-private.rsa 4096 + # public key + openssl rsa -in rs256-4096-private.rsa -pubout > rs256-4096-public.pem + +The private key can be used to generate JWT tokens, for instance +using link:https://github.com/vandium-io/jwtgen[jwtgen]: + + jwtgen -a RS256 -p rs256-4096-private.rsa 4096 -c "sub=bob@domain.tld" -e 3600 -V + +This token can then be passed as `Bearer` of the `Authorization` header : + + curl -H "Authorization: Bearer $token" -XPOST http://127.0.0.1:80/jmap -d '...' + +The public key can be referenced as `jwt.publickeypem.url` of the `jmap.properties` configuration file. + +== Annotated specification + +The [annotated documentation](https://github.com/apache/james-project/tree/master/server/protocols/jmap-rfc-8621/doc/specs/spec) +presents the limits of the JMAP RFC-8621 implementation part of the Apache James project. We furthermore implement +[JSON Meta Application Protocol (JMAP) Subprotocol for WebSocket](https://tools.ietf.org/html/rfc8887). + +Some methods / types are not yet implemented, some implementations are naive, and the PUSH is not supported yet. + +Users are invited to read these limitations before using actively the JMAP RFC-8621 implementation, and should ensure their +client applications only uses supported operations. + +Contributions enhancing support are furthermore welcomed. + +The list of tested JMAP clients are: + + - Experiments had been run on top of [LTT.RS](https://github.com/iNPUTmice/lttrs-android). Version in the Accept + headers needs to be explicitly set to `rfc-8621`. [Read more](https://github.com/linagora/james-project/pull/4089). + +== JMAP auto-configuration + +link:https://datatracker.ietf.org/doc/html/rfc8620[RFC-8620] defining JMAP core RFC defines precisely service location. + +James already redirects `http://jmap.domain.tld/.well-known/jmap` to the JMAP session. + +You can further help your clients by publishing extra SRV records. + +Eg: + +---- +_jmap._tcp.domain.tld. 3600 IN SRV 0 1 443 jmap.domain.tld. +---- + +== JMAP reverse-proxy set up + +James implementation adds the value of `X-Real-IP` header as part of the logging MDC. + +This allows for reverse proxies to cary other the IP address of the client down to the JMAP server for diagnostic purpose. \ No newline at end of file diff --git a/docs/modules/servers/partials/configure/jmx.adoc b/docs/modules/servers/partials/configure/jmx.adoc new file mode 100644 index 00000000000..706bd52298e --- /dev/null +++ b/docs/modules/servers/partials/configure/jmx.adoc @@ -0,0 +1,64 @@ +== Disclaimer + +JMX poses several security concerns and had been leveraged to conduct arbitrary code execution. +This threat is mitigated by not allowing remote connections to JMX, setting up authentication and pre-authentication filters. +However, we recommend to either run James in isolation (docker / own virtual machine) or disable JMX altogether.
+ +James JMX endpoint provides command line utilities and exposes a few metrics, also available on the metric endpoint.

+ +== Configuration + +This is used to configure the JMX MBean server via which all management is achieved. + +Consult this link:{sample-configuration-prefix-url}/jmx.properties[example] +in GIT to get some examples and hints. + +.jmx.properties content +|=== +| Property name | explanation + +| jmx.enabled +| Boolean. Should the JMX server be enabled? Defaults to `true`. + +| jmx.address +|The IP address (host name) the MBean Server will bind/listen to. + +| jmx.port +| The port number the MBean Server will bind/listen to. +|=== + +To access from a remote location, it has been reported that `-Dcom.sun.management.jmxremote.ssl=false` is needed as +a JVM argument. + +== JMX Security + +In order to set up JMX authentication, we need to put `jmxremote.password` and `jmxremote.access` file +to `/conf` directory. + +- `jmxremote.password`: define the username and password, that will be used by the client (here is james-cli) + +File's content example: +``` +james-admin pass1 +``` + +- `jmxremote.access`: define the pair of username and access permission + +File's content example: +``` +james-admin readwrite +``` + +When James runs with option `-Djames.jmx.credential.generation=true`, James will automatically generate `jmxremote.password` if the file does not exist. +Then the default username is `james-admin` and a random password. This option defaults to true. + +=== James-cli + +When the JMX server starts with authentication configuration, it will require the client need provide username/password for bypass. +To do that, we need set arguments `-username` and `-password` for the command request. + +Command example: +``` +james-cli -h 127.0.0.1 -p 9999 -username james-admin -password pass1 listdomains +``` + diff --git a/docs/modules/servers/partials/configure/jvm.adoc b/docs/modules/servers/partials/configure/jvm.adoc new file mode 100644 index 00000000000..08e59812644 --- /dev/null +++ b/docs/modules/servers/partials/configure/jvm.adoc @@ -0,0 +1,102 @@ +This file may contain any additional system properties for tweaking JVM execution. When you normally would add a command line option `-Dmy.property=whatever`, you can put it in this file as `my.property=whatever` instead. These properties will be added as system properties on server start. + +Note that in some rare cases this might not work, +when a property affects very early JVM start behaviour. + +For testing purposes, you may specify a different file path via the command line option `-Dextra.props=/some/other/jvm.properties`. + +== Control the threshold memory +This governs the threshold MimeMessageInputStreamSource relies on for storing MimeMessage content on disk. + +In `jvm.properties` +---- +james.message.memory.threshold=12K +---- + +(Optional). String (size, integer + size units, example: `12 KIB`, supported units are bytes KIB MIB GIB TIB). Defaults to 100KIB. + +== Enable the copy of message in memory +Should MimeMessageWrapper use a copy of the message in memory? Or should bigger message exceeding james.message.memory.threshold +be copied to temporary files? + +---- +james.message.usememorycopy=true +---- + +Optional. Boolean. Defaults to false. Recommended value is false. + +== Running resource leak detection +It is used to detect a resource not be disposed of before it's garbage-collected. + +In `jvm.properties` +---- +james.lifecycle.leak.detection.mode=advanced +---- + +Allowed mode values are: none, simple, advanced, testing + +The purpose of each mode is introduced in `config-system.xml` + +== Disabling host information in protocol MDC logging context + +Should we add the host in the MDC logging context for incoming IMAP, SMTP, POP3? Doing so, a DNS resolution +is attempted for each incoming connection, which can be costly. Remote IP is always added to the logging context. + + +In `jvm.properties` +---- +james.protocols.mdc.hostname=false +---- + +Optional. Boolean. Defaults to true. + +== Change the encoding type used for the blobId + +By default, the blobId is encoded in base64 url. The property `james.blob.id.hash.encoding` allows to change the encoding type. +The support value are: base16, hex, base32, base32Hex, base64, base64Url. + +Ex in `jvm.properties` +---- +james.blob.id.hash.encoding=base16 +---- + +Optional. String. Defaults to base64Url. + +== JMAP Quota draft compatibility + +Some JMAP clients depend on the JMAP Quota draft specifications. The property `james.jmap.quota.draft.compatibility` allows +to enable JMAP Quota draft compatibility for those clients and allow them a time window to adapt to the RFC-9245 JMAP Quota. + +Optional. Boolean. Default to false. + +Ex in `jvm.properties` +---- +james.jmap.quota.draft.compatibility=true +---- +To enable the compatibility. + +== Enable S3 metrics + +James supports extracting some S3 client-level metrics e.g. number of connections being used, time to acquire an S3 connection, total time to finish a S3 request... + +The property `james.s3.metrics.enabled` allows to enable S3 metrics collection. Please pay attention that enable this +would impact a bit on S3 performance. + +Optional. Boolean. Default to true. + +Ex in `jvm.properties` +---- +james.s3.metrics.enabled=false +---- +To disable the S3 metrics. + +== Reactor Stream Prefetch + +Prefetch to use in Reactor to stream convertions (S3 => InputStream). Default to 1. +Higher values will tend to block less often at the price of higher memory consumptions. + +Ex in `jvm.properties` +---- +# james.reactor.inputstream.prefetch=4 +---- + diff --git a/docs/modules/servers/partials/configure/listeners.adoc b/docs/modules/servers/partials/configure/listeners.adoc new file mode 100644 index 00000000000..4b8acb66709 --- /dev/null +++ b/docs/modules/servers/partials/configure/listeners.adoc @@ -0,0 +1,156 @@ +{server-name} relies on an event bus system to enrich mailbox capabilities. Each +operation performed on the mailbox will trigger related events, that can +be processed asynchronously by potentially any James node on a +distributed system. + +Mailbox listeners can register themselves on this event bus system to be +called when an event is fired, allowing to do different kind of extra +operations on the system. + +{server-name} allows the user to register potentially user defined additional mailbox listeners. + +Consult this link:{sample-configuration-prefix-url}/listener.xml[example] +to get some examples and hints. + +== Configuration + +The controls whether to launch group mailbox listener consumption. Defaults to true. Use with caution: +never disable on standalone james servers, and ensure at least some instances do consume group mailbox listeners within a +clustered topology. + +Mailbox listener configuration is under the XML element . + +Some MailboxListener allows you to specify if you want to run them synchronously or asynchronously. To do so, +for MailboxListener that supports this, you can use the *async* attribute (optional, per mailet default) to govern the execution mode. +If *true* the execution will be scheduled in a reactor elastic scheduler. If *false*, the execution is synchronous. + +Already provided additional listeners are documented below. + +=== SpamAssassinListener + +Provides per user real-time HAM/SPAM feedback to a SpamAssassin server depending on user actions. + +This mailet is asynchronous by default, but this behaviour can be overridden by the *async* +configuration property. + +This MailboxListener is supported. + +Example: + +[source,xml] +.... + + + + org.apache.james.mailbox.spamassassin.SpamAssassinListener + + +.... + +Please note that a `spamassassin.properties` file is needed. Read also +xref:{pages-path}/configure/spam.adoc[this page] for extra configuration required to support this feature. + +=== RspamdListener + +Provides HAM/SPAM feedback to a Rspamd server depending on user actions. + +This MailboxListener is supported. + +Example: + +[source,xml] +.... + + + + org.apache.james.rspamd.RspamdListener + + +.... + +Please note that a `rspamd.properties` file is needed. Read also +xref:{pages-path}/configure/spam.adoc[this page] for extra configuration required to support this feature. + + +=== QuotaThresholdCrossingListener + +Sends emails to users exceeding 80% and 99% of their quota to warn them (for instance). + +Here are the following properties you can configure: + +.QuotaThresholdCrossingListener configuration properties +|=== +| Property name | explanation + +| name +| Useful when configuring several time this listener. You might want to do so to use different rendering templates for +different occupation thresholds. + +| gracePeriod +| Period during which no more email for a given threshold should be sent. + +| subjectTemplate +| Mustache template for rendering the subject of the warning email. + +| bodyTemplate +| Mustache template for rendering the body of the warning email. + +| thresholds +| Floating number between 0 and 1 representing the threshold of quota occupation from which a mail should be sent. +Configuring several thresholds is supported. + +|=== + +Example: + +[source,xml] +.... + + + + org.apache.james.mailbox.quota.mailing.listeners.QuotaThresholdCrossingListener + QuotaThresholdCrossingListener-upper-threshold + + + + 0.8 + + + thirst + conf://templates/QuotaThresholdMailSubject.mustache + conf://templates/QuotaThresholdMailBody.mustache + 1week/ + + + +.... + +Here are examples of templates you can use: + +* For subject template: `conf://templates/QuotaThresholdMailSubject.mustache` + +.... +Warning: Your email usage just exceeded a configured threshold +.... + +* For body template: `conf://templates/QuotaThresholdMailBody.mustache` + +.... +You receive this email because you recently exceeded a threshold related to the quotas of your email account. + +{{#hasExceededSizeThreshold}} +You currently occupy more than {{sizeThreshold}} % of the total size allocated to you. +You currently occupy {{usedSize}}{{#hasSizeLimit}} on a total of {{limitSize}} allocated to you{{/hasSizeLimit}}. + +{{/hasExceededSizeThreshold}} +{{#hasExceededCountThreshold}} +You currently occupy more than {{countThreshold}} % of the total message count allocated to you. +You currently have {{usedCount}} messages{{#hasCountLimit}} on a total of {{limitCount}} allowed for you{{/hasCountLimit}}. + +{{/hasExceededCountThreshold}} +You need to be aware that actions leading to exceeded quotas will be denied. This will result in a degraded service. +To mitigate this issue you might reach your administrator in order to increase your configured quota. You might also delete some non important emails. +.... + +This MailboxListener is supported. + diff --git a/docs/modules/servers/partials/configure/mailetcontainer.adoc b/docs/modules/servers/partials/configure/mailetcontainer.adoc new file mode 100644 index 00000000000..a3e7c56e29a --- /dev/null +++ b/docs/modules/servers/partials/configure/mailetcontainer.adoc @@ -0,0 +1,95 @@ +This documents explains how to configure Mail processing. Mails pass through the MailetContainer. The +MailetContainer is a Matchers (condition for executing a mailet) and Mailets (execution units that perform +actions based on incoming mail) pipeline arranged into processors (List of mailet/matcher pairs allowing +better logical organisation). You can read more about these concepts on +xref:{pages-path}/architecture/index.adoc#_mail_processing[the mailet container feature description]. + +Apache James Server includes a number of xref:{pages-path}/configure/mailets.adoc[Packaged Mailets] and +xref:{pages-path}/configure/matchers.adoc[Packaged Matchers]. + +Furthermore, you can write and use with James xref:{pages-path}/extending/mail-processing.adoc[your own mailet and matchers]. + +Consult this link:{sample-configuration-prefix-url}/mailetcontainer.xml[example] +to get some examples and hints. + +.mailetcontainer.xml content +|=== +| Property name | explanation + +| context.postmaster +| The body of this element is the address that the server +will consider its postmaster address. This address will be listed as the sender address +of all error messages that originate from James. Also, all messages addressed to +postmaster@, where is one of the domain names whose +mail is being handled by James, will be redirected to this email address. +Set this to the appropriate email address for error reports +If this is set to a non-local email address, the mail server +will still function, but will generate a warning on startup. + +| spooler.threads +| Number of simultaneous threads used to spool the mails. Set to zero, it disables mail processing - use with +caution. + +| spooler.errorRepository +| Mail repository to store email in after several unrecoverable errors. Mails failing processing, for which +the Mailet Container could not handle Error, will be stored there after their processing had been attempted +5 times. Note that if standard java Exception occurs, *Error handling* section below will be applied +instead. +|=== + +== The Mailet Tag + +Consider the following simple *mailet* tag:

+ +[source,xml] +.... + + spam + +.... + +The mailet tag has two required attributes, *match* and *class*. + +The *match* attribute is set to the value of the specific Matcher class to be instantiated with a an +optional argument. If present, the argument is separated from the Matcher class name by an '='. Semantic +interpretation of the argument is left to the particular mailet. + +The *class* attribute is set to the value of the Mailet class that is to be instantiated. + +Finally, the children of the *mailet* tag define the configuration that is passed to the Mailet. The +tags used in this section should have no attributes or children. The names and bodies of the elements will be passed to +the mailet as (name, value) pairs. + +So in the example above, a Matcher instance of RemoteAddrNotInNetwork would be instantiated, and the value "127.0.0.1" +would be passed to the matcher. The Mailet of the pair will be an instance of ToProcessor, and it will be passed the (name, value) +pair of ("processor", "spam"). + +== Error handling + +If an exception is encountered during the execution of a mailet or a matcher, the default behaviour is to +process the mail using the *error* processor. + +The *onMailetException* property allows you to override this behaviour. You can specify another +processor than the *error* one for handling the errors of this mailet. + +The *ignore* special value also allows to continue processing and ignore the error. + +The *propagate* special value causes the mailet container to rethrow the +exception, propagating it to the execution context. In an SMTP execution context, the spooler will then requeue +the item and automatic retries will be setted up - note that attempts will be done for each recipients. In LMTP +(if LMTP is configured to execute the mailetContainer), the entire mail transaction is reported as failed to the caller. + +Moreover, the *onMatcherException* allows you to override matcher error handling. You can +specify another processor than the *error* one for handling the errors of this mailet. The *matchall* +special value also allows you to match all recipients when there is an error. The *nomatch* +special value also allows you to match no recipients when there is an error. + +Here is a short example to illustrate this: + +[source,xml] +.... + + deliveryError + nomatch + +.... \ No newline at end of file diff --git a/docs/modules/servers/partials/configure/mailets.adoc b/docs/modules/servers/partials/configure/mailets.adoc new file mode 100644 index 00000000000..5c20ff872a1 --- /dev/null +++ b/docs/modules/servers/partials/configure/mailets.adoc @@ -0,0 +1,146 @@ +This documentation page lists and documents Mailet that can be used within the +{server-name} MailetContainer in order to write your own mail processing logic with out-of-the-box components. + +== Supported mailets + +include::partial$AddDeliveredToHeader.adoc[] + +include::partial$AddFooter.adoc[] + +include::partial$AddSubjectPrefix.adoc[] + +include::partial$AmqpForwardAttribute.adoc[] + +include::partial$Bounce.adoc[] + +include::partial$ContactExtractor.adoc[] + +include::partial$ConvertTo7Bit.adoc[] + +include::partial$DKIMSign.adoc[] + +include::partial$DKIMVerify.adoc[] + +include::partial$DSNBounce.adoc[] + +include::partial$Expires.adoc[] + +include::partial$ExtractMDNOriginalJMAPMessageId.adoc[] + +include::partial$Forward.adoc[] + +include::partial$ICalendarParser.adoc[] + +include::partial$ICALToHeader.adoc[] + +include::partial$ICALToJsonAttribute.adoc[] + +include::partial$ICSSanitizer.adoc[] + +include::partial$LocalDelivery.adoc[] + +include::partial$LogMessage.adoc[] + +include::partial$MailAttributesListToMimeHeaders.adoc[] + +include::partial$MailAttributesToMimeHeaders.adoc[] + +include::partial$MetricsMailet.adoc[] + +include::partial$MimeDecodingMailet.adoc[] + +include::partial$NotifyPostmaster.adoc[] + +include::partial$NotifySender.adoc[] + +include::partial$Null.adoc[] + +include::partial$PostmasterAlias.adoc[] + +include::partial$RandomStoring.adoc[] + +include::partial$RecipientRewriteTable.adoc[] + +include::partial$RecipientToLowerCase.adoc[] + +include::partial$Redirect.adoc[] + +include::partial$RemoteDelivery.adoc[] + +include::partial$RemoveAllMailAttributes.adoc[] + +include::partial$RemoveMailAttribute.adoc[] + +include::partial$RemoveMimeHeader.adoc[] + +include::partial$RemoveMimeHeaderByPrefix.adoc[] + +include::partial$ReplaceContent.adoc[] + +include::partial$Resend.adoc[] + +include::partial$SetMailAttribute.adoc[] + +include::partial$SetMimeHeader.adoc[] + +include::partial$Sieve.adoc[] + +include::partial$Sign.adoc[] + +include::partial$SMIMECheckSignature.adoc[] + +include::partial$SMIMEDecrypt.adoc[] + +include::partial$SMIMESign.adoc[] + +include::partial$SpamAssassin.adoc[] + +include::partial$StripAttachment.adoc[] + +include::partial$TextCalendarBodyToAttachment.adoc[] + +include::partial$ToProcessor.adoc[] + +include::partial$ToRepository.adoc[] + +include::partial$ToSenderDomainRepository.adoc[] + +include::partial$VacationMailet.adoc[] + +include::partial$WithPriority.adoc[] + +include::partial$WithStorageDirective.adoc[] + +== Experimental mailets + +include::partial$AddHabeasWarrantMark.adoc[] + +include::partial$ClamAVScan.adoc[] + +include::partial$ClassifyBounce.adoc[] + +include::partial$FromRepository.adoc[] + +include::partial$HeadersToHTTP.adoc[] + +include::partial$OnlyText.adoc[] + +include::partial$ManageSieveMailet.adoc[] + +include::partial$RecoverAttachment.adoc[] + +include::partial$SerialiseToHTTP.adoc[] + +include::partial$ServerTime.adoc[] + +include::partial$SPF.adoc[] + +include::partial$ToPlainText.adoc[] + +include::partial$ToSenderFolder.adoc[] + +include::partial$UnwrapText.adoc[] + +include::partial$UseHeaderRecipients.adoc[] + +include::partial$WrapText.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/partials/configure/mailrepositorystore.adoc b/docs/modules/servers/partials/configure/mailrepositorystore.adoc new file mode 100644 index 00000000000..e0c7b88bc24 --- /dev/null +++ b/docs/modules/servers/partials/configure/mailrepositorystore.adoc @@ -0,0 +1,34 @@ +A `mail repository` allows storage of a mail as part of its +processing. Standard configuration relies on the following mail +repository. + +A mail repository is identified by its *url*, constituted of a *protocol* and a *path*. + +For instance in the url `{mailet-repository-path-prefix}://var/mail/error/` `{mail-repository-protocol}` is the protocol and `var/mail/error` the path. + +The *mailrepositorystore.xml* file allows registration of available protocols, and their binding to actual MailRepository +implementation. Note that extension developers can write their own MailRepository implementations, load them via the +`extensions-jars` mechanism as documented in xref:{pages-path}/extending/index.adoc['writing your own extensions'], and finally +associated to a protocol in *mailrepositorystore.xml* for a usage in *mailetcontainer.xml*. + +== Configuration + +Consult this link:{sample-configuration-prefix-url}/mailrepositorystore.xml[example] +to get some examples and hints. + +[subs=attributes+,xml] +---- + + {mail-repository-protocol} + + + + {mail-repository-protocol} + + + + +---- + +Only the *{mail-repository-class}* is available by default for the {server-name}. Mails metadata are stored in +{mail-repository-protocol} while the headers and bodies are stored within the xref:{pages-path}/architecture/index.adoc#_blobstore[BlobStore]. diff --git a/docs/modules/servers/partials/configure/matchers.adoc b/docs/modules/servers/partials/configure/matchers.adoc new file mode 100644 index 00000000000..a7e7526a512 --- /dev/null +++ b/docs/modules/servers/partials/configure/matchers.adoc @@ -0,0 +1,166 @@ +This documentation page lists and documents Matchers that can be used within the +{server-name} MailetContainer in order to write your own mail processing logic with out-of-the-box components. + +== Supported matchers + +include::partial$All.adoc[] + +include::partial$AtLeastPriority.adoc[] + +include::partial$AtMost.adoc[] + +include::partial$AtMostPriority.adoc[] + +include::partial$DLP.adoc[] + +include::partial$FetchedFrom.adoc[] + +include::partial$HasAttachment.adoc[] + +include::partial$HasException.adoc[] + +include::partial$HasHeader.adoc[] + +include::partial$HasHeaderWithPrefix.adoc[] + +include::partial$HasMailAttribute.adoc[] + +include::partial$HasMailAttributeWithValue.adoc[] + +include::partial$HasMailAttributeWithValueRegex.adoc[] + +include::partial$HasMimeType.adoc[] + +include::partial$HasMimeTypeParameter.adoc[] + +include::partial$HasPriority.adoc[] + +include::partial$HostIs.adoc[] + +include::partial$HostIsLocal.adoc[] + +include::partial$IsMarkedAsSpam.adoc[] + +include::partial$IsOverQuota.adoc[] + +include::partial$IsRemoteDeliveryPermanentError.adoc[] + +include::partial$IsRemoteDeliveryTemporaryError.adoc[] + +include::partial$IsSenderInRRTLoop.adoc[] + +include::partial$IsSingleRecipient.adoc[] + +include::partial$IsSMIMEEncrypted.adoc[] + +include::partial$IsSMIMESigned.adoc[] + +include::partial$IsX509CertificateSubject.adoc[] + +include::partial$RecipientDomainIs.adoc[] + +include::partial$RecipientIs.adoc[] + +include::partial$RecipientIsLocal.adoc[] + +include::partial$RecipientIsRegex.adoc[] + +include::partial$RelayLimit.adoc[] + +include::partial$RemoteAddrInNetwork.adoc[] + +include::partial$RemoteAddrNotInNetwork.adoc[] + +include::partial$RemoteDeliveryFailedWithSMTPCode.adoc[] + +include::partial$SenderDomainIs.adoc[] + +include::partial$SenderHostIs.adoc[] + +include::partial$SenderIs.adoc[] + +include::partial$SenderIsLocal.adoc[] + +include::partial$SenderIsNull.adoc[] + +include::partial$SenderIsRegex.adoc[] + +include::partial$SentByJmap.adoc[] + +include::partial$SentByMailet.adoc[] + +include::partial$SizeGreaterThan.adoc[] + +include::partial$SMTPAuthSuccessful.adoc[] + +include::partial$SMTPAuthUserIs.adoc[] + +include::partial$SMTPIsAuthNetwork.adoc[] + +include::partial$SubjectIs.adoc[] + +include::partial$SubjectStartsWith.adoc[] + +include::partial$TooManyRecipients.adoc[] + +include::partial$UserIs.adoc[] + +include::partial$XOriginatingIpInNetwork.adoc[] + +== Experimental matchers + +include::partial$AttachmentFileNameIs.adoc[] + +include::partial$CommandForListserv.adoc[] + +include::partial$CommandListservMatcher.adoc[] + +include::partial$CompareNumericHeaderValue.adoc[] + +include::partial$FileRegexMatcher.adoc[] + +include::partial$HasHabeasWarrantMark.adoc[] + +include::partial$InSpammerBlacklist.adoc[] + +include::partial$NESSpamCheck.adoc[] + +include::partial$SenderInFakeDomain.adoc[] + +== Composite matchers + +It is possible to combine together matchers in order to create a composite matcher, thus simplifying your +Mailet Container logic. + +Here are the available logical operations: + +* *And* : This matcher performs And conjunction between the two matchers: recipients needs to match both matcher in order to +match the composite matcher. +* *Or* : This matcher performs Or conjunction between the two matchers: consider it to be a union of the results. +It returns recipients from the Or composition results of the child matchers. +* *Not* : It returns recipients from the negated composition of the child Matcher(s). Consider what wasn't +in the result set of each child matcher. Of course it is easier to understand if it only +includes one matcher in the composition, the normal recommended use. +* *Xor* : It returns Recipients from the Xor composition of the child matchers. Consider it to be the inequality +operator for recipients. If any recipients match other matcher results +then the result does not include that recipient. + +Here is the syntax to adopt in *mailetcontainer.xml*: + +[source,xml] +.... + + + + + + + + + + + + relay + + +.... \ No newline at end of file diff --git a/docs/modules/servers/partials/configure/opensearch.adoc b/docs/modules/servers/partials/configure/opensearch.adoc new file mode 100644 index 00000000000..14b1a929b96 --- /dev/null +++ b/docs/modules/servers/partials/configure/opensearch.adoc @@ -0,0 +1,310 @@ +== Search overrides + +*Search overrides* allow resolution of predefined search queries against alternative sources of data +and allow bypassing OpenSearch. This is useful to handle most resynchronisation queries that +are simple enough to be resolved against {package-tag}. + +Possible values are: + +- `org.apache.james.mailbox.{package-tag}.search.AllSearchOverride` Some IMAP clients uses SEARCH ALL to fully list messages in +a mailbox and detect deletions. This is typically done by clients not supporting QRESYNC and from an IMAP perspective +is considered an optimisation as less data is transmitted compared to a FETCH command. Resolving such requests against +Cassandra is enabled by this search override and likely desirable. +- `org.apache.james.mailbox.{package-tag}.search.UidSearchOverride`. Same as above but restricted by ranges. +- `org.apache.james.mailbox.{package-tag}.search.DeletedSearchOverride`. Find deleted messages by looking up in the relevant Cassandra +table. +- `org.apache.james.mailbox.{package-tag}.search.DeletedWithRangeSearchOverride`. Same as above but limited by ranges. +- `org.apache.james.mailbox.{package-tag}.search.NotDeletedWithRangeSearchOverride`. List non deleted messages in a given range. +Lists all messages and filters out deleted message thus this is based on the following heuristic: most messages are not marked as deleted. +- `org.apache.james.mailbox.{package-tag}.search.UnseenSearchOverride`. List unseen messages in the corresponding cassandra projection. + +Please note that custom overrides can be defined here. `opensearch.search.overrides` allow specifying search overrides and is a +coma separated list of search override FQDNs. Default to none. + +EG: + +[subs=attributes+] +---- +opensearch.search.overrides=org.apache.james.mailbox.{package-tag}.search.AllSearchOverride,org.apache.james.mailbox.{package-tag}.search.DeletedSearchOverride, org.apache.james.mailbox.{package-tag}.search.DeletedWithRangeSearchOverride,org.apache.james.mailbox.{package-tag}.search.NotDeletedWithRangeSearchOverride,org.apache.james.mailbox.{package-tag}.search.UidSearchOverride,org.apache.james.mailbox.{package-tag}.search.UnseenSearchOverride +---- + +Consult this link:{sample-configuration-prefix-url}/opensearch.properties[example] +to get some examples and hints. + +If you want more explanation about OpenSearch configuration, you should visit the dedicated https://opensearch.org/[documentation]. + +== OpenSearch Configuration + +This file section is used to configure the connection tp an OpenSearch cluster. + +Here are the properties allowing to do so : + +.opensearch.properties content +|=== +| Property name | explanation + +| opensearch.clusterName +| Is the name of the cluster used by James. + +| opensearch.nb.shards +| Number of shards for index provisionned by James + +| opensearch.nb.replica +| Number of replica for index provisionned by James (default: 0) + +| opensearch.index.waitForActiveShards +| Wait for a certain number of active shard copies before proceeding with the operation. Defaults to 1. +You may consult the https://www.elastic.co/guide/en/elasticsearch/reference/7.10/docs-index_.html#active-shards[documentation] for more information. + +| opensearch.retryConnection.maxRetries +| Number of retries when connecting the cluster + +| opensearch.retryConnection.minDelay +| Minimum delay between connection attempts + +| opensearch.max.connections +| Maximum count of HTTP connections allowed for the OpenSearch driver. Optional integer, if unspecified driver defaults +applies (30 connections). + +| opensearch.max.connections.per.hosts +| Maximum count of HTTP connections per host allowed for the OpenSearch driver. Optional integer, if unspecified driver defaults +applies (10 connections). + +|=== + +=== Mailbox search + +The main use of OpenSearch within the {server-name} is indexing the mailbox content of users in order to enable +powerful and efficient full-text search of the mailbox content. + +Data indexing is performed asynchronously in a reliable fashion via a MailboxListener. + +Here are the properties related to the use of OpenSearch for Mailbox Search: + +.opensearch.properties content +|=== +| Property name | explanation + +| opensearch.index.mailbox.name +| Name of the mailbox index backed by the alias. It will be created if missing. + +| opensearch.index.name +| *Deprecated* Use *opensearch.index.mailbox.name* instead. +Name of the mailbox index backed by the alias. It will be created if missing. + +| opensearch.alias.read.mailbox.name +| Name of the alias to use by Apache James for mailbox reads. It will be created if missing. +The target of the alias is the index name configured above. + +| opensearch.alias.read.name +| *Deprecated* Use *opensearch.alias.read.mailbox.name* instead. +Name of the alias to use by Apache James for mailbox reads. It will be created if missing. +The target of the alias is the index name configured above. + +| opensearch.alias.write.mailbox.name +| Name of the alias to use by Apache James for mailbox writes. It will be created if missing. +The target of the alias is the index name configured above. + +| opensearch.alias.write.name +| *Deprecated* Use *opensearch.alias.write.mailbox.name* instead. +Name of the alias to use by Apache James for mailbox writes. It will be created if missing. +The target of the alias is the index name configured above. + +| opensearch.indexAttachments +| Indicates if you wish to index attachments or not (default: true). + +| opensearch.indexHeaders +| Indicates if you wish to index headers or not (default: true). Note that specific headers +(From, To, Cc, Bcc, Subject, Message-Id, Date, Content-Type) are still indexed in their dedicated type. +Header indexing is expensive as each header currently need to be stored as a nested document but +turning off headers indexing result in non-strict compliance with the IMAP / JMAP standards. + +| opensearch.message.index.optimize.move +| When set to true, James will attempt to reindex from the indexed message when moved. +If the message is not found, it will fall back to the old behavior (The message will be indexed from the blobStore source) +Default to false. + +| opensearch.indexBody +| Indicates if you wish to index body or not (default: true). This can be used to decrease the performance cost associated with indexing. +|=== + +=== Quota search + +Users are indexed by quota usage, allowing operators a quick audit of users quota occupation. + +Users quota are asynchronously indexed upon quota changes via a dedicated MailboxListener. + +The following properties affect quota search : + +.opensearch.properties content +|=== +| Property name | explanation + +| opensearch.index.quota.ratio.name +| Specify the OpenSearch alias name used for quotas + +| opensearch.alias.read.quota.ratio.name +| Specify the OpenSearch alias name used for reading quotas + +| opensearch.alias.write.quota.ratio.name +| Specify the OpenSearch alias name used for writing quotas +|=== + +=== Disabling OpenSearch + +OpenSearch component can be disabled but consider it would make search feature to not work. In particular it will break JMAP protocol and SEARCH IMAP comment in an nondeterministic way. +This is controlled in the `search.properties` file via the `implementation` property (defaults +to `OpenSearch`). Setting this configuration parameter to `scanning` will effectively disable OpenSearch, no +further indexation will be done however searches will rely on the scrolling search, leading to expensive and longer +searches. Disabling OpenSearch requires no extra action, however +xref:{pages-path}/operate/webadmin.adoc#_reindexing_all_mails[a full re-indexing]needs to be carried out when enabling OpenSearch. + +== SSL Trusting Configuration + +By default, James will use the system TrustStore to validate https server certificates, if the certificate on +ES side is already in the system TrustStore, you can leave the sslValidationStrategy property empty or set it to default. + +.opensearch.properties content +|=== +| Property name | explanation + +| opensearch.hostScheme.https.sslValidationStrategy +| Optional. Accept only *default*, *ignore*, *override*. Default is *default*. default: Use the default SSL TrustStore of the system. +ignore: Ignore SSL Validation check (not recommended). +override: Override the SSL Context to use a custom TrustStore containing ES server's certificate. + +|=== + +In some cases, you want to secure the connection from clients to ES by setting up a *https* protocol +with a self signed certificate. And you prefer to left the system ca-certificates un touch. +There are possible solutions to let the ES RestHighLevelClient to trust your self signed certificate. + +Second solution: importing a TrustStore containing the certificate into SSL context. +A certificate normally contains two parts: a public part in .crt file, another private part in .key file. +To trust the server, the client needs to be acknowledged that the server's certificate is in the list of +client's TrustStore. Basically, you can create a local TrustStore file containing the public part of a remote server +by execute this command: + +.... +keytool -import -v -trustcacerts -file certificatePublicFile.crt -keystore trustStoreFileName.jks -keypass fillThePassword -storepass fillThePassword +.... + +When there is a TrustStore file and the password to read, fill two options *trustStorePath* +and *trustStorePassword* with the TrustStore location and the password. ES client will accept +the certificate of ES service. + +.opensearch.properties content +|=== +| Property name | explanation + +| opensearch.hostScheme.https.trustStorePath +| Optional. Use it when https is configured in opensearch.hostScheme, and sslValidationStrategy is *override* +Configure OpenSearch rest client to use this trustStore file to recognize nginx's ssl certificate. +Once you chose *override*, you need to specify both trustStorePath and trustStorePassword. + +| opensearch.hostScheme.https.trustStorePassword +| Optional. Use it when https is configured in opensearch.hostScheme, and sslValidationStrategy is *override* +Configure OpenSearch rest client to use this trustStore file with the specified password. +Once you chose *override*, you need to specify both trustStorePath and trustStorePassword. + +|=== + +During SSL handshaking, the client can determine whether accept or reject connecting to a remote server by its hostname. +You can configure to use which HostNameVerifier in the client. + +.opensearch.properties content +|=== +| Property name | explanation + +| opensearch.hostScheme.https.hostNameVerifier +| Optional. Default is *default*. default: using the default hostname verifier provided by apache http client. +accept_any_hostname: accept any host (not recommended). + +|=== + +== Configure dedicated language analyzers for mailbox index + +OpenSearch supports various language analyzers out of the box: https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-lang-analyzer.html. + +James could utilize this to improve the user searching experience upon his language. + +While one could modify mailbox index mapping programmatically to customize this behavior, here we should just document a manual way to archive this without breaking our common index' mapping code. + +The idea is modifying mailbox index mappings with the target language analyzer as a JSON file, then submit it directly +to OpenSearch via cURL command to create the mailbox index before James start. Let's adapt dedicated language analyzers +where appropriate for the following fields: + +.Language analyzers propose change +|=== +| Field | Analyzer change + +| from.name +| `keep_mail_and_url` analyzer -> `keep_mail_and_url_language_a` analyzer + +| subject +| `keep_mail_and_url` analyzer -> `keep_mail_and_url_language_a` analyzer + +| to.name +| `keep_mail_and_url` analyzer -> `keep_mail_and_url_language_a` analyzer + +| cc.name +| `keep_mail_and_url` analyzer -> `keep_mail_and_url_language_a` analyzer + +| bcc.name +| `keep_mail_and_url` analyzer -> `keep_mail_and_url_language_a` analyzer + +| textBody +| `standard` analyzer -> `language_a` analyzer + +| htmlBody +| `standard` analyzer -> `language_a` analyzer + +| attachments.fileName +| `standard` analyzer -> `language_a` analyzer + +| attachments.textContent +| `standard` analyzer -> `language_a` analyzer + +|=== + +In there: + + - `keep_mail_and_url` and `standard` are our current analyzers for mailbox index. + - `language_a` analyzer: the built-in analyzer of OpenSearch. EG: `french` + - `keep_mail_and_url_language_a` analyzer: a custom of `keep_mail_and_url` analyzer with some language filters.Every language has +their own filters so please have a look at filters which your language need to add. EG which need to be added for French: +---- +"filter": { + "french_elision": { + "type": "elision", + "articles_case": true, + "articles": [ + "l", "m", "t", "qu", "n", "s", + "j", "d", "c", "jusqu", "quoiqu", + "lorsqu", "puisqu" + ] + }, + "french_stop": { + "type": "stop", + "stopwords": "_french_" + }, + "french_stemmer": { + "type": "stemmer", + "language": "light_french" + } +} +---- + +After modifying above proposed change, you should have a JSON file that contains new setting and mapping of mailbox index. Here +we provide https://github.com/apache/james-project/blob/master/mailbox/opensearch/example_french_index.json[a sample JSON for French language]. +If you want to customize that JSON file for your own language need, please make these modifications: + + - Replace the `french` analyzer with your built-in language (have a look at https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-lang-analyzer.html[built-in language analyzers]) + - Modify `keep_mail_and_url_french` analyzer' filters with your language filters, and customize the analyzer' name. + +Please change also `number_of_shards`, `number_of_replicas` and `index.write.wait_for_active_shards` values in the sample file according to your need. + +Run this cURL command with above JSON file to create `mailbox_v1` (Mailbox index' default name) index before James start: +---- +curl -X PUT ES_IP:ES_PORT/mailbox_v1 -H "Content-Type: application/json" -d @example_french_index.json +---- diff --git a/docs/modules/servers/partials/configure/pop3.adoc b/docs/modules/servers/partials/configure/pop3.adoc new file mode 100644 index 00000000000..dc01589791f --- /dev/null +++ b/docs/modules/servers/partials/configure/pop3.adoc @@ -0,0 +1,74 @@ +Consult this link:{sample-configuration-prefix-url}/pop3server.xml[example] +to get some examples and hints. + +The POP3 service is controlled by a configuration block in the pop3server.xml. +The pop3server tag defines the boundaries of the configuration block. It encloses +all the relevant configuration for the POP3 server. The behavior of the POP service is +controlled by the attributes and children of this tag. + +This tag has an optional boolean attribute - *enabled* - that defines whether the service is active or not. +The value defaults to "true" if not present. + +The standard children of the pop3server tag are: + +.jmx.properties content +|=== +| Property name | explanation + +| bind +| Configure this to bind to a specific inetaddress. This is an optional integer value. +This value is the port on which this POP3 server is configured +to listen. If the tag or value is absent then the service +will bind to all network interfaces for the machine If the tag or value is omitted, +the value will default to the standard POP3 port, 11 +port 995 is the well-known/IANA registered port for POP3S ie over SSL/TLS +port 110 is the well-known/IANA registered port for Standard POP3 + +| connectionBacklog +| + +| tls +| Set to true to support STARTTLS or SSL for the Socket. +To create a new keystore execute: +`keytool -genkey -alias james -keyalg RSA -storetype PKCS12 -keystore /path/to/james/conf/keystore` +Please note that each POP3 server exposed on different port can specify its own keystore, independently from any other +TLS based protocols. Read xref:{pages-path}/configure/ssl.adoc[SSL configuration page] for more information. + +| handler.helloName +| This is the name used by the server to identify itself in the POP3 +protocol. If autodetect is TRUE, the server will discover its +own host name and use that in the protocol. If discovery fails, +the value of 'localhost' is used. If autodetect is FALSE, James +will use the specified value. + +| handler.connectiontimeout +| Connection timeout in seconds + +| handler.connectionLimit +| Set the maximum simultaneous incoming connections for this service + +| handler.connectionLimitPerIP +| Set the maximum simultaneous incoming connections per IP for this service + +| handler.handlerchain +| This loads the core CommandHandlers. Only remove this if you really know what you are doing. + +| bossWorkerCount +| Set the maximum count of boss threads. Boss threads are responsible for accepting incoming POP3 connections +and initializing associated resources. Optional integer, by default, boss threads are not used and this responsibility is being dealt with +by IO threads. + +| ioWorkerCount +| Set the maximum count of IO threads. IO threads are responsible for receiving incoming POP3 messages and framing them +(split line by line). IO threads also take care of compression and SSL encryption. Their tasks are short-lived and non-blocking. +Optional integer, defaults to 2 times the count of CPUs. + +| maxExecutorCount +| Set the maximum count of worker threads. Worker threads takes care of potentially blocking tasks like executing POP3 requests. Optional integer, defaults to 16. + +| useEpoll +| true or false - If true uses native EPOLL implementation for Netty otherwise uses NIO. Defaults to false. + +| gracefulShutdown +| true or false - If true attempts a graceful shutdown, which is safer but can take time. Defaults to true. +|=== \ No newline at end of file diff --git a/docs/modules/servers/partials/configure/queue.adoc b/docs/modules/servers/partials/configure/queue.adoc new file mode 100644 index 00000000000..cbec12d7252 --- /dev/null +++ b/docs/modules/servers/partials/configure/queue.adoc @@ -0,0 +1,16 @@ +This configuration helps you configure mail queue you want to select. + +== Queue Configuration + +.queue.properties content +|=== +| Property name | explanation + +| mail.queue.choice +| Mail queue can be implemented by many type of message brokers: Pulsar, RabbitMQ,... This property will choose which mail queue you want, defaulting to RABBITMQ +|=== + +`mail.queue.choice` supports the following options: + +* You can specify the `RABBITMQ` if you want to choose RabbitMQ mail queue +* You can specify the `PULSAR` if you want to choose Pulsar mail queue diff --git a/docs/modules/servers/partials/configure/rabbitmq.adoc b/docs/modules/servers/partials/configure/rabbitmq.adoc new file mode 100644 index 00000000000..689bb17a57c --- /dev/null +++ b/docs/modules/servers/partials/configure/rabbitmq.adoc @@ -0,0 +1,162 @@ +This configuration helps you configure components using RabbitMQ. + +Consult this link:{sample-configuration-prefix-url}/rabbitmq.properties[example] +to get some examples and hints. + +== RabbitMQ Configuration + +.rabbitmq.properties content +|=== +| Property name | explanation + +| uri +| the amqp URI pointing to RabbitMQ server. If you use a vhost, specify it as well at the end of the URI. +Details about amqp URI format is in https://www.rabbitmq.com/uri-spec.html[RabbitMQ URI Specification] + +| management.uri +| the URI pointing to RabbitMQ Management Service. James need to retrieve some information about listing queues +from this service in runtime. +Details about URI format is in https://www.rabbitmq.com/management.html#usage-ui[RabbitMQ Management URI] + +| management.user +| username used to access management service + +| management.password +| password used to access management service + +| connection.pool.retries +| Configure retries count to retrieve a connection. Exponential backoff is performed between each retries. +Optional integer, defaults to 10 + +| connection.pool.min.delay.ms +| Configure initial duration (in ms) between two connection retries. Exponential backoff is performed between each retries. +Optional integer, defaults to 100 + +| channel.pool.retries +| Configure retries count to retrieve a channel. Exponential backoff is performed between each retries. +Optional integer, defaults to 3 + +| channel.pool.max.delay.ms +| Configure timeout duration (in ms) to obtain a rabbitmq channel. Defaults to 30 seconds. +Optional integer, defaults to 30 seconds. + +| channel.pool.size +| Configure the size of the channel pool. +Optional integer, defaults to 3 + +| driver.network.recovery.interval +| Optional, non-negative integer, default to 100ms. The interval (in ms) that RabbitMQ driver will automatic recovery wait before attempting to reconnect. See https://www.rabbitmq.com/client-libraries/java-api-guide#connection-recovery + +| ssl.enabled +| Is using ssl enabled +Optional boolean, defaults to false + +| ssl.management.enabled +| Is using ssl on management api enabled +Optional boolean, defaults to false + +| ssl.validation.strategy +| Configure the validation strategy used for rabbitmq connections. Possible values are default, ignore and override. +Optional string, defaults to using systemwide ssl configuration + +| ssl.truststore +| Points to the truststore (PKCS12) used for verifying rabbitmq connection. If configured then "ssl.truststore.password" must also be configured, +Optional string, defaults to systemwide truststore. "ssl.validation.strategy: override" must be configured if you want to use this + +| ssl.truststore.password +| Configure the truststore password. If configured then "ssl.truststore" must also be configured, +Optional string, defaults to empty string. "ssl.validation.strategy: override" must be configured if you want to use this + +| ssl.hostname.verifier +| Configure host name verification. Possible options are default and accept_any_hostname +Optional string, defaults to subject alternative name host verifier + +| ssl.keystore +| Points to the keystore(PKCS12) used for client certificate authentication. If configured then "ssl.keystore.password" must also be configured, +Optional string, defaults to empty string + +| ssl.keystore.password +| Configure the keystore password. If configured then "ssl.keystore" must also be configured, +Optional string, defaults to empty string + +| quorum.queues.enable +| Boolean. Whether to activate Quorum queue usage for all queues. +Quorum queues enables high availability. +False (default value) results in the usage of classic queues. + +| quorum.queues.replication.factor +| Strictly positive integer. The replication factor to use when creating quorum queues. + +| quorum.queues.delivery.limit +| Strictly positive integer. Value for x-delivery-limit queue parameter, default to none. Setting a delivery limit can +prevent RabbitMQ outage if message processing fails. Read https://www.rabbitmq.com/docs/quorum-queues#poison-message-handling + +| hosts +| Optional, default to the host specified as part of the URI. +Allow creating cluster aware connections. +A coma separated list of hosts, example: hosts=ip1:5672,ip2:5672 + +| mailqueue.publish.confirm.enabled +| Whether or not to enable publish confirms for the mail queue. Optional boolean, defaults to true. + +| event.bus.publish.confirm.enabled +| Whether or not to enable publish confirms for the event bus. Optional boolean, defaults to true. + +| event.bus.notification.durability.enabled +| Whether or not the queue backing notifications should be durable. Optional boolean, defaults to true. + +| event.bus.propagate.dispatch.error +| Whether to propagate errors back to the callers when eventbus fails to dispatch group events to RabbitMQ (then store the failed events in the event dead letters). +Optional boolean, defaults to true. + +| vhost +| Optional string. This parameter is only a workaround to support invalid URIs containing character like '_'. +You still need to specify the vhost in the uri parameter. + +|=== + +== Tuning RabbitMQ for quorum queue use + +While quorum queues are great at preserving your data and enabling High Availability, they demand more resources and +a greater care than regular RabbitMQ queues. + +See link:https://www.rabbitmq.com/docs/quorum-queues#performance-tuning[this section of RabbitMQ documentation regarding RabbitMQ quroum queue performance tunning]. + + - Provide decent amount of RAM memory to RabbitMQ. 4GB is a good start. + - Setting a delivery limit is advised as looping messages can cause extreme memory consumptions onto quorum queues. + - Set up Raft for small messages: + +.... +raft.segment_max_entries = 32768 +.... + +== RabbitMQ Tasks Configuration + +Tasks are WebAdmin triggered long running jobs. RabbitMQ is used to organise their execution in a work queue, +with an exclusive consumer. + +.rabbitmq.properties content +|=== +| Property name | explanation + +| task.consumption.enabled +| Whether to enable task consumption on this node. +Disable with caution (this only makes sense in a distributed setup where other nodes consume tasks). +Defaults to true. + +Limitation: Sometimes, some tasks running on James can be very heavy and take a couple of hours to complete. +If other tasks are being triggered meanwhile on WebAdmin, they go on the TaskManagerWorkQueue and James unack them, +telling RabbitMQ it will consume them later. If they don't get consumed before the consumer timeout setup in +RabbitMQ (default being 30 minutes), RabbitMQ closes the channel on an exception. It is thus advised to declare a +longer timeout in rabbitmq.conf. More https://www.rabbitmq.com/consumers.html#acknowledgement-timeout[here]. + +| task.queue.consumer.timeout +| Task queue consumer timeout. + +Optional. Duration (support multiple time units cf `DurationParser`), defaults to 1 day. + +Required at least RabbitMQ version 3.12 to have effect. +This is used to avoid the task queue consumer (which could run very long tasks) being disconnected by RabbitMQ after the default acknowledgement timeout 30 minutes. +References: https://www.rabbitmq.com/consumers.html#acknowledgement-timeout. + +|=== \ No newline at end of file diff --git a/docs/modules/servers/partials/configure/recipientrewritetable.adoc b/docs/modules/servers/partials/configure/recipientrewritetable.adoc new file mode 100644 index 00000000000..67edfe32ad5 --- /dev/null +++ b/docs/modules/servers/partials/configure/recipientrewritetable.adoc @@ -0,0 +1,15 @@ +Here are explanations on the different kinds about xref:{pages-path}/architecture/index.adoc#_recipient_rewrite_tables[recipient rewriting]. + +Consult this link:{sample-configuration-prefix-url}/recipientrewritetable.xml[example] +to get some examples and hints. + +.recipientrewritetable.xml +|=== +| Property name | explanation + +| recursiveMapping +| If set to false only the first mapping will get processed - Default true. + +| mappingLimit +|By setting the mappingLimit you can specify how much mapping will get processed before a bounce will send. This avoids infinity loops. Default 10. +|=== diff --git a/docs/modules/servers/partials/configure/redis.adoc b/docs/modules/servers/partials/configure/redis.adoc new file mode 100644 index 00000000000..183e335b671 --- /dev/null +++ b/docs/modules/servers/partials/configure/redis.adoc @@ -0,0 +1,28 @@ +This configuration helps you configure components using Redis. This so far only includes optional rate limiting component. + +Consult this link:https://github.com/apache/james-project/blob/fabfdf4874da3aebb04e6fe4a7277322a395536a/server/mailet/rate-limiter-redis/redis.properties[example] +to get some examples and hints. + +== Redis Configuration + +.redis.properties content +|=== +| Property name | explanation + +| redisURL +| the Redis URI pointing to Redis server. Compulsory. + +| redis.topology +| Redis server topology. Defaults to standalone. Possible values: standalone, cluster, master-replica + +| redis.readFrom +| The property to determine how Lettuce routes read operations to Redis server with topologies other than standalone. Defaults to master. Possible values: master, masterPreferred, replica, replicaPreferred, any + +Reference: https://github.com/redis/lettuce/wiki/ReadFrom-Settings + +| redis.ioThreads +| IO threads to be using for the underlying Netty networking resources. If unspecified driver defaults applies. + +| redis.workerThreads +| Worker threads to be using for the underlying driver. If unspecified driver defaults applies. +|=== diff --git a/docs/modules/servers/partials/configure/remote-delivery-error-handling.adoc b/docs/modules/servers/partials/configure/remote-delivery-error-handling.adoc new file mode 100644 index 00000000000..25d7c121bcc --- /dev/null +++ b/docs/modules/servers/partials/configure/remote-delivery-error-handling.adoc @@ -0,0 +1,117 @@ +The advanced server mailQueue implemented by combining RabbitMQ for messaging and {mailet-repository-path-prefix} for administrative operation +does not support delays. + +Delays are an important feature for Mail Exchange servers, allowing to defer in time the retries, potentially letting the +time for the remote server to recover. Furthermore, they enable implementation of advanced features like throttling and +rate limiting of emails sent to a given domain. + +As such, the use of the distributed server as a Mail Exchange server is currently discouraged. + +However, for operators willing to inter-operate with a limited set of well-identified, trusted remote mail servers, such +limitation can be reconsidered. The main concern then become error handling for remote mail server failures. The following +document will present a well tested strategy for Remote Delivery error handling leveraging standards Mail Processing components +and mechanisms. + +== Expectations + +Such a solution should: + +- Attempt delivery a single time +- Store transient and permanent failure in different mail repositories +- After a given number of tries, transient failures should be considered permanent + +== Design + +image::remote-delivery-error-handling.png[Schema detailing the proposed solution] + +- Remote Delivery is configured for performing a single retry. +- Remote Delivery attaches the error code and if the failure is permanent/temporary when transferring failed emails to the +bounce processor. +- The specified bounce processor will categorise the failure, and store temporary and permanent failures in different +mail repositories. +- A reprocessing of the temporary delivery errors mailRepository needs to be scheduled in a recurring basis. For +instance via a CRON job calling the right webadmin endpoint. +- A counter ensures that a configured number of delivery tries is not exceeded. + +=== Limitation + +MailRepositories are not meant for transient data storage, and thus are prone to tombstone issues. + +This might be acceptable if you need to send mail to well-known peers. For instance handling your mail gateway failures. +However a Mail Exchange server doing relay on the internet would quickly hit this limitation. + +Also note that external triggering of the retry process is needed. + +== Operation + +Here is an example of configuration achieving the proposed solution: + +[subs=attributes+,xml] +---- + + + + outgoing + 0 + 0 + 10 + true + + remote-delivery-error + + + + {mailet-repository-path-prefix}://var/mail/error/remote-delivery/permanent/ + + + + + + + {mailet-repository-path-prefix}://var/mail/error/remote-delivery/temporary/ + + + + {mailet-repository-path-prefix}://var/mail/error/remote-delivery/permanent/ + + + + {mailet-repository-path-prefix}://var/mail/error/ + + +---- + +Note: + +- The *relay* processor holds a RemoteDelivery mailet configured to do a single try, at most 5 times (see the AtMost matcher). +Mails exceeding the AtMost condition are considered as permanent delivery errors. Delivery errors are sent to the +*remote-delivery-error* processor. +- The *remote-delivery-error* stores temporary and permanent errors. +- Permanent relay errors are stored in `{mailet-repository-path-prefix}://var/mail/error/remote-delivery/permanent/`. +- Temporary relay errors are stored in `{mailet-repository-path-prefix}://var/mail/error/remote-delivery/temporary/`. + +In order to retry the relay of temporary failed emails, operators will have to configure a cron job for reprocessing +emails from *{mailet-repository-path-prefix}://var/mail/error/remote-delivery/temporary/* mailRepository into the *relay* processor. + +This can be achieved via the following webAdmin call : + +[subs=attributes+] +---- +curl -XPATCH 'http://ip:8000/mailRepositories/{mailet-repository-path-prefix}%3A%2F%2Fvar%2Fmail%2Ferror%2Fremote-delivery%2Ftemporary%2F/mails?action=reprocess&processor=relay' +---- + +See xref:{pages-path}/operate/webadmin.adoc#_reprocessing_mails_from_a_mail_repository[the documentation]. + +Administrators need to keep a close eye on permanent errors (that might require audit, and potentially contacting the remote +service supplier). + +To do so, one should regularly audit the content of *{mailet-repository-path-prefix}://var/mail/error/remote-delivery/permanent/*. This can be done +via webAdmin calls: + +[subs=attributes+] +---- +curl -XGET 'http://ip:8000/mailRepositories/{mailet-repository-path-prefix}%3A%2F%2Fvar%2Fmail%2Ferror%2Fremote-delivery%2Ftemporary%2F/mails' +---- + +See xref:{pages-path}/operate/webadmin.adoc#_listing_mails_contained_in_a_mail_repository[the documentation]. diff --git a/docs/modules/servers/partials/configure/search.adoc b/docs/modules/servers/partials/configure/search.adoc new file mode 100644 index 00000000000..239e266c21e --- /dev/null +++ b/docs/modules/servers/partials/configure/search.adoc @@ -0,0 +1,15 @@ +This configuration helps you configure the components used to back search. + +.search.properties content +|=== +| Property name | explanation + +| implementation +| The implementation to be used for search. Should be one of: + - *opensearch* : Index and search mails into OpenSearch. + - *scanning* : Do not index documents and perform scanning search, scrolling mailbox for matching contents. + This implementation can have a prohibitive cost. + - *opensearch-disabled* : Saves events to index into event dead letter. Make searches fails. + This is useful to start James without OpenSearch while still tracking messages to index for later recovery. This + can be used in order to ease delays for disaster recovery action plans. +|=== \ No newline at end of file diff --git a/docs/modules/servers/partials/configure/sieve.adoc b/docs/modules/servers/partials/configure/sieve.adoc new file mode 100644 index 00000000000..7ecd4c452f7 --- /dev/null +++ b/docs/modules/servers/partials/configure/sieve.adoc @@ -0,0 +1,89 @@ +James servers are able to evaluate and execute Sieve scripts. + +Sieve is an extensible mail filtering language. It's limited +expressiveness (no loops or variables, no tests with side +effects) allows user created scripts to be run safely on email +servers. Sieve is targeted at the final delivery phase (where +an incoming email is transferred to a user's mailbox). + +The following Sieve capabilities are supported by Apache James: + + - link:https://www.ietf.org/rfc/rfc2234.txt[RFC 2234 ABNF] + - link:https://www.ietf.org/rfc/rfc2244.txt[RFC 2244 ACAP] + - link:https://www.ietf.org/rfc/rfc2298.txt[RFC 2298 MDN] + - link:https://tools.ietf.org/html/rfc5228[RFC 5228 Sieve] + - link:https://tools.ietf.org/html/rfc4790[RFC 4790 IAPCR] + - link:https://tools.ietf.org/html/rfc5173[RFC 5173 Body Extension] + - link:https://datatracker.ietf.org/doc/html/rfc5230[RFC 5230 Vacations] + +To be correctly executed, please note that the *Sieve* mailet is required to be positioned prior the +*LocalDelivery* mailet. + +== Managing Sieve scripts + +A user willing to manage his Sieve scripts on the server can do so via several means: + +He can ask an admin to upload his script via the xref:{pages-path}/operate/cli.adoc[CLI] + +As James supports ManageSieve (link:https://datatracker.ietf.org/doc/html/rfc5804[RFC-5804]) a user +can thus use compatible software to manage his Sieve scripts.

+ +== ManageSieve protocol + +*WARNING*: ManageSieve protocol should be considered experimental. + +Consult link:{sample-configuration-prefix-url}/managesieveserver.xml[managesieveserver.xml] +in GIT to get some examples and hints. + +The service is controlled by a configuration block in the managesieveserver.xml. +The managesieveserver tag defines the boundaries of the configuration block. It encloses +all the relevant configuration for the ManageSieve server. The behavior of the ManageSieve service is +controlled by the attributes and children of this tag. + +This tag has an optional boolean attribute - *enabled* - that defines whether the service is active or not. +The value defaults to "false" if +not present. + +The standard children of the managesieveserver tag are: + +.managesieveserver.xml content +|=== +| Property name | explanation + +| bind +| Configure this to bind to a specific inetaddress. This is an optional integer value. This value is the port on which this ManageSieve server is configured to listen. If the tag or value is absent then the service +will bind to all network interfaces for the machine If the tag or value is omitted, the value will default to the standard ManageSieve port (port 4190 is the well-known/IANA registered port for ManageSieve.) + +| tls +| Set to true to support STARTTLS or SSL for the Socket. +To use this you need to copy sunjce_provider.jar to /path/james/lib directory. To create a new keystore execute: +`keytool -genkey -alias james -keyalg RSA -storetype PKCS12 -keystore /path/to/james/conf/keystore`. +Please note that each ManageSieve server exposed on different port can specify its own keystore, independently from any other +TLS based protocols. + +| connectionBacklog +| Number of connection backlog of the server (maximum number of queued connection requests) + +| connectiontimeout +| Connection timeout in seconds + +| connectionLimit +| Set the maximum simultaneous incoming connections for this service + +| connectionLimitPerIP +| Set the maximum simultaneous incoming connections per IP for this service + +| bossWorkerCount +| Set the maximum count of boss threads. Boss threads are responsible for accepting incoming ManageSieve connections +and initializing associated resources. Optional integer, by default, boss threads are not used and this responsibility is being dealt with +by IO threads. + +| ioWorkerCount +| Set the maximum count of IO threads. IO threads are responsible for receiving incoming ManageSieve messages and framing them +(split line by line). IO threads also take care of compression and SSL encryption. Their tasks are short-lived and non-blocking. +Optional integer, defaults to 2 times the count of CPUs. + +| maxExecutorCount +| Set the maximum count of worker threads. Worker threads takes care of potentially blocking tasks like executing ManageSieve commands. +Optional integer, defaults to 16. +|=== \ No newline at end of file diff --git a/docs/modules/servers/partials/configure/smtp-hooks.adoc b/docs/modules/servers/partials/configure/smtp-hooks.adoc new file mode 100644 index 00000000000..a660051a1d6 --- /dev/null +++ b/docs/modules/servers/partials/configure/smtp-hooks.adoc @@ -0,0 +1,364 @@ +This documentation page lists and documents SMTP hooks that can be used within the +{server-name} SMTP protocol stack in order to customize the way your SMTP server +behaves without of the box components. + +== DNSRBLHandler + +This command handler check against https://www.wikiwand.com/en/Domain_Name_System-based_Blackhole_List[RBL-Lists] +(Real-time Blackhole List). + +If getDetail is set to true it try to retrieve information from TXT Record +why the ip was blocked. Default to false. + +before you enable out the DNS RBL handler documented as an example below, +please take a moment to review each block in the list. +We have included some that various JAMES committers use, +but you must decide which, if any, are appropriate +for your environment. + +The mail servers hosting +@apache.org mailing lists, for example, use a +slightly different list than we have included below. +And it is likely that most JAMES committers also have +slightly different sets of lists. + +The SpamAssassin user's list would be one good place to discuss the +measured quality of various block lists. + +NOTA BENE: the domain names, below, are terminated +with '.' to ensure that they are absolute names in +DNS lookups. Under some circumstances, names that +are not explicitly absolute could be treated as +relative names, leading to incorrect results. This +has been observed on *nix and MS-Windows platforms +by users of multiple mail servers, and is not JAMES +specific. If you are unsure what this means for you, +please speak with your local system/network admins. + +This handler should be considered experimental. + +Example configuration: + +[source,xml] +.... + + + + false + + query.bondedsender.org. + sbl-xbl.spamhaus.org. + dul.dnsbl.sorbs.net. + list.dsbl.org. + + + +.... + +== DSN hooks + +The {server-name} has optional support for DSN (link:https://tools.ietf.org/html/rfc3461[RFC-3461]) + +Please read carefully xref:{pages-path}/configure/dsn.adoc[this page]. + +[source,xml] +.... + + <...> + + + + + + <...> + + + +.... + +Note that a specific configuration of xref:{pages-path}/configure/mailetcontainer.adoc[mailetcontainer.xml] is +required as well to be spec compliant. + +== MailPriorityHandler + +This handler can add a hint to the mail which tells the MailQueue which email should get processed first. + +Normally the MailQueue will just handle Mails in FIFO manner. + +Valid priority values are 1,5,9 where 9 is the highest. + +This handler should be considered experimental. + +Example configuration: + +[source,xml] +.... + + + + + + yourdomain1 + 1 + + + yourdomain2 + 9 + + + + +.... + +== MaxRcptHandler +If activated you can limit the maximal recipients. + +This handler should be considered experimental. + +Example configuration: + +[source,xml] +.... + + + + 10 + + +.... + +== POP3BeforeSMTPHandler + +This connect handler can be used to enable POP3 before SMTP support. + +Please note that only the ip get stored to identify an authenticated client. + +The expireTime is the time after which an ipAddress is handled as expired. + +This handler should be considered as unsupported. + +Example configuration: + +[source,xml] +.... + + + + 1 hour + + +.... + +== ResolvableEhloHeloHandler + +Checks for resolvable HELO/EHLO before accept the HELO/EHLO. + +If checkAuthNetworks is set to true sender domain will be checked also for clients that +are allowed to relay. Default is false. + +This handler should be considered experimental. + +Example configuration: + +[source,xml] +.... + + + + +.... + +== ReverseEqualsEhloHeloHandler + +Checks HELO/EHLO is equal the reverse of the connecting client before accept it +If checkAuthNetworks is set to true sender domain will be checked also for clients that +are allowed to relay. Default is false. + +This handler should be considered experimental. + +Example configuration: + +[source,xml] +.... + + + + +.... + +== SetMimeHeaderHandler + +This handler allows you to add mime headers to the processed mails. + +This handler should be considered experimental. + +Example configuration: + +[source,xml] +.... + + + + SPF-test + passed + + +.... + +== SpamAssassinHandler + +This MessageHandler could be used to check message against spamd before +accept the email. So it's possible to reject a message on smtplevel if a +configured hits amount is reached. + +This handler should be considered experimental. + +Example configuration: + +[source,xml] +.... + + + + 127.0.0.1 + 783 + 10 + + +.... + +== SPFHandler + +This command handler can be used to reject emails with not match the SPF record of the sender domain. + +If checkAuthNetworks is set to true sender domain will be checked also for clients that +are allowed to relay. Default is false. + +This handler should be considered experimental. + +Example configuration: + +[source,xml] +.... + + + + false + true + + +.... + +== URIRBLHandler + +This MessageHandler could be used to extract domain out of the message and check +this domains against uriRbllists. See http://www.surbl.org for more information. +The message get rejected if a domain matched. + +This handler should be considered experimental. + +Example configuration: + +[source,xml] +.... + + + + reject + true + + multi.surbl.org + + + +.... + +== ValidRcptHandler + +With ValidRcptHandler, all email will get rejected which has no valid user. + +You need to add the recipient to the validRecipient list if you want +to accept email for a recipient which not exist on the server. + +If you want James to act as a spamtrap or honeypot, you may comment ValidRcptHandler +and implement the needed processors in spoolmanager.xml. + +This handler should be considered stable. + +Example configuration: + +[source,xml] +.... + + + + +.... + +== ValidSenderDomainHandler + +If activated mail is only accepted if the sender contains +a resolvable domain having a valid MX Record or A Record associated! + +If checkAuthNetworks is set to true sender domain will be checked also for clients that +are allowed to relay. Default is false. + +Example configuration: + +[source,xml] +.... + + + + +.... + +== FUTURERELEASE hooks + +The {server-name} has optional support for FUTURERELEASE (link:https://www.rfc-editor.org/rfc/rfc4865.html[RFC-4865]) + +[source,xml] +.... + + <...> + + + + + + +.... + +== DKIM checks hooks + +Hook for verifying DKIM signatures of incoming mails. + +This hook can be restricted to specific sender domains and authenticate those emails against +their DKIM signature. Given a signed outgoing traffic this hook can use operators to accept legitimate +emails emitted by their infrastructure but redirected without envelope changes to there own domains by +some intermediate third parties. See link:https://issues.apache.org/jira/browse/JAMES-4032[JAMES-4032]. + +Supported configuration elements: + +- *forceCRLF*: Should CRLF be forced when computing body hashes. +- *onlyForSenderDomain*: If specified, the DKIM checks are applied just for the emails whose MAIL FROM specifies this domain. If unspecified, all emails are checked (default). +- *signatureRequired*: If DKIM signature is checked, the absence of signature will generate failure. Defaults to false. +- *expectedDToken*: If DKIM signature is checked, the body should contain at least one DKIM signature with this d token. If unspecified, all d tokens are considered valid (default). + +Example handlerchain configuration for `smtpserver.xml`: + +[source,xml] +.... + + + true + apache.org + true + apache.org + + + +.... + +Would allow emails using `apache.org` as a MAIL FROM domain if, and only if they contain a +valid DKIM signature for the `apache.org` domain. \ No newline at end of file diff --git a/docs/modules/servers/partials/configure/smtp.adoc b/docs/modules/servers/partials/configure/smtp.adoc new file mode 100644 index 00000000000..d2d8d519c4e --- /dev/null +++ b/docs/modules/servers/partials/configure/smtp.adoc @@ -0,0 +1,315 @@ +== Incoming SMTP + +Consult this link:{sample-configuration-prefix-url}/smtpserver.xml[example] +to get some examples and hints. + +The SMTP service is controlled by a configuration block in the smptserver.xml. +The smtpserver tag defines the boundaries of the configuration block. It encloses +all the relevant configuration for the SMTP server. The behavior of the SMTP service is +controlled by the attributes and children of this tag. + +This tag has an optional boolean attribute - *enabled* - that defines whether the service is active or not. The value defaults to "true" if +not present. + +The standard children of the smtpserver tag are: + +.smtpserver.xml content +|=== +| Property name | explanation + +| bind +| A list of address:port separed by comma - This is an optional value. If present, this value is a string describing +the IP address to which this service should be bound. If the tag or value is absent then the service +will bind to all network interfaces for the machine on port 25. Port 25 is the well-known/IANA registered port for SMTP. +Port 465 is the well-known/IANA registered port for SMTP over TLS. + +| connectBacklog +|The IP address (host name) the MBean Server will bind/listen to. + +| tls +| Set to true to support STARTTLS or SSL for the Socket. +To use this you need to copy sunjce_provider.jar to /path/james/lib directory. To create a new keystore execute: +`keytool -genkey -alias james -keyalg RSA -storetype PKCS12 -keystore /path/to/james/conf/keystore`. +The algorithm is optional and only needs to be specified when using something other +than the Sun JCE provider - You could use IbmX509 with IBM Java runtime. +Please note that each SMTP/LMTP server exposed on different port can specify its own keystore, independently from any other +TLS based protocols. + +| helloName +| This is a required tag with an optional body that defines the server name +used in the initial service greeting. The tag may have an optional attribute - *autodetect*. If +the autodetect attribute is present and true, the service will use the local hostname +returned by the Java libraries. If autodetect is absent or false, the body of the tag will be used. In +this case, if nobody is present, the value "localhost" will be used. + +| connectionTimeout +| This is an optional tag with a non-negative integer body. Connection timeout in seconds. + +| connectionLimit +| Set the maximum simultaneous incoming connections for this service. + +| connectionLimitPerIP +| Set the maximum simultaneous incoming connections per IP for this service. + +| proxyRequired +| Enables proxy support for this service for incoming connections. HAProxy's protocol +(https://www.haproxy.org/download/2.7/doc/proxy-protocol.txt) is used and might be compatible +with other proxies (e.g. traefik). If enabled, it is *required* to initiate the connection +using HAProxy's proxy protocol. + +| authRequired +| (deprecated) use auth.announce instead. + +This is an optional tag with a boolean body. If true, then the server will +announce authentication after HELO command. If this tag is absent, or the value +is false then the client will not be prompted for authentication. Only simple user/password authentication is +supported at this time. Supported values: + + * true: announced only to not authorizedAddresses + + * false: don't announce AUTH. If absent, *authorizedAddresses* are set to a wildcard to accept all remote hosts. + + * announce: like true, but always announce AUTH capability to clients + +Please note that emails are only relayed if, and only if, the user did authenticate, or is in an authorized network, +regardless of this option. + +| auth.announce +| This is an optional tag. Possible values are: + +* never: Don't announce auth. + +* always: always announce AUTH capability to clients. + +* forUnauthorizedAddresses: announced only to not authorizedAddresses + +Please note that emails are only relayed if, and only if, the user did authenticate, or is in an authorized network, +regardless of this option. + +| auth.requireSSL +| This is an optional tag, defaults to true. If true, authentication is not advertised via capabilities on unencrypted +channels. + +| auth.plainAuthEnabled +| This is an optional tag, defaults to true. If false, AUTH PLAIN and AUTH LOGIN will not be exposed. This setting +can be used to enforce strong authentication mechanisms. + +| auth.oidc.oidcConfigurationURL +| Provide OIDC url address for information to user. Only configure this when you want to authenticate SMTP server using a OIDC provider. + +| auth.oidc.jwksURL +| Provide url to get OIDC's JSON Web Key Set to validate user token. Only configure this when you want to authenticate SMTP server using a OIDC provider. + +| auth.oidc.claim +| Claim string uses to identify user. E.g: "email_address". Only configure this when you want to authenticate SMTP server using a OIDC provider. + +| auth.oidc.scope +| An OAuth scope that is valid to access the service (RF: RFC7628). Only configure this when you want to authenticate SMTP server using a OIDC provider. + +| auth.oidc.introspection.url +| Optional. An OAuth introspection token URL will be called to validate the token (RF: RFC7662). +Only configure this when you want to validate the revocation token by the OIDC provider. +Note that James always verifies the signature of the token even whether this configuration is provided or not. + +| auth.oidc.introspection.auth +| Optional. Provide Authorization in header request when introspecting token. +Eg: `Basic xyz` + +| auth.oidc.userinfo.url +| Optional. An Userinfo URL will be called to validate the token (RF: OpenId.Core https://openid.net/specs/openid-connect-core-1_0.html). +Only configure this when you want to validate the revocation token by the OIDC provider. +Note that James always verifies the signature of the token even whether this configuration is provided or not. +James will ignore check token by userInfo if the `auth.oidc.introspection.url` is already configured + +| authorizedAddresses +| Authorize specific addresses/networks. + +If you use SMTP AUTH, addresses that match those specified here will +be permitted to relay without SMTP AUTH. If you do not use SMTP +AUTH, and you specify addresses here, then only addresses that match +those specified will be permitted to relay. + +Addresses may be specified as a IP address or domain name, with an +optional netmask, e.g., + +127.*, 127.0.0.0/8, 127.0.0.0/255.0.0.0, and localhost/8 are all the same + +See also the RemoteAddrNotInNetwork matcher in the transport processor. +You would generally use one OR the other approach. + +| verifyIdentity +| This is an optional tag. This options governs MAIL FROM verifications, and prevents spoofing of the MAIL FROM +envelop field. + +The following values are supported: + + - `strict`: use of a local domain in MAIL FROM requires the SMTP client to be authenticated with a matching user or one + of its aliases. It will verify that the sender address matches the address of the user or one of its alias (from user or domain aliases). + This prevents a user of your mail server from acting as someone else + - `disabled`: no check is performed and third party are free to send emails as local users. Note that relaying emails will + need third party to be authenticated thus preventing open relays. + - `relaxed`: Based on a simple heuristic to determine if the SMTP client is a MUA or a MX (use of a valid domain in EHLO), + we do act as `strict` for MUAs thus prompting them early for the need of authentication, but accept use of local MAIL FROM for + MX. Authentication can then be delayed to later, eg after DATA transaction with the DKIMHook which might allow email looping through + third party domains via mail redirection, effectively enforcing that the mail originates from our servers. See + link:https://issues.apache.org/jira/browse/JAMES-4032[JAMES-4032] for detailed explanation. + +Backward compatibility is provided and thus the following values are supported: + + - `true`: act as `strict` + - `false`: act as `disabled` + +| maxmessagesize +| This is an optional tag with a non-negative integer body. It specifies the maximum +size, in kbytes, of any message that will be transmitted by this SMTP server. It is a service-wide, as opposed to +a per user, limit. If the value is zero then there is no limit. If the tag isn't specified, the service will +default to an unlimited message size. Must be a positive integer, optionally with a unit: B, K, M, G. + +| heloEhloEnforcement +| This sets whether to enforce the use of HELO/EHLO salutation before a +MAIL command is accepted. If unspecified, the value defaults to true. + +| smtpGreeting +| This sets the SMTPGreeting which will be used when connect to the smtpserver +If none is specified a default is generated + +| handlerchain +| The configuration handler chain. See xref:{pages-path}/configure/smtp-hooks.adoc[this page] for configuring out-of the +box extra SMTP handlers and hooks. + +| bossWorkerCount +| Set the maximum count of boss threads. Boss threads are responsible for accepting incoming SMTP connections +and initializing associated resources. Optional integer, by default, boss threads are not used and this responsibility is being dealt with +by IO threads. + +| ioWorkerCount +| Set the maximum count of IO threads. IO threads are responsible for receiving incoming SMTP messages and framing them +(split line by line). IO threads also take care of compression and SSL encryption. Their tasks are short-lived and non-blocking. +Optional integer, defaults to 2 times the count of CPUs. + +| maxExecutorCount +| Set the maximum count of worker threads. Worker threads takes care of potentially blocking tasks like executing SMTP commands. +Optional integer, defaults to 16. + +| useEpoll +| true or false - If true uses native EPOLL implementation for Netty otherwise uses NIO. Defaults to false. + +| gracefulShutdown +| true or false - If true attempts a graceful shutdown, which is safer but can take time. Defaults to true. + +| disabledFeatures +| Extended SMTP features to hide in EHLO responses. +|=== + +=== OIDC setup +James SMTP support XOAUTH2 authentication mechanism which allow authenticating against a OIDC providers. +Please configure `auth.oidc` part to use this. + +We do supply an link:https://github.com/apache/james-project/tree/master/examples/oidc[example] of such a setup. +It uses the Keycloak OIDC provider, but usage of similar technologies is definitely doable. + +== About open relays + +Authenticated SMTP is a method of securing your SMTP server. With SMTP AUTH enabled senders who wish to +relay mail through the SMTP server (that is, send mail that is eventually to be delivered to another SMTP +server) must authenticate themselves to Apache James Server before sending their message. Mail that is to be delivered +locally does not require authentication. This method ensures that spammers cannot use your SMTP server +to send unauthorized mail, while still enabling users who may not have fixed IP addresses to send their +messages. + +Mail servers that allow spammers to send unauthorized email are known as open relays. So SMTP AUTH +is a mechanism for ensuring that your server is not an open relay. + +It is extremely important that your server not be configured as an open relay. Aside from potential +costs associated with usage by spammers, connections from servers that are determined to be open relays +are routinely rejected by SMTP servers. This can severely impede the ability of your mail server to +send mail. + +At this time Apache James Server only supports simple user name / password authentication. + +As mentioned above, SMTP AUTH requires that Apache James Server be able to distinguish between mail intended +for local delivery and mail intended for remote delivery. Apache James Server makes this determination by matching the +domain to which the mail was sent against the *DomainList* component, configured by +xref:{pages-path}/configure/domainlist.adoc[*domainlist.xml*]. + +The {server-name} is configured out of the box so as to not serve as an open relay for spammers. This is done +by relayed emails originate from a trusted source. This includes: + +* Authenticated SMTP/JMAP users +* Mails generated by the server (eg: bounces) +* Mails originating from a trusted network as configured in *smtpserver.xml* + +If you wish to ensure that authenticated users can only send email from their own account, you may +optionally set the verifyIdentity element of the smtpserver configuration block to "true". + +=== Verification + +Verify that you have not inadvertently configured your server as an open relay. This is most easily +accomplished by using the service provided at https://mxtoolbox.com/diagnostic.aspx[mxtoolbox.com]. mxtoolbox.com will +check your mail server and inform you if it is an open relay. This tool further more verifies additional properties like: + +* Your DNS configuration, especially that you mail server IP has a valid reverse DNS entry +* That your SMTP connection is secured +* That you are not an OpenRelay +* This website also allow a quick lookup to ensure your mail server is not in public blacklists. + +Of course it is also necessary to confirm that users and log in and send +mail through your server. This can be accomplished using any standard mail client (i.e. Thunderbird, Outlook, +Eudora, Evolution). + +== LMTP Configuration + +Consult this link:{sample-configuration-prefix-url}/lmtpserver.xml[example] +to get some examples and hints. + +The configuration is the same of for SMTP. + +By default, it is deactivated. You can activate it alongside SMTP and bind for example on port 24. + +The default LMTP server stores directly emails in user mailboxes, without further treatment. + +However we do ship an alternative handler chain allowing to execute the mailet container, thus achieving a behaviour similar +to the default SMTP protocol. Here is how to achieve this: + +[source,xml] +.... + + + lmtpserver + 0.0.0.0:24 + 200 + 1200 + 0 + 0 + 0 + + + + + +.... + +Note that by default the mailet container is executed with all recipients at once and do not allow per recipient +error reporting. An option splitExecution allow to execute the mailet container for each recipient separately and mitigate this +limitation at the cost of performance. + +[source,xml] +.... + + + lmtpserver + 0.0.0.0:24 + 200 + 1200 + 0 + 0 + 0 + + + + true + + + + +.... \ No newline at end of file diff --git a/docs/modules/servers/partials/configure/spam.adoc b/docs/modules/servers/partials/configure/spam.adoc new file mode 100644 index 00000000000..5e5b8b2d6f0 --- /dev/null +++ b/docs/modules/servers/partials/configure/spam.adoc @@ -0,0 +1,191 @@ +Anti-Spam system can be configured via two main different mechanisms: + +* SMTP Hooks; +* Mailets; + +== AntiSpam SMTP Hooks + +"FastFail" SMTP Hooks acts to reject before spooling +on the SMTP level. The Spam detector hook can be used as a fastfail hook, therefore +Spam filtering system must run as a server on the same machine as the Apache James Server. + +SMTP Hooks for non-existent users, DSN filter, domains with invalid MX record, +can also be configured. + +*SpamAssassinHandler* (experimental) also enables to classify the messages as spam or not +with a configurable score threshold (`0.0`, non-configurable). Only a global database is supported. Per user spam +detection is not supported by this hook. + +== AntiSpam Mailets + +James' repository provide two AntiSpam mailets: SpamAssassin and RspamdScanner. +We can select one in them for filtering spam mail. + +* *SpamAssassin and RspamdScanner* Mailet is designed to classify the messages as spam or not +with a configurable score threshold. Usually a message will only be +considered as spam if it matches multiple criteria; matching just a single test +will not usually be enough to reach the threshold. Note that this mailet is executed on a per-user basis. + +=== Rspamd + +The Rspamd extension (optional) requires an extra configuration file `rspamd.properties` to configure RSpamd connection + +.rspamd.properties content +|=== +| Property name | explanation + +| rSpamdUrl +| URL defining the Rspamd's server. Eg: http://rspamd:11334 + +| rSpamdPassword +| Password for pass authentication when request to Rspamd's server. Eg: admin + +| rspamdTimeout +| Integer. Timeout for http requests to Rspamd. Default to 15 seconds. + +| perUserBayes +| Boolean. Whether to scan/learn mails using per-user Bayes. Default to false. +|=== + +`RspamdScanner` supports the following options: + +* You can specify the `virusProcessor` if you want to enable virus scanning for mail. Upon configurable `virusProcessor` +you can specify how James process mail virus. We provide a sample Rspamd mailet and `virusProcessor` configuration: + +* You can specify the `rejectSpamProcessor`. Emails marked as `rejected` by Rspamd will be redirected to this +processor. This corresponds to emails with the highest spam score, thus delivering them to users as marked as spam +might not even be desirable. + +* The `rewriteSubject` option allows to rewritte subjects when asked by Rspamd. + +This mailet can scan mails against per-user Bayes by configure `perUserBayes` in `rspamd.properties`. This is achieved +through the use of Rspamd `Deliver-To` HTTP header. If true, Rspamd will be called for each recipient of the mail, which comes at a performance cost. If true, subjects are not rewritten. +If true `virusProcessor` and `rejectSpamProcessor` are honnered per user, at the cost of email copies. Default to false. + +Here is an example of mailet pipeline conducting out RspamdScanner execution: + +[subs=attributes+,xml] +---- + + + true + virus + spam + + + Spam + + + + + + + + file://var/mail/virus/ + + + + + + all + .* + + + [VIRUS] + + + + + + + {mailet-repository-path-prefix}://var/mail/spam + + +---- + +==== Feedback for Rspamd +If enabled, the `RspamdListener` will base on the Mailbox event to detect the message is a spam or not, then James will send report `spam` or `ham` to Rspamd. +This listener can report mails to per-user Bayes by configure `perUserBayes` in `rspamd.properties`. +The Rspamd listener needs to explicitly be registered with xref:{pages-path}/configure/listeners.adoc[listeners.xml]. + +Example: + +[source,xml] +.... + + + org.apache.james.rspamd.RspamdListener + + +.... + +For more detail about how to use Rspamd's extension: `third-party/rspamd/index.md` + +Alternatively, batch reports can be triggered on user mailbox content via webAdmin. link:https://github.com/apache/james-project/tree/master/third-party/rspamd#additional-webadmin-endpoints[Read more]. + + +=== SpamAssassin +Here is an example of mailet pipeline conducting out SpamAssassin execution: + +[source,xml] +.... + + ignore + spamassassin + 783 + + + + org.apache.james.spamassassin.status; X-JAMES-SPAMASSASSIN-STATUS + org.apache.james.spamassassin.flag; X-JAMES-SPAMASSASSIN-FLAG + + + Spam + +.... + +* *BayesianAnalysis* (unsupported) in the Mailet uses Bayesian probability to classify mail as +spam or not spam. It relies on the training data coming from the users’ judgment. +Users need to manually judge as spam and send to spam@thisdomain.com, oppositely, +if not spam they then send to not.spam@thisdomain.com. BayesianAnalysisfeeder learns +from this training dataset, and build predictive models based on Bayesian probability. +There will be a certain table for maintaining the frequency of Corpus for keywords +in the database. Every 10 mins a thread in the BayesianAnalysis will check and update +the table. Also, the correct approach is to send the original spam or non-spam +as an attachment to another message sent to the feeder in order to avoid bias from the +current sender's email header. + +==== Feedback for SpamAssassin + +If enabled, the `SpamAssassinListener` will asynchronously report users mails moved to the `Spam` mailbox as Spam, +and other mails as `Ham`, effectively populating the user database for per user spam detection. This enables a per-user +Spam categorization to be conducted out by the SpamAssassin mailet, the SpamAssassin hook being unaffected. + +The SpamAssassin listener requires an extra configuration file `spamassassin.properties` to configure SpamAssassin connection (optional): + +.spamassassin.properties content +|=== +| Property name | explanation + +| spamassassin.host +| Hostname of the SpamAssassin server. Defaults to 127.0.0.1. + +| spamassassin.port +| Port of the SpamAssassin server. Defaults to 783. +|=== + +Note that this configuration file only affects the listener, and not the hook or mailet. + +The SpamAssassin listener needs to explicitly be registered with xref:{pages-path}/configure/listeners.adoc[listeners.xml]. + +Example: + +[source,xml] +.... + + + org.apache.james.mailbox.spamassassin.SpamAssassinListener + true + + +.... diff --git a/docs/modules/servers/partials/configure/ssl.adoc b/docs/modules/servers/partials/configure/ssl.adoc new file mode 100644 index 00000000000..df740c26bb4 --- /dev/null +++ b/docs/modules/servers/partials/configure/ssl.adoc @@ -0,0 +1,253 @@ +This document explains how to enable James 3.0 servers to use Transport Layer Security (TLS) +for encrypted client-server communication. + +== Configure a Server to Use SSL/TLS + +Each of the servers xref:{pages-path}/configure/smtp.adoc[SMTP - LMTP], +xref:{pages-path}/configure/pop3.adoc[POP3] and xref:{pages-path}/configure/imap.adoc[IMAP] +supports use of SSL/TLS. + +TLS (Transport Layer Security) and SSL (Secure Sockets Layer) are protocols that provide +data encryption and authentication between applications in scenarios where that data is +being sent across an insecure network, such as checking your email +(How does the Secure Socket Layer work?). The terms SSL and TLS are often used +interchangeably or in conjunction with each other (TLS/SSL), +but one is in fact the predecessor of the other — SSL 3.0 served as the basis +for TLS 1.0 which, as a result, is sometimes referred to as SSL 3.1. + +You need to add a block in the corresponding configuration file (smtpserver.xml, pop3server.xml, imapserver.xml,..) + +[source,xml] +.... + + file://conf/keystore + PKCS12 + yoursecret + org.bouncycastle.jce.provider.BouncyCastleProvider + +.... + +Alternatively TLS keys can be supplied via PEM files: + +[source,xml] +.... + + file://conf/private.key + file://conf/certs.self-signed.csr + +.... + +An optional secret might be specified for the private key: + +[source,xml] +.... + + file://conf/private.key + file://conf/certs.self-signed.csr + yoursecret + +.... + +Optionally, TLS protocols and/or cipher suites can be specified explicitly (smtpserver.xml, pop3server.xml, imapserver.xml,..). +Otherwise, the default protocols and cipher suites of the used JDK will be used: + +[source,xml] +.... + + + TLSv1.2 + TLSv1.1 + TLSv1 + SSLv3 + + + TLS_AES_256_GCM_SHA384 + TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 + + +.... + +Each of these block has an optional boolean configuration element socketTLS and startTLS which is used to toggle +use of SSL or TLS for the service. + +With socketTLS (SSL/TLS in Thunderbird), all the communication is encrypted. + +With startTLS (STARTTLS in Thunderbird), the preamble is readable, but the rest is encrypted. + +.... +* OK JAMES IMAP4rev1 Server Server 192.168.1.4 is ready. +* CAPABILITY IMAP4rev1 LITERAL+ CHILDREN WITHIN STARTTLS IDLE NAMESPACE UIDPLUS UNSELECT AUTH=PLAIN +1 OK CAPABILITY completed. +2 OK STARTTLS Begin TLS negotiation now. +... rest is encrypted... +.... + +You can only enable one of the both at the same time for a service. + +It is also recommended to change the port number on which the service will listen: + +* POP3 - port 110, Secure POP3 - port 995 +* IMAP - port 143, Secure IMAP4 - port 993 +* SMTP - port 25, Secure SMTP - port 465 + +You will now need to create your certificate store and place it in the james/conf/ folder with the name you defined in the keystore tag. + +Please note `JKS` keystore format is also supported (default value if no keystore type is specified): + +[source,xml] +.... + + file://conf/keystore + JKS + yoursecret + org.bouncycastle.jce.provider.BouncyCastleProvider + +.... + + +=== Client authentication via certificates + +When you enable TLS, you may also configure the server to require a client certificate for authentication: + +[source,xml] +.... + + file://conf/keystore + JKS + yoursecret + + + file://conf/truststore + JKS + yoursecret + false + + +.... + +James verifies client certificates against the provided truststore. You can fill it with trusted peer certificates directly, or an issuer certificate (CA) if you trust all certificates created by it. If you omit the truststore configuration, James will use the Java default truststore instead, effectively trusting any known CA. + +James can optionally enable OCSP verifications for client certificates against Certificate Revocation List referenced +in the certificate itself. + +== Creating your own PEM keys + +The following commands can be used to create self signed PEM keys: + +[source,xml] +.... +# Generating your private key +openssl genrsa -des3 -out private.key 2048 + +# Creating your certificates +openssl req -new -key private.key -out certs.csr + +# Signing the certificate yourself +openssl x509 -req -days 365 -in certs.csr -signkey private.key -out certs.self-signed.csr + +# Removing the password from the private key +# Not necessary if you supply the secret in the configuration +openssl rsa -in private.key -out private.nopass.key +.... + +You may then supply this TLS configuration: + +[source,xml] +.... + + file://conf/private.nopass.key + file://conf/certs.self-signed.csr + +.... + +== Certificate Keystores + +This section gives more indication for users relying on keystores. + +=== Creating your own Certificate Keystore + +(Adapted from the Tomcat 4.1 documentation) + +James currently operates only on JKS or PKCS12 format keystores. This is Java's standard "Java KeyStore" format, and is +the format created by the keytool command-line utility. This tool is included in the JDK. + +To import an existing certificate into a JKS keystore, please read the documentation (in your JDK documentation package) +about keytool. + +To create a new keystore from scratch, containing a single self-signed Certificate, execute the following from a terminal +command line: + +.... +keytool -genkey -alias james -keyalg RSA -storetype PKCS12 -keystore your_keystore_filename +.... + +(The RSA algorithm should be preferred as a secure algorithm, and this also ensures general compatibility with other +servers and components.) + +As a suggested standard, create the keystore in the james/conf directory, with a name like james.keystore. + +After executing this command, you will first be prompted for the keystore password. + +Next, you will be prompted for general information about this Certificate, such as company, contact name, and so on. +This information may be displayed to users when importing into the certificate store of the client, so make sure that +the information provided here matches what they will expect. + +Important: in the "distinguished name", set the "common name" (CN) to the DNS name of your James server, the one +you will use to access it from your mail client (like "mail.xyz.com"). + +Finally, you will be prompted for the key password, which is the password specifically for this Certificate +(as opposed to any other Certificates stored in the same keystore file). + +If everything was successful, you now have a keystore file with a Certificate that can be used by your server. + +You MUST have only one certificate in the keystore file used by James. + +=== Installing a Certificate provided by a Certificate Authority + +(Adapted from the Tomcat 4.1 documentation + +To obtain and install a Certificate from a Certificate Authority (like verisign.com, thawte.com or trustcenter.de) +you should have read the previous section and then follow these instructions: + +==== Create a local Certificate Signing Request (CSR) + +In order to obtain a Certificate from the Certificate Authority of your choice you have to create a so called +Certificate Signing Request (CSR). That CSR will be used by the Certificate Authority to create a Certificate +that will identify your James server as "secure". To create a CSR follow these steps: + +* Create a local Certificate as described in the previous section. + +The CSR is then created with: + +.... + keytool -certreq -keyalg RSA -alias james -file certreq.csr -keystore your_keystore_filename +.... + +Now you have a file called certreq.csr. The file is encoded in PEM format. You can submit it to the Certificate Authority +(look at the documentation of the Certificate Authority website on how to do this). In return you get a Certificate. + +Now that you have your Certificate you can import it into you local keystore. First of all you may have to import a so +called Chain Certificate or Root Certificate into your keystore (the major Certificate Authorities are already in place, +so it's unlikely that you will need to perform this step). After that you can procede with importing your Certificate. + +==== Optionally Importing a so called Chain Certificate or Root Certificate + +Download a Chain Certificate from the Certificate Authority you obtained the Certificate from. + +* For Verisign.com go to: http://www.verisign.com/support/install/intermediate.html +* For Trustcenter.de go to: http://www.trustcenter.de/certservices/cacerts/en/en.htm#server +* For Thawte.com go to: http://www.thawte.com/certs/trustmap.html (seems no longer valid) + +==== Import the Chain Certificate into you keystore + +.... +keytool -import -alias root -keystore your_keystore_filename -trustcacerts -file filename_of_the_chain_certificate +.... + +And finally import your new Certificate (It must be in X509 format): + +.... +keytool -import -alias james -keystore your_keystore_filename -trustcacerts -file your_certificate_filename +.... + +See also http://www.agentbob.info/agentbob/79.html[this page] \ No newline at end of file diff --git a/docs/modules/servers/partials/configure/systemPropertiesPartial.adoc b/docs/modules/servers/partials/configure/systemPropertiesPartial.adoc new file mode 100644 index 00000000000..40648e9b9df --- /dev/null +++ b/docs/modules/servers/partials/configure/systemPropertiesPartial.adoc @@ -0,0 +1,23 @@ +== System properties + +Some tuning can be done via system properties. This includes: + +.System properties +|=== +| Property name | explanation + +| james.message.memory.threshold +| (Optional). String (size, integer + size units, example: `12 KIB`, supported units are bytes KIB MIB GIB TIB). Defaults to 100KIB. +This governs the threshold MimeMessageInputStreamSource relies on for storing MimeMessage content on disk. +Below, data is stored in memory. Above data is stored on disk. +Lower values will lead to longer processing time but will minimize heap memory usage. Modern SSD hardware +should however support a high throughput. Higher values will lead to faster single mail processing at the cost +of higher heap usage. + + +| james.message.usememorycopy +|Optional. Boolean. Defaults to false. Recommended value is false. +Should MimeMessageWrapper use a copy of the message in memory? Or should bigger message exceeding james.message.memory.threshold +be copied to temporary files? + +|=== \ No newline at end of file diff --git a/docs/modules/servers/partials/configure/tika.adoc b/docs/modules/servers/partials/configure/tika.adoc new file mode 100644 index 00000000000..4e2ae166620 --- /dev/null +++ b/docs/modules/servers/partials/configure/tika.adoc @@ -0,0 +1,48 @@ +When using OpenSearch, you can configure an external Tika server for extracting and indexing text from attachments. +Thus you can significantly improve user experience upon text searches. + +Note: You can launch a tika server using this command line: + +.... +docker run --name tika linagora/docker-tikaserver:1.24 +.... + +Here are the different properties: + +.tika.properties content +|=== +| Property name | explanation + +| tika.enabled +| Should Tika text extractor be used? +If true, the TikaTextExtractor will be used behind a cache. +If false, the DefaultTextExtractor will be used (naive implementation only supporting text). +Defaults to false. + +| tika.host +| IP or domain name of your Tika server. The default value is 127.0.0.1 + +| tika.port +| Port of your tika server. The default value is 9998 + +| tika.timeoutInMillis +| Timeout when issuing request to the tika server. The default value is 3 seconds. + +| tika.cache.eviction.period +| A cache is used to avoid, when possible, query Tika multiple time for the same attachments. +This entry determines how long after the last read an entry vanishes. +Please note that units are supported (ms - millisecond, s - second, m - minute, h - hour, d - day). Default unit is seconds. +Default value is *1 day* + +| tika.cache.enabled +| Should the cache be used? False by default + +| tika.cache.weight.max +| Maximum weight of the cache. +A value of *0* disables the cache +Please note that units are supported (K for KB, M for MB, G for GB). Defaults is no units, so in bytes. +Default value is *100 MB*. + +| tika.contentType.blacklist +| Blacklist of content type is known-to-be-failing with Tika. Specify the list with comma separator. +|=== diff --git a/docs/modules/servers/partials/configure/usersrepository.adoc b/docs/modules/servers/partials/configure/usersrepository.adoc new file mode 100644 index 00000000000..e8d40e67a6a --- /dev/null +++ b/docs/modules/servers/partials/configure/usersrepository.adoc @@ -0,0 +1,126 @@ +User repositories are required to store James user information and authentication data. + +Consult this link:{sample-configuration-prefix-url}/usersrepository.xml[example] +to get some examples and hints. + +== The user data model + +A user has two attributes: username and password. + +A valid user should satisfy these criteria: + +* username and password cannot be null or empty +* username should not be longer than 255 characters +* username can not contain '/' +* username can not contain multiple domain delimiter('@') +* A username can have only a local part when virtualHosting is disabled. E.g.'myUser' +* When virtualHosting is enabled, a username should have a domain part, and the domain part should be concatenated +after a domain delimiter('@'). E.g. 'myuser@james.org' + +A user is always considered as lower cased, so 'myUser' and 'myuser' are the same user, and can be used as well as +recipient local part than as login for different protocols. + +== Configuration + +.usersrepository.xml content +|=== +| Property name | explanation + +| enableVirtualHosting +| true or false. Add domain support for users (default: false, except for Cassandra Users Repository) + +| administratorId +|user's name. Allow a user to access to the https://tools.ietf.org/html/rfc4616#section-2[impersonation command], +acting on the behalf of any user. + +| verifyFailureDelay +| Delay after a failed authentication attempt with an invalid user name or password. Duration string defaulting to seconds, e.g. `2`, `2s`, `2000ms`. Default `0s` (disabled). + +| algorithm +| use a specific hash algorithm to compute passwords, with optional mode `plain` (default) or `salted`; e.g. `SHA-512`, `SHA-512/plain`, `SHA-512/salted`, `PBKDF2`, `PBKDF2-SHA512` (default). +Note: When using `PBKDF2` or `PBKDF2-SHA512` one can specify the iteration count and the key size in bytes. You can specify it as part of the algorithm. EG: `PBKDF2-SHA512-2000-512` will use +2000 iterations with a key size of 512 bytes. + +| hashingMode +| specify the hashing mode to use if there is none recorded in the database: `plain` (default) for newer installations or `legacy` for older ones + +|=== + +== Configuring a LDAP + +Alternatively you can authenticate your users against a LDAP server. You need to configure +the properties for accessing your LDAP server in this file. + +Consult this link:{sample-configuration-prefix-url}/usersrepository.xml[example] +to get some examples and hints. + +Example: + +[source,xml] +.... + + true + +.... + +SSL can be enabled by using `ldaps` scheme. `trustAllCerts` option can be used to trust all LDAP client certificates +(optional, defaults to false). + +Example: + +[source,xml] +.... + + true + +.... + +Moreover, per domain base DN can be configured: + +[source,xml] +.... +true + + ou=People,o=other.com,ou=system + + +.... + +You can connect to multiple LDAP servers for better availability by using `ldapHosts` option (fallback to `ldapHost` is supported) to specify the list of LDAP Server URL with the comma `,` delimiter. We do support different schemas for LDAP servers. + +Example: + +[source,xml] +.... + + true + +.... + +When VirtualHosting is on, you can enable local part as login username by configure the `resolveLocalPartAttribute`. +This is the LDAP attribute that allows to retrieve the local part of users. Optional, default to empty, which disables login with local part as username. + +Example: + +[source,xml] +.... + + true + +.... + +The "userListBase" configuration option is used to differentiate users that can login from those that are listed + as regular users. This is useful for dis-activating users, for instance. + +A different values from "userBase" can be used for setting up virtual logins, +for instance in conjunction with "resolveLocalPartAttribute". This can also be used to manage +disactivated users (in "userListBase" but not in "userBase"). + +Note that "userListBase" can not be specified on a per-domain-basis. diff --git a/docs/modules/servers/partials/configure/vault.adoc b/docs/modules/servers/partials/configure/vault.adoc new file mode 100644 index 00000000000..b631222e748 --- /dev/null +++ b/docs/modules/servers/partials/configure/vault.adoc @@ -0,0 +1,29 @@ +Deleted Messages Vault is the component in charge of retaining messages before they are going to be deleted. +Messages stored in the Deleted Messages Vault could be deleted after exceeding their retentionPeriod (explained below). +It also supports to restore or export messages matching with defined criteria in +xref:{pages-path}/operate/webadmin.adoc#_deleted_messages_vault[WebAdmin deleted messages vault document] by using +xref:{pages-path}/operate/webadmin.adoc#_deleted_messages_vault[WebAdmin endpoints]. + +== Deleted Messages Vault Configuration + +Once the vault is active, James will start moving deleted messages to it asynchronously. + +The Deleted Messages Vault also stores and manages deleted messages into a BlobStore. The BlobStore can be either +based on an object storage or on {backend-name}. For configuring the BlobStore the vault will use, you can look at +xref:{pages-path}/configure/blobstore.adoc[*blobstore.properties*] BlobStore Configuration section. + +== deletedMessageVault.properties + +Consult this link:{sample-configuration-prefix-url}/deletedMessageVault.properties[example] +to get some examples and hints. + +.deletedMessageVault.properties content +|=== +| Property name | explanation + +| retentionPeriod +| Deleted messages stored in the Deleted Messages Vault are expired after this period (default: 1 year). It can be expressed in *y* years, *d* days, *h* hours, ... + +| restoreLocation +| Messages restored from the Deleted Messages Vault are placed in a mailbox with this name (default: ``Restored-Messages``). The mailbox will be created if it does not exist yet. +|=== diff --git a/docs/modules/servers/partials/configure/webadmin.adoc b/docs/modules/servers/partials/configure/webadmin.adoc new file mode 100644 index 00000000000..61da6ed21fa --- /dev/null +++ b/docs/modules/servers/partials/configure/webadmin.adoc @@ -0,0 +1,104 @@ +The web administration supports for now the CRUD operations on: + +- The domains +- The users +- Their mailboxes +- Their quotas +- Managing mail repositories +- Performing cassandra migrations [small]*_(only for Distributed James Server that uses cassandra as backend)_* +- And much more, as described in the following sections. + +*WARNING*: This API allows authentication only via the use of JWT. If not +configured with JWT, an administrator should ensure an attacker can not +use this API. + +By the way, some endpoints are not filtered by authentication. Those endpoints are not related to data stored in James, +for example: Swagger documentation & James health checks. + +== Configuration + +Consult this link:{sample-configuration-prefix-url}/webadmin.properties[example] +to get some examples and hints. + +.webadmin.properties content +|=== +| Property name | explanation + +| enabled +| Define if WebAdmin is launched (default: false) + +| port +| Define WebAdmin's port (default: 8080) + +| host +| Define WebAdmin's host (default: localhost, use 0.0.0.0 to listen on all addresses) + +| cors.enable +| Allow the Cross-origin resource sharing (default: false) + +| cors.origin +| Specify ths CORS origin (default: null) + +| jwt.enable +| Allow JSON Web Token as an authentication mechanism (default: false) + +| https.enable +| Use https (default: false) + +| https.keystore +| Specify a keystore file for https (default: null) + +| https.password +| Specify the keystore password (default: null) + +| https.trust.keystore +| Specify a truststore file for https (default: null) + +| https.trust.password +| Specify the truststore password (default: null) + +| jwt.publickeypem.url +| Optional. JWT tokens allow request to bypass authentication. Path to the JWT public key. +Defaults to the `jwt.publickeypem.url` value of `jmap.properties` file if unspecified +(legacy behaviour) + +| extensions.routes +| List of Routes specified as fully qualified class name that should be loaded in addition to your product routes list. Routes +needs to be on the classpath or in the ./extensions-jars folder. Read mode about +xref:{pages-path}/extending/webadmin-routes.adoc[creating you own webadmin routes]. + +| maxThreadCount +| Maximum threads used by the underlying Jetty server. Optional. + +| minThreadCount +| Minimum threads used by the underlying Jetty server. Optional. + +|=== + +== Generating a JWT key pair + +The {server-name} enforces the use of RSA-SHA-256. + +One can use OpenSSL to generate a JWT key pair : + + # private key + openssl genrsa -out rs256-4096-private.rsa 4096 + # public key + openssl rsa -in rs256-4096-private.rsa -pubout > rs256-4096-public.pem + +The private key can be used to generate JWT tokens, for instance +using link:https://github.com/vandium-io/jwtgen[jwtgen]: + + jwtgen -a RS256 -p rs256-4096-private.rsa 4096 -c "sub=bob@domain.tld" -c "admin=true" -e 3600 -V + +This token can then be passed as `Bearer` of the `Authorization` header : + + curl -H "Authorization: Bearer $token" -XGET http://127.0.0.1:8000/domains + +The public key can be referenced as `jwt.publickeypem.url` of the `jmap.properties` configuration file. + +== Reverse-proxy set up + +WebAdmin adds the value of `X-Real-IP` header as part of the logging MDC. + +This allows for reverse proxies to cary other the IP address of the client down to the JMAP server for diagnostic purpose. \ No newline at end of file From 75fc4d5229fd8be6cd584f1720bf9d16ffffb355 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 3 Jul 2024 12:01:33 +0700 Subject: [PATCH 002/341] [Antora] Clone Benchmarks section files to /partials --- .../partials/benchmark/db-benchmark.adoc | 455 ++++++++++++++++++ .../partials/benchmark/james-benchmark.adoc | 100 ++++ 2 files changed, 555 insertions(+) create mode 100644 docs/modules/servers/partials/benchmark/db-benchmark.adoc create mode 100644 docs/modules/servers/partials/benchmark/james-benchmark.adoc diff --git a/docs/modules/servers/partials/benchmark/db-benchmark.adoc b/docs/modules/servers/partials/benchmark/db-benchmark.adoc new file mode 100644 index 00000000000..a3e42d7b340 --- /dev/null +++ b/docs/modules/servers/partials/benchmark/db-benchmark.adoc @@ -0,0 +1,455 @@ += Distributed James Server -- Database benchmarks +:navtitle: Database benchmarks + +This document provides basic performance of Distributed James' databases, benchmark methodologies as a basis for a James administrator who +can test and evaluate if his Distributed James databases are performing well. + +It includes: + +* A sample deployment topology +* Propose benchmark methodology and base performance for each database. This aims to help operators to quickly identify +performance issues and compliance of their databases. + +== Sample deployment topology + +We deploy a sample topology of Distributed James with these following databases: + +- Apache Cassandra 4 as main database: 3 nodes, each node has 8 OVH vCores CPU and 30 GB memory limit (OVH b2-30 instance). +- OpenDistro 1.13.1 as search engine: 3 nodes, each node has 8 OVH vCores CPU and 30 GB memory limit (OVH b2-30 instance). +- RabbitMQ 3.8.17 as message queue: 3 Kubernetes pods, each pod has 0.6 OVH vCore CPU and 2 GB memory limit. +- OVH Swift S3 as an object storage + +With the above system, our email service operates stably with valuable performance. +For a more details, it can handle a load throughput up to about 1000 JMAP requests per second with 99th percentile latency is 400ms. + +== Benchmark methodologies and base performances +We are willing to share the benchmark methodologies and the result to you as a reference to evaluate your Distributed James' performance. +Other evaluation methods are welcome, as long as your databases exhibit similar or even better performance than ours. +It is up to your business needs. If your databases shows results that fall far from our baseline performance, there's a good chance that +there are problems with your system, and you need to check it out thoroughly. + +=== Benchmark Cassandra + +==== Benchmark methodology +===== Benchmark tool + +We use https://cassandra.apache.org/doc/latest/cassandra/tools/cassandra_stress.html[cassandra-stress tool] - an official +tool of Cassandra for stress loading tests. + +The cassandra-stress tool is a Java-based stress testing utility for basic benchmarking and load testing a Cassandra cluster. +Data modeling choices can greatly affect application performance. Significant load testing over several trials is the best method for discovering issues with a particular data model. The cassandra-stress tool is an effective tool for populating a cluster and stress testing CQL tables and queries. Use cassandra-stress to: + +- Quickly determine how a schema performs. +- Understand how your database scales. +- Optimize your data model and settings. +- Determine production capacity. + +There are several operation types: + +- write-only, read-only, and mixed workloads of standard data +- write-only and read-only workloads for counter columns +- user configured workloads, running custom queries on custom schemas + +===== How to benchmark + +Here we are using a simple case to test and compare Cassandra performance between different setup environments. + +[source,yaml] +---- +keyspace: stresscql + +keyspace_definition: | + CREATE KEYSPACE stresscql WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 3}; + +table: mixed_workload + +table_definition: | + CREATE TABLE mixed_workload ( + key uuid PRIMARY KEY, + a blob, + b blob + ) WITH COMPACT STORAGE + +columnspec: + - name: a + size: uniform(1..10000) + - name: b + size: uniform(1..100000) + +insert: + partitions: fixed(1) + +queries: + read: + cql: select * from mixed_workload where key = ? + fields: samerow +---- + +Create the yaml file as above and copy to a Cassandra node. + +Insert some sample data: + +[source,bash] +---- +cassandra-stress user profile=mixed_workload.yml n=100000 "ops(insert=1)" cl=ONE -mode native cql3 user= password= -node -rate threads=8 -graph file=./graph_insert.xml title=Benchmark revision=insert_ONE +---- + +Read intensive scenario: + +[source,bash] +---- +cassandra-stress user profile=mixed_workload.yml n=100000 "ops(insert=1,read=4)" cl=ONE -mode native cql3 user= password= -node -rate threads=8 -graph file=./graph_mixed.xml title=Benchmark revision=mixed_ONE +---- + +In there: + +- n=100000: The number of insert batches, not number of individual insert operations. +- rate threads=8: The number of concurrent threads. If not specified it will start with 4 threads and increase until server reaches a limit. +- ops(insert=1,read=4): This will execute insert and read queries in the ratio 1:4. +- graph: Export results to graph in html format. + +==== Sample benchmark result +image::cassandra_stress_test_result_1.png[] + +image::cassandra_stress_test_result_2.png[] + +==== References +https://www.datastax.com/blog/improved-cassandra-21-stress-tool-benchmark-any-schema-part-1[Datastax - Cassandra stress tool] + +https://www.instaclustr.com/deep-diving-cassandra-stress-part-3-using-yaml-profiles/[Deep Diving cassandra-stress – Part 3 (Using YAML Profiles)] + +=== Benchmark OpenSearch + +==== Benchmark methodology + +===== Benchmark tool +We use https://github.com/opensearch-project/opensearch-benchmark[opensearch-benchmark] - an official OpenSearch benchmarking tool. +It provides the following features: + +- Automatically create OpenSearch clusters, stress tests them, and delete them. +- Manage stress testing data and solutions by OpenSearch version. +- Present stress testing data in a comprehensive way, allowing you to compare and analyze the data of different stress tests and store the data on a particular OpenSearch instance for secondary analysis. +- Collect Java Virtual Machine (JVM) details, such as memory and garbage collection (GC) data, to locate performance problems. + +===== How to benchmark +To install the `opensearch-benchmark` tool, you need Python 3.8+ including pip3 first, then run: +``` +python3 -m pip install opensearch-benchmark +``` + +If you have any trouble or need more detailed instructions, please look in the https://github.com/opensearch-project/OpenSearch-Benchmark/blob/main/DEVELOPER_GUIDE.md[detailed installation guide]. + +Let's see which workloads (simulation profiles) that `opensearch-benchmark` provides: ```opensearch-benchmark list worloads```. +For our James use case, we are interested in ```pmc``` workload: ```Full-text benchmark with academic papers from PMC```. + +Run the below script to benchmark against your OpenSearch cluster: + +[source,bash] +---- +opensearch-benchmark execute_test --pipeline=benchmark-only --workload=[workload-name] --target-host=[ip_node1:port_node1],[ip_node2:port_node2],[ip_node3:port_node3] --client-options="use_ssl:false,verify_certs:false,basic_auth_user:'[user]',basic_auth_password:'[password]'" +---- + +In there: + +* --pipeline=benchmark-only: benchmark against a running cluster +* workload-name: the workload you want to benchmark +* ip:port: OpenSearch Node' socket +* user/password: OpenSearch authentication credentials + +==== Sample benchmark result +===== PMC worload + +[source] +---- +| Metric | Task | Value | Unit | +|---------------------------------------------------------------:|------------------------------:|------------:|--------:| +| Min Throughput | index-append | 734.63 | docs/s | +| Mean Throughput | index-append | 763.16 | docs/s | +| Median Throughput | index-append | 746.5 | docs/s | +| Max Throughput | index-append | 833.51 | docs/s | +| 50th percentile latency | index-append | 4738.57 | ms | +| 90th percentile latency | index-append | 8129.1 | ms | +| 99th percentile latency | index-append | 11734.5 | ms | +| 100th percentile latency | index-append | 14662.9 | ms | +| 50th percentile service time | index-append | 4738.57 | ms | +| 90th percentile service time | index-append | 8129.1 | ms | +| 99th percentile service time | index-append | 11734.5 | ms | +| 100th percentile service time | index-append | 14662.9 | ms | +| error rate | index-append | 0 | % | +| Min Throughput | default | 19.94 | ops/s | +| Mean Throughput | default | 19.95 | ops/s | +| Median Throughput | default | 19.95 | ops/s | +| Max Throughput | default | 19.96 | ops/s | +| 50th percentile latency | default | 23.1322 | ms | +| 90th percentile latency | default | 25.4129 | ms | +| 99th percentile latency | default | 29.1382 | ms | +| 100th percentile latency | default | 29.4762 | ms | +| 50th percentile service time | default | 21.4895 | ms | +| 90th percentile service time | default | 23.589 | ms | +| 99th percentile service time | default | 26.6134 | ms | +| 100th percentile service time | default | 27.9068 | ms | +| error rate | default | 0 | % | +| Min Throughput | term | 19.93 | ops/s | +| Mean Throughput | term | 19.94 | ops/s | +| Median Throughput | term | 19.94 | ops/s | +| Max Throughput | term | 19.95 | ops/s | +| 50th percentile latency | term | 31.0684 | ms | +| 90th percentile latency | term | 34.1419 | ms | +| 99th percentile latency | term | 74.7904 | ms | +| 100th percentile latency | term | 103.663 | ms | +| 50th percentile service time | term | 29.6775 | ms | +| 90th percentile service time | term | 32.4288 | ms | +| 99th percentile service time | term | 36.013 | ms | +| 100th percentile service time | term | 102.193 | ms | +| error rate | term | 0 | % | +| Min Throughput | phrase | 19.94 | ops/s | +| Mean Throughput | phrase | 19.95 | ops/s | +| Median Throughput | phrase | 19.95 | ops/s | +| Max Throughput | phrase | 19.95 | ops/s | +| 50th percentile latency | phrase | 23.0255 | ms | +| 90th percentile latency | phrase | 26.1607 | ms | +| 99th percentile latency | phrase | 31.2094 | ms | +| 100th percentile latency | phrase | 45.5012 | ms | +| 50th percentile service time | phrase | 21.5109 | ms | +| 90th percentile service time | phrase | 24.4144 | ms | +| 99th percentile service time | phrase | 26.1865 | ms | +| 100th percentile service time | phrase | 43.5122 | ms | +| error rate | phrase | 0 | % | + +---------------------------------- +[INFO] SUCCESS (took 1772 seconds) +---------------------------------- +---- + +===== PMC custom workload +We customized the PMC workload by increasing search throughput target to figure out our OpenSearch cluster limit. + +The result is that with 25-30 request/s we have a 99th percentile latency of 1s. + +==== References +The `opensearch-benchmark` tool seems to be a fork of the official benchmark tool https://github.com/elastic/rally[EsRally] of Elasticsearch. +The `opensearch-benchmark` tool is not adopted widely yet, so we believe some EsRally references could help as well: + +- https://www.alibabacloud.com/blog/esrally-official-stress-testing-tool-for-elasticsearch_597102[esrally: Official Stress Testing Tool for Elasticsearch] + +- https://esrally.readthedocs.io/en/latest/adding_tracks.html[Create a custom EsRally track] + +- https://discuss.elastic.co/t/why-the-percentile-latency-is-several-times-more-than-service-time/69630[Why the percentile latency is several times more than service time] + +=== Benchmark RabbitMQ + +==== Benchmark methodology + +===== Benchmark tool +We use https://github.com/rabbitmq/rabbitmq-perf-test[rabbitmq-perf-test] tool. + +===== How to benchmark +Using PerfTestMulti for more friendly: + +- Provide input scenario from a single file +- Provide output result as a single file. Can be visualized result file by the chart (graph WebUI) + +Run a command like below: + +[source,bash] +---- +bin/runjava com.rabbitmq.perf.PerfTestMulti [scenario-file] [result-file] +---- + +In order to visualize result, coping [result-file] to ```/html/examples/[result-file]```. +Start webserver to view graph by the command: + +[source,bash] +---- +bin/runjava com.rabbitmq.perf.WebServer +---- +Then browse: http://localhost:8080/examples/sample.html + +==== Sample benchmark result +- Scenario file: + +[source] +---- +[{'name': 'consume', 'type': 'simple', +'uri': 'amqp://james:eeN7Auquaeng@localhost:5677', +'params': + [{'time-limit': 30, 'producer-count': 2, 'consumer-count': 4}]}] +---- + +- Result file: + +[source,json] +---- +{ + "consume": { + "send-bytes-rate": 0, + "recv-msg-rate": 4330.225080385852, + "avg-latency": 18975254, + "send-msg-rate": 455161.3183279743, + "recv-bytes-rate": 0, + "samples": [{ + "elapsed": 15086, + "send-bytes-rate": 0, + "recv-msg-rate": 0, + "send-msg-rate": 0.06628662335940608, + "recv-bytes-rate": 0 + }, + { + "elapsed": 16086, + "send-bytes-rate": 0, + "recv-msg-rate": 1579, + "max-latency": 928296, + "min-latency": 278765, + "avg-latency": 725508, + "send-msg-rate": 388994, + "recv-bytes-rate": 0 + }, + { + "elapsed": 48184, + "send-bytes-rate": 0, + "recv-msg-rate": 3768.4918347742555, + "max-latency": 32969370, + "min-latency": 31852685, + "avg-latency": 32385432, + "send-msg-rate": 0, + "recv-bytes-rate": 0 + }, + { + "elapsed": 49186, + "send-bytes-rate": 0, + "recv-msg-rate": 4416.167664670658, + "max-latency": 33953465, + "min-latency": 32854771, + "avg-latency": 33373113, + "send-msg-rate": 0, + "recv-bytes-rate": 0 + }] + } +} +---- + +- Key result points: + +|=== +|Metrics |Unit |Result + +|Publisher throughput (the sending rate) +|messages / second +|3111 + +|Consumer throughput (the receiving rate) +|messages / second +|4404 +|=== + +=== Benchmark S3 storage + +==== Benchmark methodology + +===== Benchmark tool +We use https://github.com/dvassallo/s3-benchmark[s3-benchmark] tool. + +===== How to benchmark +1. Make sure you set up appropriate S3 credentials with `awscli`. +2. If you are using a compatible S3 storage of cloud providers like OVH, you would need to configure +`awscli-plugin-endpoint`. E.g: https://docs.ovh.com/au/en/storage/getting_started_with_the_swift_S3_API/[Getting started with the OVH Swift S3 API] +3. Install `s3-benchmark` tool and run the command: + +[source,bash] +---- +./s3-benchmark -endpoint=[endpoint] -region=[region] -bucket-name=[bucket-name] -payloads-min=[payload-min] -payloads-max=[payload-max] threads-max=[threads-max] +---- + +==== Sample benchmark result +We did S3 performance testing with suitable email objects sizes: 4 KB, 128 KB, 1 MB, 8 MB. + +Result: + +[source,bash] +---- +--- SETUP -------------------------------------------------------------------------------------------------------------------- + +Uploading 4 KB objects + 100% |████████████████████████████████████████| [4s:0s] +Uploading 128 KB objects + 100% |████████████████████████████████████████| [9s:0s] +Uploading 1 MB objects + 100% |████████████████████████████████████████| [8s:0s] +Uploading 8 MB objects + 100% |████████████████████████████████████████| [10s:0s] + +--- BENCHMARK ---------------------------------------------------------------------------------------------------------------- + +Download performance with 4 KB objects (b2-30) + +-------------------------------------------------------------------------------------------------+ + | Time to First Byte (ms) | Time to Last Byte (ms) | ++---------+----------------+------------------------------------------------+------------------------------------------------+ +| Threads | Throughput | avg min p25 p50 p75 p90 p99 max | avg min p25 p50 p75 p90 p99 max | ++---------+----------------+------------------------------------------------+------------------------------------------------+ +| 8 | 0.6 MB/s | 36 10 17 22 36 57 233 249 | 37 10 17 22 36 57 233 249 | +| 9 | 0.6 MB/s | 30 10 15 21 33 45 82 234 | 30 10 15 21 33 45 83 235 | +| 10 | 0.2 MB/s | 55 11 18 22 28 52 248 1075 | 55 11 18 22 28 52 249 1075 | +| 11 | 0.3 MB/s | 66 11 18 23 45 233 293 683 | 67 11 19 23 45 233 293 683 | +| 12 | 0.6 MB/s | 35 12 19 22 43 55 67 235 | 35 12 19 22 43 56 67 235 | +| 13 | 0.2 MB/s | 68 11 19 26 58 79 279 1037 | 68 11 19 26 58 80 279 1037 | +| 14 | 0.6 MB/s | 43 17 20 24 52 56 230 236 | 43 17 20 25 52 56 230 236 | +| 15 | 0.2 MB/s | 69 11 16 23 50 66 274 1299 | 69 11 16 24 50 66 274 1299 | +| 16 | 0.5 MB/s | 52 9 19 31 81 95 228 237 | 53 9 19 31 81 95 229 237 | ++---------+----------------+------------------------------------------------+------------------------------------------------+ + +Download performance with 128 KB objects (b2-30) + +-------------------------------------------------------------------------------------------------+ + | Time to First Byte (ms) | Time to Last Byte (ms) | ++---------+----------------+------------------------------------------------+------------------------------------------------+ +| Threads | Throughput | avg min p25 p50 p75 p90 p99 max | avg min p25 p50 p75 p90 p99 max | ++---------+----------------+------------------------------------------------+------------------------------------------------+ +| 8 | 3.3 MB/s | 71 16 22 28 39 66 232 1768 | 73 16 23 29 43 67 233 1769 | +| 9 | 3.6 MB/s | 74 9 19 23 34 58 239 1646 | 75 10 20 24 37 59 240 1647 | +| 10 | 2.9 MB/s | 97 16 21 24 48 89 656 2034 | 99 17 21 26 49 92 657 2035 | +| 11 | 3.0 MB/s | 100 10 21 26 39 64 1049 2029 | 101 11 21 27 40 65 1050 2030 | +| 12 | 3.0 MB/s | 76 12 19 24 44 56 256 2012 | 77 13 20 25 48 69 258 2013 | +| 13 | 6.1 MB/s | 73 10 13 20 43 223 505 1026 | 74 10 15 21 43 224 506 1027 | +| 14 | 5.5 MB/s | 81 11 15 23 51 240 666 1060 | 82 12 16 23 54 241 667 1060 | +| 15 | 2.7 MB/s | 80 10 19 28 43 59 234 2222 | 84 11 25 34 47 60 236 2224 | +| 16 | 18.6 MB/s | 58 10 19 26 61 224 248 266 | 61 10 22 29 65 224 249 267 | ++---------+----------------+------------------------------------------------+------------------------------------------------+ + +Download performance with 1 MB objects (b2-30) + +-------------------------------------------------------------------------------------------------+ + | Time to First Byte (ms) | Time to Last Byte (ms) | ++---------+----------------+------------------------------------------------+------------------------------------------------+ +| Threads | Throughput | avg min p25 p50 p75 p90 p99 max | avg min p25 p50 p75 p90 p99 max | ++---------+----------------+------------------------------------------------+------------------------------------------------+ +| 8 | 56.4 MB/s | 41 12 26 34 43 57 94 235 | 136 30 69 100 161 284 345 396 | +| 9 | 55.2 MB/s | 53 19 32 39 50 69 238 247 | 149 26 84 117 164 245 324 655 | +| 10 | 33.9 MB/s | 74 17 27 37 50 77 456 1060 | 177 29 97 134 205 273 484 1076 | +| 11 | 57.3 MB/s | 56 26 35 44 57 71 251 298 | 185 40 93 129 216 329 546 871 | +| 12 | 37.7 MB/s | 66 21 33 43 58 73 102 1024 | 202 24 81 125 205 427 839 1222 | +| 13 | 57.6 MB/s | 59 24 35 40 58 71 275 289 | 215 40 94 181 288 393 500 674 | +| 14 | 47.1 MB/s | 73 18 46 56 66 75 475 519 | 229 30 116 221 272 441 603 686 | +| 15 | 58.2 MB/s | 65 11 40 51 63 75 260 294 | 243 29 132 174 265 485 831 849 | +| 16 | 23.1 MB/s | 96 14 46 55 62 80 124 2022 | 278 31 124 187 249 634 827 2028 | ++---------+----------------+------------------------------------------------+------------------------------------------------+ + +Download performance with 8 MB objects (b2-30) + +-------------------------------------------------------------------------------------------------+ + | Time to First Byte (ms) | Time to Last Byte (ms) | ++---------+----------------+------------------------------------------------+------------------------------------------------+ +| Threads | Throughput | avg min p25 p50 p75 p90 p99 max | avg min p25 p50 p75 p90 p99 max | ++---------+----------------+------------------------------------------------+------------------------------------------------+ +| 8 | 58.4 MB/s | 88 35 65 79 88 96 288 307 | 1063 458 564 759 928 1151 4967 6841 | +| 9 | 50.4 MB/s | 137 32 52 69 145 286 509 1404 | 1212 160 471 581 1720 2873 3744 4871 | +| 10 | 58.2 MB/s | 77 46 54 66 77 98 275 285 | 1319 377 432 962 1264 3232 4266 6151 | +| 11 | 58.4 MB/s | 97 32 63 72 80 91 323 707 | 1429 325 593 722 1648 3020 6172 6370 | +| 12 | 58.5 MB/s | 108 26 65 81 91 261 301 519 | 1569 472 696 1101 1915 3175 4066 5110 | +| 13 | 56.1 MB/s | 115 35 69 83 93 125 329 1092 | 1712 458 801 1165 2354 3559 3865 5945 | +| 14 | 58.6 MB/s | 103 26 70 78 88 112 309 656 | 1807 789 999 1269 1998 3258 5201 6651 | +| 15 | 58.3 MB/s | 113 31 55 67 79 134 276 1490 | 1947 497 1081 1756 2730 3557 3799 3974 | +| 16 | 58.0 MB/s | 99 35 67 79 96 146 282 513 | 2091 531 882 1136 2161 6034 6686 6702 | ++---------+----------------+------------------------------------------------+------------------------------------------------+ +---- + +We believe that the actual OVH Swift S3' throughput should be at least about 100 MB/s. This was not fully achieved due to +network limitations of the client machine performing the benchmark. + + diff --git a/docs/modules/servers/partials/benchmark/james-benchmark.adoc b/docs/modules/servers/partials/benchmark/james-benchmark.adoc new file mode 100644 index 00000000000..07040bb90fa --- /dev/null +++ b/docs/modules/servers/partials/benchmark/james-benchmark.adoc @@ -0,0 +1,100 @@ += Distributed James Server benchmark +:navtitle: James benchmarks + +This document provides benchmark methodology and basic performance of Distributed James as a basis for a James administrator who +can test and evaluate if his Distributed James is performing well. + +It includes: + +* A sample Distributed James deployment topology +* Propose benchmark methodology +* Sample performance results + +This aims to help operators quickly identify performance issues. + +== Sample deployment topology + +We deploy a sample topology of Distributed James with these following components: + +- Distributed James: 3 Kubernetes pods, each pod has 2 OVH vCore CPU and 4 GB memory limit. +- Apache Cassandra 4 as main database: 3 nodes, each node has 8 OVH vCores CPU and 30 GB memory limit (OVH b2-30 instance). +- OpenDistro 1.13.1 as search engine: 3 nodes, each node has 8 OVH vCores CPU and 30 GB memory limit (OVH b2-30 instance). +- RabbitMQ 3.8.17 as message queue: 3 Kubernetes pods, each pod has 0.6 OVH vCore CPU and 2 GB memory limit. +- OVH Swift S3 as an object storage + +== Benchmark methodology and base performance + +=== Provision testing data + +Before doing the performance test, you should make sure you have a Distributed James up and running with some provisioned testing +data so that it is representative of reality. + +Please follow these steps to provision testing data: + +* Prepare James with a custom `mailetcontainer.xml` having Random storing mailet. This help us easily setting a good amount of +provisioned emails. + +Add this under transport processor +---- + +---- + +* Modify https://github.com/apache/james-project/tree/master/docs/modules/servers/pages/distributed/benchmark/provision.sh[provision.sh] +upon your need (number of users, mailboxes, emails to be provisioned). + +Currently, this script provisions 10 users, 15 mailboxes and hundreds of emails for example. Normally to make the performance test representative, you +should provision thousands of users, thousands of mailboxes and millions of emails. + +* Add the permission to execute the script: +---- +chmod +x provision.sh +---- + +* Install postfix (to get the smtp-source command): +---- +sudo apt-get install postfix +---- + +* Run the provision script: +---- +./provision.sh +---- + +After provisioning once, you should remove the Random storing mailet and move on to performance testing phase. + +=== Provide performance testing method + +We introduce the tailored https://github.com/linagora/james-gatling[James Gatling] which bases on https://gatling.io/[Gatling - Load testing framework] +for performance testing against IMAP/JMAP servers. Other testing method is welcome as long as you feel it is appropriate. + +Here are steps to do performance testing with James Gatling: + +* Setup James Gatling with `sbt` build tool + +* Configure the `Configuration.scala` to point to your Distributed James IMAP/JMAP server(s). For more configuration details, please read +https://github.com/linagora/james-gatling#readme[James Gatling Readme]. + +* Run the performance testing simulation: +---- +$ sbt +> gatling:testOnly SIMULATION_FQDN +---- + +In there: `SIMULATION_FQDN` is fully qualified class name of a performance test simulation. + +We did provide a lot of simulations in `org.apache.james.gatling.simulation` path. You can have a look and choose the suitable simulation. +`sbt gatling:testOnly org.apache.james.gatling.simulation.imap.PlatformValidationSimulation` is a good starting point. Or you can even customize your simulation also! + +Some symbolic simulations we often use: + +* IMAP: `org.apache.james.gatling.simulation.imap.PlatformValidationSimulation` +* JMAP rfc8621: `org.apache.james.gatling.simulation.jmap.rfc8621.PushPlatformValidationSimulation` + +=== Base performance result + +A sample IMAP performance testing result (PlatformValidationSimulation): + +image::james-imap-base-performance.png[] + +If you get a IMAP performance far below this base performance, you should consider investigating for performance issues. + From 369f932c20ea36ddc96d8454ab3e181c6cce48b4 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 3 Jul 2024 12:47:40 +0700 Subject: [PATCH 003/341] [Antora] Make partial for server Benchmarks --- ...ames-imap-base-performance-distributed.png | Bin 0 -> 695993 bytes .../images/james-imap-base-performance.png | Bin 192914 -> 0 bytes .../benchmark/benchmark_prepare.adoc | 1 + .../distributed/benchmark/db-benchmark.adoc | 377 +----------------- .../pages/distributed/benchmark/index.adoc | 11 +- .../benchmark/james-benchmark.adoc | 102 +---- .../partials/benchmark/db-benchmark.adoc | 103 +---- .../servers/partials/benchmark/index.adoc | 7 + .../partials/benchmark/james-benchmark.adoc | 27 +- 9 files changed, 42 insertions(+), 586 deletions(-) create mode 100644 docs/modules/servers/assets/images/james-imap-base-performance-distributed.png delete mode 100644 docs/modules/servers/assets/images/james-imap-base-performance.png create mode 100644 docs/modules/servers/pages/distributed/benchmark/benchmark_prepare.adoc create mode 100644 docs/modules/servers/partials/benchmark/index.adoc diff --git a/docs/modules/servers/assets/images/james-imap-base-performance-distributed.png b/docs/modules/servers/assets/images/james-imap-base-performance-distributed.png new file mode 100644 index 0000000000000000000000000000000000000000..aa693982a6f4cc6a33b6f0a33f7bd71eb883bd0e GIT binary patch literal 695993 zcmc$`XH-?$wk>Q9m=#e3!4e7(5y>FYKu{#ZCP)%ckPMQu36Ug)h$I1p0%Ri~k~2t> zEFx@zwCc`L|C9ilixv2EM7L$_{< zD{b4h$79>J-H!YA;3s;#U*++y{Z=G~2eF+jdL*nu?>&uP!4q zT8Hr7ElYx(;oetW_224_Uw9SsnXTGDTen^!zrVUXQ(Oht=5@D++%e z$Z@gD5$QeLwnd*)s#jK*vNv6*wA=d45Q~QDyAR^tSAt~i8xl7!OKTrDmbg9F7Cy3b zA79(!`YGZ#ab!iLF?KXBh2h!J>-+xr?|;xyjCaug=MV5lFH$ex+WcQX6BcJ&`Cq>~ zdo*w`;D27N{D$}|vHx-XXGf15`+s_|+75b3irqdH{T=iN{^L3Rx$S+v6;J97Zz#Ub z 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%0Aq8irn_&qjkjpXLZDYQ26BF=FJ~%@ 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�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%0Df8En6zmwRL|4H_Fc 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
  • 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 literal 0 HcmV?d00001 diff --git a/docs/modules/servers/assets/images/james-imap-base-performance.png b/docs/modules/servers/assets/images/james-imap-base-performance.png deleted file mode 100644 index 1caa11dc496bef5575976686e40fa84e5fa5803f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 192914 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%0Ak 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 P^pKHI6t58d=J$U9B5eX+ diff --git a/docs/modules/servers/pages/distributed/benchmark/benchmark_prepare.adoc b/docs/modules/servers/pages/distributed/benchmark/benchmark_prepare.adoc new file mode 100644 index 00000000000..ab0c01417a7 --- /dev/null +++ b/docs/modules/servers/pages/distributed/benchmark/benchmark_prepare.adoc @@ -0,0 +1 @@ +// \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/benchmark/db-benchmark.adoc b/docs/modules/servers/pages/distributed/benchmark/db-benchmark.adoc index f2172186346..50b91bfeb4a 100644 --- a/docs/modules/servers/pages/distributed/benchmark/db-benchmark.adoc +++ b/docs/modules/servers/pages/distributed/benchmark/db-benchmark.adoc @@ -1,32 +1,12 @@ = Distributed James Server -- Database benchmarks :navtitle: Database benchmarks -This document provides basic performance of Distributed James' databases, benchmark methodologies as a basis for a James administrator who -can test and evaluate if his Distributed James databases are performing well. +:backend-name: cassandra +:backend-name-cap: Cassandra +:server-name: Distributed James Server +:backend-database-extend-sample: Apache Cassandra 4 as main database: 3 nodes, each node has 8 OVH vCores CPU and 30 GB memory limit (OVH b2-30 instance). -It includes: - -* A sample deployment topology -* Propose benchmark methodology and base performance for each database. This aims to help operators to quickly identify -performance issues and compliance of their databases. - -== Sample deployment topology - -We deploy a sample topology of Distributed James with these following databases: - -- Apache Cassandra 4 as main database: 3 nodes, each node has 8 OVH vCores CPU and 30 GB memory limit (OVH b2-30 instance). -- OpenDistro 1.13.1 as search engine: 3 nodes, each node has 8 OVH vCores CPU and 30 GB memory limit (OVH b2-30 instance). -- RabbitMQ 3.8.17 as message queue: 3 Kubernetes pods, each pod has 0.6 OVH vCore CPU and 2 GB memory limit. -- OVH Swift S3 as an object storage - -With the above system, our email service operates stably with valuable performance. -For a more details, it can handle a load throughput up to about 1000 JMAP requests per second with 99th percentile latency is 400ms. - -== Benchmark methodologies and base performances -We are willing to share the benchmark methodologies and the result to you as a reference to evaluate your Distributed James' performance. -Other evaluation methods are welcome, as long as your databases exhibit similar or even better performance than ours. -It is up to your business needs. If your databases shows results that fall far from our baseline performance, there's a good chance that -there are problems with your system, and you need to check it out thoroughly. +include::partial$benchmark/db-benchmark.adoc[] === Benchmark Cassandra @@ -118,350 +98,3 @@ https://www.datastax.com/blog/improved-cassandra-21-stress-tool-benchmark-any-sc https://www.instaclustr.com/deep-diving-cassandra-stress-part-3-using-yaml-profiles/[Deep Diving cassandra-stress – Part 3 (Using YAML Profiles)] -=== Benchmark OpenSearch - -==== Benchmark methodology - -===== Benchmark tool -We use https://github.com/opensearch-project/opensearch-benchmark[opensearch-benchmark] - an official OpenSearch benchmarking tool. -It provides the following features: - -- Automatically create OpenSearch clusters, stress tests them, and delete them. -- Manage stress testing data and solutions by OpenSearch version. -- Present stress testing data in a comprehensive way, allowing you to compare and analyze the data of different stress tests and store the data on a particular OpenSearch instance for secondary analysis. -- Collect Java Virtual Machine (JVM) details, such as memory and garbage collection (GC) data, to locate performance problems. - -===== How to benchmark -To install the `opensearch-benchmark` tool, you need Python 3.8+ including pip3 first, then run: -``` -python3 -m pip install opensearch-benchmark -``` - -If you have any trouble or need more detailed instructions, please look in the https://github.com/opensearch-project/OpenSearch-Benchmark/blob/main/DEVELOPER_GUIDE.md[detailed installation guide]. - -Let's see which workloads (simulation profiles) that `opensearch-benchmark` provides: ```opensearch-benchmark list worloads```. -For our James use case, we are interested in ```pmc``` workload: ```Full-text benchmark with academic papers from PMC```. - -Run the below script to benchmark against your OpenSearch cluster: - -[source,bash] ----- -opensearch-benchmark execute_test --pipeline=benchmark-only --workload=[workload-name] --target-host=[ip_node1:port_node1],[ip_node2:port_node2],[ip_node3:port_node3] --client-options="use_ssl:false,verify_certs:false,basic_auth_user:'[user]',basic_auth_password:'[password]'" ----- - -In there: - -* --pipeline=benchmark-only: benchmark against a running cluster -* workload-name: the workload you want to benchmark -* ip:port: OpenSearch Node' socket -* user/password: OpenSearch authentication credentials - -==== Sample benchmark result -===== PMC worload - -[source] ----- -| Metric | Task | Value | Unit | -|---------------------------------------------------------------:|------------------------------:|------------:|--------:| -| Min Throughput | index-append | 734.63 | docs/s | -| Mean Throughput | index-append | 763.16 | docs/s | -| Median Throughput | index-append | 746.5 | docs/s | -| Max Throughput | index-append | 833.51 | docs/s | -| 50th percentile latency | index-append | 4738.57 | ms | -| 90th percentile latency | index-append | 8129.1 | ms | -| 99th percentile latency | index-append | 11734.5 | ms | -| 100th percentile latency | index-append | 14662.9 | ms | -| 50th percentile service time | index-append | 4738.57 | ms | -| 90th percentile service time | index-append | 8129.1 | ms | -| 99th percentile service time | index-append | 11734.5 | ms | -| 100th percentile service time | index-append | 14662.9 | ms | -| error rate | index-append | 0 | % | -| Min Throughput | default | 19.94 | ops/s | -| Mean Throughput | default | 19.95 | ops/s | -| Median Throughput | default | 19.95 | ops/s | -| Max Throughput | default | 19.96 | ops/s | -| 50th percentile latency | default | 23.1322 | ms | -| 90th percentile latency | default | 25.4129 | ms | -| 99th percentile latency | default | 29.1382 | ms | -| 100th percentile latency | default | 29.4762 | ms | -| 50th percentile service time | default | 21.4895 | ms | -| 90th percentile service time | default | 23.589 | ms | -| 99th percentile service time | default | 26.6134 | ms | -| 100th percentile service time | default | 27.9068 | ms | -| error rate | default | 0 | % | -| Min Throughput | term | 19.93 | ops/s | -| Mean Throughput | term | 19.94 | ops/s | -| Median Throughput | term | 19.94 | ops/s | -| Max Throughput | term | 19.95 | ops/s | -| 50th percentile latency | term | 31.0684 | ms | -| 90th percentile latency | term | 34.1419 | ms | -| 99th percentile latency | term | 74.7904 | ms | -| 100th percentile latency | term | 103.663 | ms | -| 50th percentile service time | term | 29.6775 | ms | -| 90th percentile service time | term | 32.4288 | ms | -| 99th percentile service time | term | 36.013 | ms | -| 100th percentile service time | term | 102.193 | ms | -| error rate | term | 0 | % | -| Min Throughput | phrase | 19.94 | ops/s | -| Mean Throughput | phrase | 19.95 | ops/s | -| Median Throughput | phrase | 19.95 | ops/s | -| Max Throughput | phrase | 19.95 | ops/s | -| 50th percentile latency | phrase | 23.0255 | ms | -| 90th percentile latency | phrase | 26.1607 | ms | -| 99th percentile latency | phrase | 31.2094 | ms | -| 100th percentile latency | phrase | 45.5012 | ms | -| 50th percentile service time | phrase | 21.5109 | ms | -| 90th percentile service time | phrase | 24.4144 | ms | -| 99th percentile service time | phrase | 26.1865 | ms | -| 100th percentile service time | phrase | 43.5122 | ms | -| error rate | phrase | 0 | % | - ----------------------------------- -[INFO] SUCCESS (took 1772 seconds) ----------------------------------- ----- - -===== PMC custom workload -We customized the PMC workload by increasing search throughput target to figure out our OpenSearch cluster limit. - -The result is that with 25-30 request/s we have a 99th percentile latency of 1s. - -==== References -The `opensearch-benchmark` tool seems to be a fork of the official benchmark tool https://github.com/elastic/rally[EsRally] of Elasticsearch. -The `opensearch-benchmark` tool is not adopted widely yet, so we believe some EsRally references could help as well: - -- https://www.alibabacloud.com/blog/esrally-official-stress-testing-tool-for-elasticsearch_597102[esrally: Official Stress Testing Tool for Elasticsearch] - -- https://esrally.readthedocs.io/en/latest/adding_tracks.html[Create a custom EsRally track] - -- https://discuss.elastic.co/t/why-the-percentile-latency-is-several-times-more-than-service-time/69630[Why the percentile latency is several times more than service time] - -=== Benchmark RabbitMQ - -==== Benchmark methodology - -===== Benchmark tool -We use https://github.com/rabbitmq/rabbitmq-perf-test[rabbitmq-perf-test] tool. - -===== How to benchmark -Using PerfTestMulti for more friendly: - -- Provide input scenario from a single file -- Provide output result as a single file. Can be visualized result file by the chart (graph WebUI) - -Run a command like below: - -[source,bash] ----- -bin/runjava com.rabbitmq.perf.PerfTestMulti [scenario-file] [result-file] ----- - -In order to visualize result, coping [result-file] to ```/html/examples/[result-file]```. -Start webserver to view graph by the command: - -[source,bash] ----- -bin/runjava com.rabbitmq.perf.WebServer ----- -Then browse: http://localhost:8080/examples/sample.html - -==== Sample benchmark result -- Scenario file: - -[source] ----- -[{'name': 'consume', 'type': 'simple', -'uri': 'amqp://james:eeN7Auquaeng@localhost:5677', -'params': - [{'time-limit': 30, 'producer-count': 2, 'consumer-count': 4}]}] ----- - -- Result file: - -[source,json] ----- -{ - "consume": { - "send-bytes-rate": 0, - "recv-msg-rate": 4330.225080385852, - "avg-latency": 18975254, - "send-msg-rate": 455161.3183279743, - "recv-bytes-rate": 0, - "samples": [{ - "elapsed": 15086, - "send-bytes-rate": 0, - "recv-msg-rate": 0, - "send-msg-rate": 0.06628662335940608, - "recv-bytes-rate": 0 - }, - { - "elapsed": 16086, - "send-bytes-rate": 0, - "recv-msg-rate": 1579, - "max-latency": 928296, - "min-latency": 278765, - "avg-latency": 725508, - "send-msg-rate": 388994, - "recv-bytes-rate": 0 - }, - { - "elapsed": 48184, - "send-bytes-rate": 0, - "recv-msg-rate": 3768.4918347742555, - "max-latency": 32969370, - "min-latency": 31852685, - "avg-latency": 32385432, - "send-msg-rate": 0, - "recv-bytes-rate": 0 - }, - { - "elapsed": 49186, - "send-bytes-rate": 0, - "recv-msg-rate": 4416.167664670658, - "max-latency": 33953465, - "min-latency": 32854771, - "avg-latency": 33373113, - "send-msg-rate": 0, - "recv-bytes-rate": 0 - }] - } -} ----- - -- Key result points: - -|=== -|Metrics |Unit |Result - -|Publisher throughput (the sending rate) -|messages / second -|3111 - -|Consumer throughput (the receiving rate) -|messages / second -|4404 -|=== - -=== Benchmark S3 storage - -==== Benchmark methodology - -===== Benchmark tool -We use https://github.com/dvassallo/s3-benchmark[s3-benchmark] tool. - -===== How to benchmark -1. Make sure you set up appropriate S3 credentials with `awscli`. -2. If you are using a compatible S3 storage of cloud providers like OVH, you would need to configure -`awscli-plugin-endpoint`. E.g: https://docs.ovh.com/au/en/storage/getting_started_with_the_swift_S3_API/[Getting started with the OVH Swift S3 API] -3. Install `s3-benchmark` tool and run the command: - -[source,bash] ----- -./s3-benchmark -endpoint=[endpoint] -region=[region] -bucket-name=[bucket-name] -payloads-min=[payload-min] -payloads-max=[payload-max] threads-max=[threads-max] ----- - -==== Sample benchmark result -We did S3 performance testing with suitable email objects sizes: 4 KB, 128 KB, 1 MB, 8 MB. - -Result: - -[source,bash] ----- ---- SETUP -------------------------------------------------------------------------------------------------------------------- - -Uploading 4 KB objects - 100% |████████████████████████████████████████| [4s:0s] -Uploading 128 KB objects - 100% |████████████████████████████████████████| [9s:0s] -Uploading 1 MB objects - 100% |████████████████████████████████████████| [8s:0s] -Uploading 8 MB objects - 100% |████████████████████████████████████████| [10s:0s] - ---- BENCHMARK ---------------------------------------------------------------------------------------------------------------- - -Download performance with 4 KB objects (b2-30) - +-------------------------------------------------------------------------------------------------+ - | Time to First Byte (ms) | Time to Last Byte (ms) | -+---------+----------------+------------------------------------------------+------------------------------------------------+ -| Threads | Throughput | avg min p25 p50 p75 p90 p99 max | avg min p25 p50 p75 p90 p99 max | -+---------+----------------+------------------------------------------------+------------------------------------------------+ -| 8 | 0.6 MB/s | 36 10 17 22 36 57 233 249 | 37 10 17 22 36 57 233 249 | -| 9 | 0.6 MB/s | 30 10 15 21 33 45 82 234 | 30 10 15 21 33 45 83 235 | -| 10 | 0.2 MB/s | 55 11 18 22 28 52 248 1075 | 55 11 18 22 28 52 249 1075 | -| 11 | 0.3 MB/s | 66 11 18 23 45 233 293 683 | 67 11 19 23 45 233 293 683 | -| 12 | 0.6 MB/s | 35 12 19 22 43 55 67 235 | 35 12 19 22 43 56 67 235 | -| 13 | 0.2 MB/s | 68 11 19 26 58 79 279 1037 | 68 11 19 26 58 80 279 1037 | -| 14 | 0.6 MB/s | 43 17 20 24 52 56 230 236 | 43 17 20 25 52 56 230 236 | -| 15 | 0.2 MB/s | 69 11 16 23 50 66 274 1299 | 69 11 16 24 50 66 274 1299 | -| 16 | 0.5 MB/s | 52 9 19 31 81 95 228 237 | 53 9 19 31 81 95 229 237 | -+---------+----------------+------------------------------------------------+------------------------------------------------+ - -Download performance with 128 KB objects (b2-30) - +-------------------------------------------------------------------------------------------------+ - | Time to First Byte (ms) | Time to Last Byte (ms) | -+---------+----------------+------------------------------------------------+------------------------------------------------+ -| Threads | Throughput | avg min p25 p50 p75 p90 p99 max | avg min p25 p50 p75 p90 p99 max | -+---------+----------------+------------------------------------------------+------------------------------------------------+ -| 8 | 3.3 MB/s | 71 16 22 28 39 66 232 1768 | 73 16 23 29 43 67 233 1769 | -| 9 | 3.6 MB/s | 74 9 19 23 34 58 239 1646 | 75 10 20 24 37 59 240 1647 | -| 10 | 2.9 MB/s | 97 16 21 24 48 89 656 2034 | 99 17 21 26 49 92 657 2035 | -| 11 | 3.0 MB/s | 100 10 21 26 39 64 1049 2029 | 101 11 21 27 40 65 1050 2030 | -| 12 | 3.0 MB/s | 76 12 19 24 44 56 256 2012 | 77 13 20 25 48 69 258 2013 | -| 13 | 6.1 MB/s | 73 10 13 20 43 223 505 1026 | 74 10 15 21 43 224 506 1027 | -| 14 | 5.5 MB/s | 81 11 15 23 51 240 666 1060 | 82 12 16 23 54 241 667 1060 | -| 15 | 2.7 MB/s | 80 10 19 28 43 59 234 2222 | 84 11 25 34 47 60 236 2224 | -| 16 | 18.6 MB/s | 58 10 19 26 61 224 248 266 | 61 10 22 29 65 224 249 267 | -+---------+----------------+------------------------------------------------+------------------------------------------------+ - -Download performance with 1 MB objects (b2-30) - +-------------------------------------------------------------------------------------------------+ - | Time to First Byte (ms) | Time to Last Byte (ms) | -+---------+----------------+------------------------------------------------+------------------------------------------------+ -| Threads | Throughput | avg min p25 p50 p75 p90 p99 max | avg min p25 p50 p75 p90 p99 max | -+---------+----------------+------------------------------------------------+------------------------------------------------+ -| 8 | 56.4 MB/s | 41 12 26 34 43 57 94 235 | 136 30 69 100 161 284 345 396 | -| 9 | 55.2 MB/s | 53 19 32 39 50 69 238 247 | 149 26 84 117 164 245 324 655 | -| 10 | 33.9 MB/s | 74 17 27 37 50 77 456 1060 | 177 29 97 134 205 273 484 1076 | -| 11 | 57.3 MB/s | 56 26 35 44 57 71 251 298 | 185 40 93 129 216 329 546 871 | -| 12 | 37.7 MB/s | 66 21 33 43 58 73 102 1024 | 202 24 81 125 205 427 839 1222 | -| 13 | 57.6 MB/s | 59 24 35 40 58 71 275 289 | 215 40 94 181 288 393 500 674 | -| 14 | 47.1 MB/s | 73 18 46 56 66 75 475 519 | 229 30 116 221 272 441 603 686 | -| 15 | 58.2 MB/s | 65 11 40 51 63 75 260 294 | 243 29 132 174 265 485 831 849 | -| 16 | 23.1 MB/s | 96 14 46 55 62 80 124 2022 | 278 31 124 187 249 634 827 2028 | -+---------+----------------+------------------------------------------------+------------------------------------------------+ - -Download performance with 8 MB objects (b2-30) - +-------------------------------------------------------------------------------------------------+ - | Time to First Byte (ms) | Time to Last Byte (ms) | -+---------+----------------+------------------------------------------------+------------------------------------------------+ -| Threads | Throughput | avg min p25 p50 p75 p90 p99 max | avg min p25 p50 p75 p90 p99 max | -+---------+----------------+------------------------------------------------+------------------------------------------------+ -| 8 | 58.4 MB/s | 88 35 65 79 88 96 288 307 | 1063 458 564 759 928 1151 4967 6841 | -| 9 | 50.4 MB/s | 137 32 52 69 145 286 509 1404 | 1212 160 471 581 1720 2873 3744 4871 | -| 10 | 58.2 MB/s | 77 46 54 66 77 98 275 285 | 1319 377 432 962 1264 3232 4266 6151 | -| 11 | 58.4 MB/s | 97 32 63 72 80 91 323 707 | 1429 325 593 722 1648 3020 6172 6370 | -| 12 | 58.5 MB/s | 108 26 65 81 91 261 301 519 | 1569 472 696 1101 1915 3175 4066 5110 | -| 13 | 56.1 MB/s | 115 35 69 83 93 125 329 1092 | 1712 458 801 1165 2354 3559 3865 5945 | -| 14 | 58.6 MB/s | 103 26 70 78 88 112 309 656 | 1807 789 999 1269 1998 3258 5201 6651 | -| 15 | 58.3 MB/s | 113 31 55 67 79 134 276 1490 | 1947 497 1081 1756 2730 3557 3799 3974 | -| 16 | 58.0 MB/s | 99 35 67 79 96 146 282 513 | 2091 531 882 1136 2161 6034 6686 6702 | -+---------+----------------+------------------------------------------------+------------------------------------------------+ ----- - -We believe that the actual OVH Swift S3' throughput should be at least about 100 MB/s. This was not fully achieved due to -network limitations of the client machine performing the benchmark. - -=== Benchmark Redis - -==== Benchmark methodology - -We can use the built-in https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/benchmarks/[redis-benchmark utility]. - -The tool is easy to use with good documentation. Just to be sure that you specify the redis-benchmark to use multi-thread if it runs against a multi-thread Redis instance. - -Example: -``` -redis-benchmark -n 1000000 --threads 4 -``` - diff --git a/docs/modules/servers/pages/distributed/benchmark/index.adoc b/docs/modules/servers/pages/distributed/benchmark/index.adoc index e94aba0a08a..0c299967fe0 100644 --- a/docs/modules/servers/pages/distributed/benchmark/index.adoc +++ b/docs/modules/servers/pages/distributed/benchmark/index.adoc @@ -1,10 +1,7 @@ -= Distributed James Server — Performance testing the Distributed server += Distributed James Server — Performance testing :navtitle: Performance testing the Distributed server -The following pages detail how to do performance testing for the Distributed server also its database. +:xref-base: distributed +:server-name: Distributed James Server -Once you have a Distributed James server up and running you then need to ensure it operates correctly and has a decent performance. -You may need to do performance testings periodically to make sure your James performs well. - -We introduced xref:distributed/benchmark/james-benchmark.adoc[tools and base benchmark result for Distributed James] also xref:distributed/benchmark/db-benchmark.adoc[James database's base performance and how to benchmark them] -to cover this topic. \ No newline at end of file +include::partial$benchmark/index.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/benchmark/james-benchmark.adoc b/docs/modules/servers/pages/distributed/benchmark/james-benchmark.adoc index 07040bb90fa..fe5d0b7579e 100644 --- a/docs/modules/servers/pages/distributed/benchmark/james-benchmark.adoc +++ b/docs/modules/servers/pages/distributed/benchmark/james-benchmark.adoc @@ -1,100 +1,10 @@ = Distributed James Server benchmark :navtitle: James benchmarks -This document provides benchmark methodology and basic performance of Distributed James as a basis for a James administrator who -can test and evaluate if his Distributed James is performing well. - -It includes: - -* A sample Distributed James deployment topology -* Propose benchmark methodology -* Sample performance results - -This aims to help operators quickly identify performance issues. - -== Sample deployment topology - -We deploy a sample topology of Distributed James with these following components: - -- Distributed James: 3 Kubernetes pods, each pod has 2 OVH vCore CPU and 4 GB memory limit. -- Apache Cassandra 4 as main database: 3 nodes, each node has 8 OVH vCores CPU and 30 GB memory limit (OVH b2-30 instance). -- OpenDistro 1.13.1 as search engine: 3 nodes, each node has 8 OVH vCores CPU and 30 GB memory limit (OVH b2-30 instance). -- RabbitMQ 3.8.17 as message queue: 3 Kubernetes pods, each pod has 0.6 OVH vCore CPU and 2 GB memory limit. -- OVH Swift S3 as an object storage - -== Benchmark methodology and base performance - -=== Provision testing data - -Before doing the performance test, you should make sure you have a Distributed James up and running with some provisioned testing -data so that it is representative of reality. - -Please follow these steps to provision testing data: - -* Prepare James with a custom `mailetcontainer.xml` having Random storing mailet. This help us easily setting a good amount of -provisioned emails. - -Add this under transport processor ----- - ----- - -* Modify https://github.com/apache/james-project/tree/master/docs/modules/servers/pages/distributed/benchmark/provision.sh[provision.sh] -upon your need (number of users, mailboxes, emails to be provisioned). - -Currently, this script provisions 10 users, 15 mailboxes and hundreds of emails for example. Normally to make the performance test representative, you -should provision thousands of users, thousands of mailboxes and millions of emails. - -* Add the permission to execute the script: ----- -chmod +x provision.sh ----- - -* Install postfix (to get the smtp-source command): ----- -sudo apt-get install postfix ----- - -* Run the provision script: ----- -./provision.sh ----- - -After provisioning once, you should remove the Random storing mailet and move on to performance testing phase. - -=== Provide performance testing method - -We introduce the tailored https://github.com/linagora/james-gatling[James Gatling] which bases on https://gatling.io/[Gatling - Load testing framework] -for performance testing against IMAP/JMAP servers. Other testing method is welcome as long as you feel it is appropriate. - -Here are steps to do performance testing with James Gatling: - -* Setup James Gatling with `sbt` build tool - -* Configure the `Configuration.scala` to point to your Distributed James IMAP/JMAP server(s). For more configuration details, please read -https://github.com/linagora/james-gatling#readme[James Gatling Readme]. - -* Run the performance testing simulation: ----- -$ sbt -> gatling:testOnly SIMULATION_FQDN ----- - -In there: `SIMULATION_FQDN` is fully qualified class name of a performance test simulation. - -We did provide a lot of simulations in `org.apache.james.gatling.simulation` path. You can have a look and choose the suitable simulation. -`sbt gatling:testOnly org.apache.james.gatling.simulation.imap.PlatformValidationSimulation` is a good starting point. Or you can even customize your simulation also! - -Some symbolic simulations we often use: - -* IMAP: `org.apache.james.gatling.simulation.imap.PlatformValidationSimulation` -* JMAP rfc8621: `org.apache.james.gatling.simulation.jmap.rfc8621.PushPlatformValidationSimulation` - -=== Base performance result - -A sample IMAP performance testing result (PlatformValidationSimulation): - -image::james-imap-base-performance.png[] - -If you get a IMAP performance far below this base performance, you should consider investigating for performance issues. +:server-name: Distributed James Server +:backend-database-extend-sample: Apache Cassandra 4 as main database: 3 nodes, each node has 8 OVH vCores CPU and 30 GB memory limit (OVH b2-30 instance). +:provision_file_url: https://github.com/apache/james-project/tree/master/docs/modules/servers/pages/distributed/benchmark/provision.sh +:benchmark_prepare_extend: servers:distributed/benchmark/benchmark_prepare.adoc +:james-imap-base-performance-picture: james-imap-base-performance-distributed.png +include::partial$benchmark/james-benchmark.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/partials/benchmark/db-benchmark.adoc b/docs/modules/servers/partials/benchmark/db-benchmark.adoc index a3e42d7b340..fca15401e95 100644 --- a/docs/modules/servers/partials/benchmark/db-benchmark.adoc +++ b/docs/modules/servers/partials/benchmark/db-benchmark.adoc @@ -1,8 +1,5 @@ -= Distributed James Server -- Database benchmarks -:navtitle: Database benchmarks - -This document provides basic performance of Distributed James' databases, benchmark methodologies as a basis for a James administrator who -can test and evaluate if his Distributed James databases are performing well. +This document provides basic performance of {server-name} databases, benchmark methodologies as a basis for a James administrator who +can test and evaluate if his {server-name} databases are performing well. It includes: @@ -12,112 +9,22 @@ performance issues and compliance of their databases. == Sample deployment topology -We deploy a sample topology of Distributed James with these following databases: +We deploy a sample topology of {server-name} with these following databases: -- Apache Cassandra 4 as main database: 3 nodes, each node has 8 OVH vCores CPU and 30 GB memory limit (OVH b2-30 instance). - OpenDistro 1.13.1 as search engine: 3 nodes, each node has 8 OVH vCores CPU and 30 GB memory limit (OVH b2-30 instance). - RabbitMQ 3.8.17 as message queue: 3 Kubernetes pods, each pod has 0.6 OVH vCore CPU and 2 GB memory limit. - OVH Swift S3 as an object storage +- {backend-database-extend-sample} With the above system, our email service operates stably with valuable performance. For a more details, it can handle a load throughput up to about 1000 JMAP requests per second with 99th percentile latency is 400ms. == Benchmark methodologies and base performances -We are willing to share the benchmark methodologies and the result to you as a reference to evaluate your Distributed James' performance. +We are willing to share the benchmark methodologies and the result to you as a reference to evaluate your {server-name}' performance. Other evaluation methods are welcome, as long as your databases exhibit similar or even better performance than ours. It is up to your business needs. If your databases shows results that fall far from our baseline performance, there's a good chance that there are problems with your system, and you need to check it out thoroughly. -=== Benchmark Cassandra - -==== Benchmark methodology -===== Benchmark tool - -We use https://cassandra.apache.org/doc/latest/cassandra/tools/cassandra_stress.html[cassandra-stress tool] - an official -tool of Cassandra for stress loading tests. - -The cassandra-stress tool is a Java-based stress testing utility for basic benchmarking and load testing a Cassandra cluster. -Data modeling choices can greatly affect application performance. Significant load testing over several trials is the best method for discovering issues with a particular data model. The cassandra-stress tool is an effective tool for populating a cluster and stress testing CQL tables and queries. Use cassandra-stress to: - -- Quickly determine how a schema performs. -- Understand how your database scales. -- Optimize your data model and settings. -- Determine production capacity. - -There are several operation types: - -- write-only, read-only, and mixed workloads of standard data -- write-only and read-only workloads for counter columns -- user configured workloads, running custom queries on custom schemas - -===== How to benchmark - -Here we are using a simple case to test and compare Cassandra performance between different setup environments. - -[source,yaml] ----- -keyspace: stresscql - -keyspace_definition: | - CREATE KEYSPACE stresscql WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 3}; - -table: mixed_workload - -table_definition: | - CREATE TABLE mixed_workload ( - key uuid PRIMARY KEY, - a blob, - b blob - ) WITH COMPACT STORAGE - -columnspec: - - name: a - size: uniform(1..10000) - - name: b - size: uniform(1..100000) - -insert: - partitions: fixed(1) - -queries: - read: - cql: select * from mixed_workload where key = ? - fields: samerow ----- - -Create the yaml file as above and copy to a Cassandra node. - -Insert some sample data: - -[source,bash] ----- -cassandra-stress user profile=mixed_workload.yml n=100000 "ops(insert=1)" cl=ONE -mode native cql3 user= password= -node -rate threads=8 -graph file=./graph_insert.xml title=Benchmark revision=insert_ONE ----- - -Read intensive scenario: - -[source,bash] ----- -cassandra-stress user profile=mixed_workload.yml n=100000 "ops(insert=1,read=4)" cl=ONE -mode native cql3 user= password= -node -rate threads=8 -graph file=./graph_mixed.xml title=Benchmark revision=mixed_ONE ----- - -In there: - -- n=100000: The number of insert batches, not number of individual insert operations. -- rate threads=8: The number of concurrent threads. If not specified it will start with 4 threads and increase until server reaches a limit. -- ops(insert=1,read=4): This will execute insert and read queries in the ratio 1:4. -- graph: Export results to graph in html format. - -==== Sample benchmark result -image::cassandra_stress_test_result_1.png[] - -image::cassandra_stress_test_result_2.png[] - -==== References -https://www.datastax.com/blog/improved-cassandra-21-stress-tool-benchmark-any-schema-part-1[Datastax - Cassandra stress tool] - -https://www.instaclustr.com/deep-diving-cassandra-stress-part-3-using-yaml-profiles/[Deep Diving cassandra-stress – Part 3 (Using YAML Profiles)] - === Benchmark OpenSearch ==== Benchmark methodology diff --git a/docs/modules/servers/partials/benchmark/index.adoc b/docs/modules/servers/partials/benchmark/index.adoc new file mode 100644 index 00000000000..6077f67481f --- /dev/null +++ b/docs/modules/servers/partials/benchmark/index.adoc @@ -0,0 +1,7 @@ +The following pages detail how to do performance testing for the {server-name} also its database. + +Once you have a {server-name} up and running you then need to ensure it operates correctly and has a decent performance. +You may need to do performance testings periodically to make sure your James performs well. + +We introduced xref:{xref-base}/benchmark/james-benchmark.adoc[tools and base benchmark result for {server-name}] also xref:{xref-base}/benchmark/db-benchmark.adoc[James database's base performance and how to benchmark them] +to cover this topic. \ No newline at end of file diff --git a/docs/modules/servers/partials/benchmark/james-benchmark.adoc b/docs/modules/servers/partials/benchmark/james-benchmark.adoc index 07040bb90fa..308281428cc 100644 --- a/docs/modules/servers/partials/benchmark/james-benchmark.adoc +++ b/docs/modules/servers/partials/benchmark/james-benchmark.adoc @@ -1,12 +1,9 @@ -= Distributed James Server benchmark -:navtitle: James benchmarks - -This document provides benchmark methodology and basic performance of Distributed James as a basis for a James administrator who -can test and evaluate if his Distributed James is performing well. +This document provides benchmark methodology and basic performance of {server-name} as a basis for a James administrator who +can test and evaluate if his {server-name} is performing well. It includes: -* A sample Distributed James deployment topology +* A sample {server-name} deployment topology * Propose benchmark methodology * Sample performance results @@ -14,19 +11,21 @@ This aims to help operators quickly identify performance issues. == Sample deployment topology -We deploy a sample topology of Distributed James with these following components: +We deploy a sample topology of {server-name} with these following components: -- Distributed James: 3 Kubernetes pods, each pod has 2 OVH vCore CPU and 4 GB memory limit. -- Apache Cassandra 4 as main database: 3 nodes, each node has 8 OVH vCores CPU and 30 GB memory limit (OVH b2-30 instance). +- {server-name}: 3 Kubernetes pods, each pod has 2 OVH vCore CPU and 4 GB memory limit. - OpenDistro 1.13.1 as search engine: 3 nodes, each node has 8 OVH vCores CPU and 30 GB memory limit (OVH b2-30 instance). - RabbitMQ 3.8.17 as message queue: 3 Kubernetes pods, each pod has 0.6 OVH vCore CPU and 2 GB memory limit. - OVH Swift S3 as an object storage +- {backend-database-extend-sample} == Benchmark methodology and base performance +include::{benchmark_prepare_extend}[] + === Provision testing data -Before doing the performance test, you should make sure you have a Distributed James up and running with some provisioned testing +Before doing the performance test, you should make sure you have a {server-name} up and running with some provisioned testing data so that it is representative of reality. Please follow these steps to provision testing data: @@ -35,11 +34,13 @@ Please follow these steps to provision testing data: provisioned emails. Add this under transport processor + +[source,xml] ---- ---- -* Modify https://github.com/apache/james-project/tree/master/docs/modules/servers/pages/distributed/benchmark/provision.sh[provision.sh] +* Modify {provision_file_url}[provision.sh] upon your need (number of users, mailboxes, emails to be provisioned). Currently, this script provisions 10 users, 15 mailboxes and hundreds of emails for example. Normally to make the performance test representative, you @@ -71,7 +72,7 @@ Here are steps to do performance testing with James Gatling: * Setup James Gatling with `sbt` build tool -* Configure the `Configuration.scala` to point to your Distributed James IMAP/JMAP server(s). For more configuration details, please read +* Configure the `Configuration.scala` to point to your {server-name} IMAP/JMAP server(s). For more configuration details, please read https://github.com/linagora/james-gatling#readme[James Gatling Readme]. * Run the performance testing simulation: @@ -94,7 +95,7 @@ Some symbolic simulations we often use: A sample IMAP performance testing result (PlatformValidationSimulation): -image::james-imap-base-performance.png[] +image::{james-imap-base-performance-picture}[] If you get a IMAP performance far below this base performance, you should consider investigating for performance issues. From 07b6087d4a95f2e11127ea92b50c72a20b8c94de Mon Sep 17 00:00:00 2001 From: vttran Date: Mon, 1 Jul 2024 09:01:37 +0700 Subject: [PATCH 004/341] [Antora] Make partial for server Architecture section --- ... => specialized-instances-distributed.png} | Bin ...rage.png => storage_james_distributed.png} | Bin .../architecture/consistency-model.adoc | 126 +------ ...istency_model_data_replication_extend.adoc | 41 +++ .../architecture/implemented-standards.adoc | 119 +------ .../pages/distributed/architecture/index.adoc | 315 +----------------- .../mailqueue_combined_extend.adoc | 4 + .../architecture/specialized-instances.adoc | 38 +-- .../architecture/consistency-model.adoc | 65 ++++ .../architecture/implemented-standards.adoc | 117 +++++++ .../servers/partials/architecture/index.adoc | 302 +++++++++++++++++ .../architecture/specialized-instances.adoc | 36 ++ 12 files changed, 591 insertions(+), 572 deletions(-) rename docs/modules/servers/assets/images/{specialized-instances.png => specialized-instances-distributed.png} (100%) rename docs/modules/servers/assets/images/{storage.png => storage_james_distributed.png} (100%) create mode 100644 docs/modules/servers/pages/distributed/architecture/consistency_model_data_replication_extend.adoc create mode 100644 docs/modules/servers/pages/distributed/architecture/mailqueue_combined_extend.adoc create mode 100644 docs/modules/servers/partials/architecture/consistency-model.adoc create mode 100644 docs/modules/servers/partials/architecture/implemented-standards.adoc create mode 100644 docs/modules/servers/partials/architecture/index.adoc create mode 100644 docs/modules/servers/partials/architecture/specialized-instances.adoc diff --git a/docs/modules/servers/assets/images/specialized-instances.png b/docs/modules/servers/assets/images/specialized-instances-distributed.png similarity index 100% rename from docs/modules/servers/assets/images/specialized-instances.png rename to docs/modules/servers/assets/images/specialized-instances-distributed.png diff --git a/docs/modules/servers/assets/images/storage.png b/docs/modules/servers/assets/images/storage_james_distributed.png similarity index 100% rename from docs/modules/servers/assets/images/storage.png rename to docs/modules/servers/assets/images/storage_james_distributed.png diff --git a/docs/modules/servers/pages/distributed/architecture/consistency-model.adoc b/docs/modules/servers/pages/distributed/architecture/consistency-model.adoc index 53a951fb181..af72b7d810b 100644 --- a/docs/modules/servers/pages/distributed/architecture/consistency-model.adoc +++ b/docs/modules/servers/pages/distributed/architecture/consistency-model.adoc @@ -1,84 +1,14 @@ = Distributed James Server — Consistency Model :navtitle: Consistency Model -This page presents the consistency model used by the Distributed Server and -points to the tools built around it. +:backend-name: cassandra +:backend-name-cap: Cassandra +:server-name: Distributed James Server +:mailet-repository-path-prefix: cassandra +:xref-base: distributed +:data_replication_extend: servers:distributed/architecture/consistency_model_data_replication_extend.adoc -== Data Replication - -The Distributed Server relies on different storage technologies, all having their own -consistency models. - -These data stores replicate data in order to enforce some level of availability. - -By consistency, we mean the ability for all replica to hold the same data. - -By availability, we mean the ability for a replica to answer a request. - -In distributed systems, link:https://en.wikipedia.org/wiki/CAP_theorem[according to the CAP theorem], -as we will necessarily encounter network partitions, then trade-offs need to be made between -consistency and availability. - -This section details this trade-off for data stores used by the Distributed Server. - -=== Cassandra consistency model - -link:https://cassandra.apache.org/[Cassandra] is an -link:https://en.wikipedia.org/wiki/Eventual_consistency[eventually consistent] data store. -This means that replica can hold diverging data, but are guaranteed to converge over time. - -Several mechanisms are built in Cassandra to enforce this convergence, and need to be -leveraged by *Distributed Server Administrator*. Namely -link:https://docs.datastax.com/en/dse/5.1/dse-admin/datastax_enterprise/tools/nodetool/toolsRepair.html[nodetool repair], -link:https://cassandra.apache.org/doc/latest/operating/hints.html[Hinted hand-off] and -link:https://cassandra.apache.org/doc/latest/operating/read_repair.html[Read repair]. - -The Distributed Server tries to mitigate inconsistencies by relying on -link:https://docs.datastax.com/en/archived/cassandra/3.0/cassandra/dml/dmlConfigConsistency.html[QUORUM] read and write levels. -This means that a majority of replica are needed for read and write operations to be performed. This guaranty is needed -as the Mailbox is a complex datamodel with several layers of metadata, and needs "read-your-writes" guaranties that QUORUM -read+writes delivers. - -Critical business operations, like UID allocation, rely on strong consistency mechanisms brought by -link:https://www.datastax.com/blog/2013/07/lightweight-transactions-cassandra-20[lightweight transaction]. - -==== About multi data-center setups - -As strong consistency is required for some operation, especially regarding IMAP monotic UID and MODSEQ generation, -and as lightweight transactions are slow across data centers, running James with a -link:https://docs.datastax.com/en/ddac/doc/datastax_enterprise/production/DDACmultiDCperWorkloadType.html[multi data-center] -Cassandra setup is discouraged. - -However, xref:distributed/configure/cassandra.adoc[this page] enables setting alternative read level, -which could be acceptable regarding limited requirements. `LOCAL_QUORUM` coupled with `LOCAL_SERIAL` -is likely the only scalable setup. Some options were added to turn off SERIAL consistency usage for message -and mailbox management. However, the use of Lightweight Transaction cannot be disabled for UIDs and ModSeqs. - -Running the Distributed Server IMAP server in a multi datacenter setup will likely result either in data loss, -or very slow operations - as we rely on monotic UID generation, without strong consistency, UIDs could be allocated -several times. - -We did wire a multi-DC friendly distributed, POP3 only server that leverages acceptable performance while staying -consistent. This is achieved by having a reduced feature set - supporting only the POP3 server and using messageIds as -identifiers (generated without synchronisation using TimeUUIDs). You can find this application -link:https://github.com/apache/james-project/tree/master/server/apps/distributed-pop3-app[on GitHub]. In the future, -JMAP support could be added, but requires followup developments as some components critically depends on UIDs -(for instance the search). - -=== OpenSearch consistency model - -OpenSearch relies on link:https://www.elastic.co/blog/a-new-era-for-cluster-coordination-in-elasticsearch[strong consistency] -with home grown algorithm. - -The 6.x release line, that the distributed server is using is known to be slow to recover from failures. - -Be aware that data is asynchronously indexed in OpenSearch, changes will be eventually visible. - -=== RabbitMQ consistency model - -The Distributed Server can be set up to rely on a RabbitMQ cluster. All queues can be set up in an high availability -fashion using link:https://www.rabbitmq.com/docs/quorum-queues[quorum queues] - those are replicated queues using the link:https://raft.github.io/[RAFT] consensus protocol and thus are -strongly consistent. +include::partial$architecture/consistency-model.adoc[] == Denormalization @@ -91,45 +21,11 @@ level across denormalization tables. We write to a "table of truth" first, then duplicate the data to denormalization tables. -The Distributed server offers several mechanisms to mitigate these inconsistencies: +The {server-name} offers several mechanisms to mitigate these inconsistencies: - - Writes to denormalization tables are retried. - - Some xref:distributed/operate/guide.adoc#_solving_cassandra_inconsistencies[SolveInconsistencies tasks] are exposed and are able to heal a given denormalization table. +- Writes to denormalization tables are retried. +- Some xref:{xref-base}/operate/guide.adoc#_solving_cassandra_inconsistencies[SolveInconsistencies tasks] are exposed and are able to heal a given denormalization table. They reset the "deduplication tables" content to the "table of truth" content. - - link:https://github.com/apache/james-project/blob/master/src/adr/0042-applicative-read-repairs.md[Read repairs], +- link:https://github.com/apache/james-project/blob/master/src/adr/0042-applicative-read-repairs.md[Read repairs], when implemented for a given denormalization, enables auto-healing. When an inconsistency is detected, They reset the "deduplication tables" entry to the "table of truth" entry. - -== Consistency across data stores - -The Distributed Server leverages several data stores: - - - Cassandra is used for metadata storage - - OpenSearch for search - - Object Storage for large object storage - -Thus the Distributed Server also offers mechanisms to enforce consistency across data stores. - -=== Write path organisation - -The primary data stores are composed of Cassandra for metadata and Object storage for binary data. - -To ensure the data referenced in Cassandra is pointing to a valid object in the object store, we write -the object store payload first, then write the corresponding metadata in Cassandra. - -Similarly, metadata is destroyed first before the corresponding object is deleted. - -Such a procedure avoids metadata pointing to unexisting blobs, however might lead to some unreferenced -blobs. - -=== Cassandra <=> OpenSearch - -After being written to the primary stores (namely Cassandra & Object Storage), email content is -asynchronously indexed into OpenSearch. - -This process, called the EventBus, which retries temporary errors, and stores transient errors for -later admin-triggered retries is described further xref:distributed/operate/guide.adoc#_mailbox_event_bus[here]. -His role is to spread load and limit inconsistencies. - -Furthermore, some xref:distributed/operate/guide.adoc#_usual_troubleshooting_procedures[re-indexing tasks] -enables to re-synchronise OpenSearch content with the primary data stores diff --git a/docs/modules/servers/pages/distributed/architecture/consistency_model_data_replication_extend.adoc b/docs/modules/servers/pages/distributed/architecture/consistency_model_data_replication_extend.adoc new file mode 100644 index 00000000000..d6e3cee9159 --- /dev/null +++ b/docs/modules/servers/pages/distributed/architecture/consistency_model_data_replication_extend.adoc @@ -0,0 +1,41 @@ +=== Cassandra consistency model + +link:https://cassandra.apache.org/[Cassandra] is an +link:https://en.wikipedia.org/wiki/Eventual_consistency[eventually consistent] data store. +This means that replica can hold diverging data, but are guaranteed to converge over time. + +Several mechanisms are built in Cassandra to enforce this convergence, and need to be +leveraged by *Distributed Server Administrator*. Namely +link:https://docs.datastax.com/en/dse/5.1/dse-admin/datastax_enterprise/tools/nodetool/toolsRepair.html[nodetool repair], +link:https://cassandra.apache.org/doc/latest/operating/hints.html[Hinted hand-off] and +link:https://cassandra.apache.org/doc/latest/operating/read_repair.html[Read repair]. + +The {server-name} tries to mitigate inconsistencies by relying on +link:https://docs.datastax.com/en/archived/cassandra/3.0/cassandra/dml/dmlConfigConsistency.html[QUORUM] read and write levels. +This means that a majority of replica are needed for read and write operations to be performed. + +Critical business operations, like UID allocation, rely on strong consistency mechanisms brought by +link:https://www.datastax.com/blog/2013/07/lightweight-transactions-cassandra-20[lightweight transaction]. + +==== About multi data-center setups + +As strong consistency is required for some operation, especially regarding IMAP monotic UID and MODSEQ generation, +and as lightweight transactions are slow across data centers, running James with a +link:https://docs.datastax.com/en/ddac/doc/datastax_enterprise/production/DDACmultiDCperWorkloadType.html[multi data-center] +Cassandra setup is discouraged. + +However, xref:{xref-base}/configure/cassandra.adoc[this page] enables setting alternative read level, +which could be acceptable regarding limited requirements. `LOCAL_QUORUM` coupled with `LOCAL_SERIAL` +is likely the only scalable setup. Some options were added to turn off SERIAL consistency usage for message +and mailbox management. However, the use of Lightweight Transaction cannot be disabled for UIDs and ModSeqs. + +Running the {server-name} IMAP server in a multi datacenter setup will likely result either in data loss, +or very slow operations - as we rely on monotic UID generation, without strong consistency, UIDs could be allocated +several times. + +We did wire a multi-DC friendly distributed, POP3 only server that leverages acceptable performance while staying +consistent. This is achieved by having a reduced feature set - supporting only the POP3 server and using messageIds as +identifiers (generated without synchronisation using TimeUUIDs). You can find this application +link:https://github.com/apache/james-project/tree/master/server/apps/distributed-pop3-app[on GitHub]. In the future, +JMAP support could be added, but requires followup developments as some components critically depends on UIDs +(for instance the search). diff --git a/docs/modules/servers/pages/distributed/architecture/implemented-standards.adoc b/docs/modules/servers/pages/distributed/architecture/implemented-standards.adoc index 3c5e1472ea4..82f085c438b 100644 --- a/docs/modules/servers/pages/distributed/architecture/implemented-standards.adoc +++ b/docs/modules/servers/pages/distributed/architecture/implemented-standards.adoc @@ -1,121 +1,6 @@ = Distributed James Server — Implemented standards :navtitle: Implemented standards -This page details standards implemented by the distributed server. - -== Message formats - - - link:https://datatracker.ietf.org/doc/html/rfc5322[RFC-5322] Internet Message Format (MIME) - - link:https://datatracker.ietf.org/doc/html/rfc2045[RFC-2045] Format of Internet Message Bodies - - link:https://datatracker.ietf.org/doc/html/rfc3464[RFC-3464] An Extensible Message Format for Delivery Status Notifications - - James allow emmit DSNs from the mailet container. - - link:https://datatracker.ietf.org/doc/html/rfc8098[RFC-8098] Message Disposition Notification - -== TLS & authentication - -- link:https://datatracker.ietf.org/doc/html/rfc2595.html[RFC-2595] TLS for IMAP, POP3, SMTP (StartTLS) -- link:https://datatracker.ietf.org/doc/html/rfc8314.html[RFC-8314] Implicit TLS -- link:https://www.rfc-editor.org/rfc/rfc4959.html[RFC-4959] SASL IR: Initial client response -- link:https://datatracker.ietf.org/doc/html/rfc4616[RFC-4616] SASL plain authentication -- link:https://datatracker.ietf.org/doc/html/rfc8314.html[RFC-7628] SASL for OAUTH -- Implemented for IMAP and SMTP -- Support for OIDC standard only. - -== SMTP - -- link:https://datatracker.ietf.org/doc/html/rfc5321[RFC-5321] SMTP Protocol -- link:https://datatracker.ietf.org/doc/html/rfc974[RFC-974] MAIL ROUTING AND THE DOMAIN SYSTEM -- link:https://www.rfc-editor.org/rfc/rfc3461[RFC-3461] Simple Mail Transfer Protocol (SMTP) Service Extension for Delivery Status Notifications (DSNs) - - Requires extra configuration. -- link:https://datatracker.ietf.org/doc/html/rfc1652[RFC-1652] SMTP Service Extension for 8bit-MIME transport -- link:https://datatracker.ietf.org/doc/html/rfc1830[RFC-1830] SMTP Service Extensions for Transmission of Large and Binary MIME Messages -- link:https://datatracker.ietf.org/doc/html/rfc1869[RFC-1869] SMTP Service Extensions -- link:https://datatracker.ietf.org/doc/html/rfc1870[RFC-1870] SMTP Service Extension for Message Size Declaration -- link:https://datatracker.ietf.org/doc/html/rfc1891[RFC-1891] SMTP Service Extension for Delivery Status Notifications -- link:https://datatracker.ietf.org/doc/html/rfc1893[RFC-1893] Enhanced Mail System Status Codes -- link:https://datatracker.ietf.org/doc/html/rfc2034[RFC-2034] SMTP Service Extension for Returning Enhanced Error Codes -- link:https://datatracker.ietf.org/doc/html/rfc2142[RFC-2142] Mailbox Names For Common Services, Roles And Functions -- link:https://datatracker.ietf.org/doc/html/rfc2197[RFC-2197] SMTP Service Extension for Command Pipelining -- link:https://datatracker.ietf.org/doc/html/rfc2554[RFC-2554] ESMTP Service Extension for Authentication -- link:https://datatracker.ietf.org/doc/html/rfc1893[RFC-1893] Enhanced Mail System Status Codes -- link:https://datatracker.ietf.org/doc/rfc6710/[RFC-6710] SMTP Extension for Message Transfer Priorities - -== LMTP - - - link:https://james.apache.org/server/rfclist/lmtp/rfc2033.txt[RFC-2033] LMTP Local Mail Transfer Protocol - -== IMAP - -The following IMAP specifications are implemented: - - - link:https://datatracker.ietf.org/doc/html/rfc3501.html[RFC-3501] INTERNET MESSAGE ACCESS PROTOCOL - VERSION 4rev1 - - link:https://datatracker.ietf.org/doc/html/rfc2177.html[RFC-2177] IMAP IDLE (mailbox scoped push notifications) - - link:https://www.rfc-editor.org/rfc/rfc9208.html[RFC-9208] IMAP QUOTA Extension - - link:https://datatracker.ietf.org/doc/html/rfc2342.html[RFC-2342] IMAP namespace - - link:https://datatracker.ietf.org/doc/html/rfc2088.html[RFC-2088] IMAP non synchronized literals - - link:https://datatracker.ietf.org/doc/html/rfc4315.html[RFC-4315] IMAP UIDPLUS - - link:https://datatracker.ietf.org/doc/html/rfc5464.html[RFC-5464] IMAP Metadata (annotations on mailboxes) - - link:https://datatracker.ietf.org/doc/html/rfc4551.html[RFC-4551] IMAP Condstore - - link:https://datatracker.ietf.org/doc/html/rfc5162.html[RFC-5162] IMAP QRESYNC (synchronisation semantic for deleted messages) - - We don't store a log of deleted modseq thus clients should rely on known sequences mechanism to optimize exchanges. - - link:https://datatracker.ietf.org/doc/html/rfc4978.html[RFC-4978] IMAP Compress (optional) - - link:https://datatracker.ietf.org/doc/html/rfc5161.html[RFC-5161] IMAP ENABLE - - link:https://datatracker.ietf.org/doc/html/rfc6851.html[RFC-6851] IMAP MOVE command - - link:https://datatracker.ietf.org/doc/html/rfc5182.html[RFC-5182] IMAP Extension for Referencing the Last SEARCH Result - - link:https://datatracker.ietf.org/doc/html/rfc5032.html[RFC-5032] IMAP WITHIN (for relative date search semantic) - - link:https://datatracker.ietf.org/doc/html/rfc4731.html[RFC-4731] IMAP ESEARCH: extentions for IMAP search: new options like min, max, count. - - link:https://datatracker.ietf.org/doc/html/rfc3348.html[RFC-3348] IMAP Child Mailbox Extension - - link:https://www.rfc-editor.org/rfc/rfc8508.html[RFC-8508] IMAP Replace Extension - - link:https://www.rfc-editor.org/rfc/rfc7889.html[RFC-7889] IMAP Extension for APPENDLIMIT - - link:https://www.rfc-editor.org/rfc/rfc8474.html[RFC-8474] IMAP Extension for Object Identifiers - - link:https://datatracker.ietf.org/doc/html/rfc2971.html[RFC-2971] IMAP ID Extension - - link:https://datatracker.ietf.org/doc/html/rfc8438.html[RFC-8438] IMAP Extension for STATUS=SIZE - - link:https://www.rfc-editor.org/rfc/rfc5258.html[RFC-5258] IMAP LIST Command Extensions - - link:https://www.rfc-editor.org/rfc/rfc5819.html[RFC-5819] IMAP4 Extension for Returning STATUS Information in Extended LIST - - link:https://www.rfc-editor.org/rfc/rfc8440.html[RFC-8440] IMAP4 Extension for Returning MYRIGHTS Information in Extended LIST - - link:https://www.rfc-editor.org/rfc/rfc8440.html[RFC-6154] IMAP LIST Extension for Special-Use Mailboxes - - link:https://www.rfc-editor.org/rfc/rfc8514.html[RFC-8514] IMAP SAVEDATE Extension - - link:https://www.rfc-editor.org/rfc/rfc8514.html[RFC-9394] IMAP PARTIAL Extension for Paged SEARCH and FETCH - -Partially implemented specifications: - - - link:https://datatracker.ietf.org/doc/html/rfc4314.html[RFC-4314] IMAP ACL - - ACLs can be created and managed but mailbox not belonging to one account cannot, as of today, be accessed in IMAP. - -== JMAP - - - link:https://datatracker.ietf.org/doc/html/rfc8620[RFC-8620] Json Metadata Application Protocol (JMAP) - - link:https://datatracker.ietf.org/doc/html/rfc8621[RFC-8621] JMAP for emails - - link:https://datatracker.ietf.org/doc/html/rfc8887[RFC-8887] JMAP over websockets - - link:https://datatracker.ietf.org/doc/html/rfc9007.html[RFC-9007] Message Delivery Notifications with JMAP. - - link:https://datatracker.ietf.org/doc/html/rfc8030.html[RFC-8030] Web PUSH: JMAP enable sending push notifications through a push gateway. - -https://jmap.io/[JMAP] is intended to be a new standard for email clients to connect to mail -stores. It therefore intends to primarily replace IMAP + SMTP submission. It is also designed to be more -generic. It does not replace MTA-to-MTA SMTP transmission. - -The link:https://github.com/apache/james-project/tree/master/server/protocols/jmap-rfc-8621/doc/specs/spec[annotated documentation] -presents the limits of the JMAP RFC-8621 implementation part of the Apache James project. - -Some methods / types are not yet implemented, some implementations are naive, and the PUSH is not supported yet. - -Users are invited to read these limitations before using actively the JMAP RFC-8621 implementation, and should ensure their -client applications only uses supported operations. - -== POP3 - - - link:https://www.ietf.org/rfc/rfc1939.txt[RFC-1939] Post Office Protocol - Version 3 - -== ManageSieve - -Support for manageSieve is experimental. - - - link:https://datatracker.ietf.org/doc/html/rfc5804[RFC-5804] A Protocol for Remotely Managing Sieve Scripts - -== Sieve - - - link:https://datatracker.ietf.org/doc/html/rfc5228[RFC-5228] Sieve: An Email Filtering Language - - link:https://datatracker.ietf.org/doc/html/rfc5173[RFC-5173] Sieve Email Filtering: Body Extension - - link:https://datatracker.ietf.org/doc/html/rfc5230[RFC-5230] Sieve Email Filtering: Vacation Extension - +:server-name: Distributed James Server +include::partial$architecture/implemented-standards.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/architecture/index.adoc b/docs/modules/servers/pages/distributed/architecture/index.adoc index ba5c25541a5..a36103cc8c8 100644 --- a/docs/modules/servers/pages/distributed/architecture/index.adoc +++ b/docs/modules/servers/pages/distributed/architecture/index.adoc @@ -1,308 +1,13 @@ = Distributed James Server — Architecture :navtitle: Architecture -This sections presents the Distributed Server architecture. - -== Storage - -In order to deliver its promises, the Distributed Server leverages the following storage strategies: - -image::storage.png[Storage responsibilities for the Distributed Server] - - * *Cassandra* is used for metadata storage. Cassandra is efficient for a very high workload of small queries following -a known pattern. - * The *blob store* storage interface is responsible of storing potentially large binary data. For instance - email bodies, headers or attachments. Different technologies can be used: *Cassandra*, or S3 compatible *Object Storage* -(S3 or Swift). - * *OpenSearch* component empowers full text search on emails. It also enables querying data with unplanned access -patterns. OpenSearch throughput do not however match the one of Cassandra thus its use is avoided upon regular workloads. - * *RabbitMQ* enables James nodes of a same cluster to collaborate together. It is used to implement connected protocols, -notification patterns as well as distributed resilient work queues and mail queue. - * *Tika* (optional) enables text extraction from attachments, thus improving full text search results. - * *link:https://spamassassin.apache.org/[SpamAssassin] or link:https://rspamd.com/[Rspamd]* (optional) can be used for Spam detection and user feedback is supported. - -xref:distributed/architecture/consistency-model.adoc[This page] further details Distributed James consistency model. - -== Protocols - -The following protocols are supported and can be used to interact with the Distributed Server: - -* *SMTP* -* *IMAP* -* xref:distributed/operate/webadmin.adoc[WebAdmin] REST Administration API -* *LMTP* -* *POP3* - -The following protocols should be considered experimental: - -* *JMAP* (RFC-8620 &RFC-8621 specifications and known limitations of the James implementation are defined link:https://github.com/apache/james-project/tree/master/server/protocols/jmap-rfc-8621/doc[here]) -* *ManagedSieve* - -Read more on xref:distributed/architecture/implemented-standards.adoc[implemented standards]. - -== Topology - -While it is perfectly possible to deploy homogeneous James instances, with the same configuration and thus the same -protocols and the same responsibilities one might want to investigate in -xref:distributed/architecture/specialized-instances.adoc['Specialized instances']. - -== Components - -This section presents the various components of the Distributed server, providing context about -their interactions, and about their implementations. - -=== High level view - -Here is a high level view of the various server components and their interactions: - -image::server-components.png[Server components mobilized for SMTP & IMAP] - - - The SMTP protocol receives a mail, and enqueue it on the MailQueue - - The MailetContainer will start processing the mail Asynchronously and will take business decisions like storing the - email locally in a user mailbox. The behaviour of the MailetContainer is highly customizable thanks to the Mailets and - the Matcher composibility. - - The Mailbox component is responsible of storing a user's mails. - - The user can use the IMAP or the JMAP protocol to retrieve and read his mails. - -These components will be presented more in depth below. - -=== Mail processing - -Mail processing allows to take asynchronously business decisions on -received emails. - -Here are its components: - -* The `spooler` takes mail out of the mailQueue and executes mail -processing within the `mailet container`. -* The `mailet container` synchronously executes the user defined logic. -This `logic' is written through the use of `mailet`, `matcher` and -`processor`. -* A `mailet` represents an action: mail modification, envelop -modification, a side effect, or stop processing. -* A `matcher` represents a condition to execute a mailet. -* A `processor` is a flow of pair of `matcher` and `mailet` executed -sequentially. The `ToProcessor` mailet is a `goto` instruction to start -executing another `processor` -* A `mail repository` allows storage of a mail as part of its -processing. Standard configuration relies on the following mail -repository: -** `cassandra://var/mail/error/` : unexpected errors that occurred -during mail processing. Emails impacted by performance related -exceptions, or logical bug within James code are typically stored here. -These mails could be reprocessed once the cause of the error is fixed. -The `Mail.error` field can help diagnose the issue. Correlation with -logs can be achieved via the use of the `Mail.name` field. -** `cassandra://var/mail/address-error/` : mail addressed to a -non-existing recipient of a handled local domain. These mails could be -reprocessed once the user is created, for instance. -** `cassandra://var/mail/relay-denied/` : mail for whom relay was -denied: missing authentication can, for instance, be a cause. In -addition to prevent disasters upon miss configuration, an email review -of this mail repository can help refine a host spammer blacklist. -** `cassandra://var/mail/rrt-error/` : runtime error upon Recipient -Rewriting occurred. This is typically due to a loop. - -=== Mail Queue - -An email queue is a mandatory component of SMTP servers. It is a system -that creates a queue of emails that are waiting to be processed for -delivery. Email queuing is a form of Message Queuing – an asynchronous -service-to-service communication. A message queue is meant to decouple a -producing process from a consuming one. An email queue decouples email -reception from email processing. It allows them to communicate without -being connected. As such, the queued emails wait for processing until -the recipient is available to receive them. As James is an Email Server, -it also supports mail queue as well. - -==== Why Mail Queue is necessary - -You might often need to check mail queue to make sure all emails are -delivered properly. At first, you need to know why email queues get -clogged. Here are the two core reasons for that: - -* Exceeded volume of emails - -Some mailbox providers enforce email rate limits on IP addresses. The -limits are based on the sender reputation. If you exceeded this rate and -queued too many emails, the delivery speed will decrease. - -* Spam-related issues - -Another common reason is that your email has been busted by spam -filters. The filters will let the emails gradually pass to analyze how -the rest of the recipients react to the message. If there is slow -progress, it’s okay. Your email campaign is being observed and assessed. -If it’s stuck, there could be different reasons including the blockage -of your IP address. - -==== Why combining Cassandra, RabbitMQ and Object storage for MailQueue - -* RabbitMQ ensures the messaging function, and avoids polling. -* Cassandra enables administrative operations such as browsing, deleting -using a time series which might require fine performance tuning (see -http://cassandra.apache.org/doc/latest/operating/index.html[Operating -Casandra documentation]). -* Object Storage stores potentially large binary payload. - -However the current design do not implement delays. Delays allow to -define the time a mail have to be living in the mailqueue before being -dequeued and is used for example for exponential wait delays upon remote -delivery retries, or - -=== Mailbox - -Storage for emails belonging for users. - -Metadata are stored in Cassandra while headers, bodies and attachments are stored -within the xref:#_blobstore[BlobStore]. - -==== Search index - -Emails are indexed asynchronously in OpenSearch via the xref:#_event_bus[EventBus] -in order to empower advanced and fast email full text search. - -Text extraction can be set up using link:https://tika.apache.org/[Tika], allowing -to extract the text from attachment, allowing to search your emails based on the attachment -textual content. In such case, the OpenSearch indexer will call a Tika server prior -indexing. - -==== Quotas - -Current Quotas of users are hold in a Cassandra projection. Limitations can be defined via -user, domain or globally. - -==== Event Bus - -Distributed James relies on an event bus system to enrich mailbox capabilities. Each -operation performed on the mailbox will trigger related events, that can -be processed asynchronously by potentially any James node on a -distributed system. - -Many different kind of events can be triggered during a mailbox -operation, such as: - -* `MailboxEvent`: event related to an operation regarding a mailbox: -** `MailboxDeletion`: a mailbox has been deleted -** `MailboxAdded`: a mailbox has been added -** `MailboxRenamed`: a mailbox has been renamed -** `MailboxACLUpdated`: a mailbox got its rights and permissions updated -* `MessageEvent`: event related to an operation regarding a message: -** `Added`: messages have been added to a mailbox -** `Expunged`: messages have been expunged from a mailbox -** `FlagsUpdated`: messages had their flags updated -** `MessageMoveEvent`: messages have been moved from a mailbox to an -other -* `QuotaUsageUpdatedEvent`: event related to quota update - -Mailbox listeners can register themselves on this event bus system to be -called when an event is fired, allowing to do different kind of extra -operations on the system, like: - -* Current quota calculation -* Message indexation with OpenSearch -* Mailbox annotations cleanup -* Ham/spam reporting to Spam filtering system -* … - -==== Deleted Messages Vault - -Deleted Messages Vault is an interesting feature that will help James -users have a chance to: - -* retain users deleted messages for some time. -* restore & export deleted messages by various criteria. -* permanently delete some retained messages. - -If the Deleted Messages Vault is enabled when users delete their mails, -and by that we mean when they try to definitely delete them by emptying -the trash, James will retain these mails into the Deleted Messages -Vault, before an email or a mailbox is going to be deleted. And only -administrators can interact with this component via -wref:webadmin.adoc#_deleted-messages-vault[WebAdmin] REST APIs]. - -However, mails are not retained forever as you have to configure a -retention period before using it (with one-year retention by default if -not defined). It’s also possible to permanently delete a mail if needed. - -=== Data - -Storage for domains and users. - -Domains are persisted in Cassandra. - -Users can be managed in Cassandra, or via a LDAP (read only). - -=== Recipient rewrite tables - -Storage of Recipients Rewriting rules, in Cassandra. - -==== Mapping types - -James allows using various mapping types for better expressing the intent of your address rewriting logic: - -* *Domain mapping*: Rewrites the domain of mail addresses. Use it for technical purposes, user will not -be allowed to use the source in their FROM address headers. Domain mappings can be managed via the CLI and -added via xref:distributed/operate/webadmin.adoc#_domain_mappings[WebAdmin] -* *Domain aliases*: Rewrites the domain of mail addresses. Express the idea that both domains can be used -inter-changeably. User will be allowed to use the source in their FROM address headers. Domain aliases can -be managed via xref:distributed/operate/webadmin.adoc#_get_the_list_of_aliases_for_a_domain[WebAdmin] -* *Forwards*: Replaces the source address by another one. Vehicles the intent of forwarding incoming mails -to other users. Listing the forward source in the forward destinations keeps a local copy. User will not be -allowed to use the source in their FROM address headers. Forward can -be managed via xref:distributed/operate/webadmin.adoc#_address_forwards[WebAdmin] -* *Groups*: Replaces the source address by another one. Vehicles the intent of a group registration: group -address will be swapped by group member addresses (Feature poor mailing list). User will not be -allowed to use the source in their FROM address headers. Groups can -be managed via xref:distributed/operate/webadmin.adoc#_address_group[WebAdmin] -* *Aliases*: Replaces the source address by another one. Represents user owned mail address, with which -he can interact as if it was his main mail address. User will be allowed to use the source in their FROM -address headers. Aliases can be managed via xref:distributed/operate/webadmin.adoc#_address_aliases[WebAdmin] -* *Address mappings*: Replaces the source address by another one. Use for technical purposes, this mapping type do -not hold specific intent. Prefer using one of the above mapping types... User will not be allowed to use the source -in their FROM address headers. Address mappings can be managed via the CLI or via -xref:distributed/operate/webadmin.adoc#_address_mappings[WebAdmin] -* *Regex mappings*: Applies the regex on the supplied address. User will not be allowed to use the source -in their FROM address headers. Regex mappings can be managed via the CLI or via -xref:distributed/operate/webadmin.adoc#_regex_mapping[WebAdmin] -* *Error*: Throws an error upon processing. User will not be allowed to use the source -in their FROM address headers. Errors can be managed via the CLI - -=== BlobStore - -Stores potentially large binary data. - -Mailbox component, Mail Queue component, Deleted Message Vault -component relies on it. - -Supported backends include S3 compatible ObjectStorage (link:https://wiki.openstack.org/wiki/Swift[Swift], S3 API). - -Encryption can be configured on top of ObjectStorage. - -Blobs can currently be deduplicated in order to reduce storage space. This means that two blobs with -the same content will be stored one once. - -The downside is that deletion is more complicated, and a garbage collection needs to be run. A first implementation -based on bloom filters can be used and triggered using the WebAdmin REST API. - -=== Task Manager - -Allows to control and schedule long running tasks run by other -components. Among other it enables scheduling, progress monitoring, -cancellation of long running tasks. - -Distributed James leverage a task manager using Event Sourcing and RabbitMQ for messaging. - -=== Event sourcing - -link:https://martinfowler.com/eaaDev/EventSourcing.html[Event sourcing] implementation -for the Distributed server stores events in Cassandra. It enables components -to rely on event sourcing technics for taking decisions. - -A short list of usage are: - -* Data leak prevention storage -* JMAP filtering rules storage -* Validation of the MailQueue configuration -* Sending email warnings to user close to their quota -* Implementation of the TaskManager +:backend-name: cassandra +:server-name: Distributed James Server +:backend-storage-introduce: Cassandra is used for metadata storage. Cassandra is efficient for a very high workload of small queries following a known pattern. +:storage-picture-file-name: storage_james_distributed.png +:mailet-repository-path-prefix: cassandra +:xref-base: distributed +:mailqueue-combined-extend-backend: , Cassandra +:mailqueue-combined-extend: servers:distributed/architecture/mailqueue_combined_extend.adoc + +include::partial$architecture/index.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/architecture/mailqueue_combined_extend.adoc b/docs/modules/servers/pages/distributed/architecture/mailqueue_combined_extend.adoc new file mode 100644 index 00000000000..2e381417e5b --- /dev/null +++ b/docs/modules/servers/pages/distributed/architecture/mailqueue_combined_extend.adoc @@ -0,0 +1,4 @@ +* Cassandra enables administrative operations such as browsing, deleting +using a time series which might require fine performance tuning (see +http://cassandra.apache.org/doc/latest/operating/index.html[Operating +Cassandra documentation]). \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/architecture/specialized-instances.adoc b/docs/modules/servers/pages/distributed/architecture/specialized-instances.adoc index 5c7365da4ba..03a412f0e2f 100644 --- a/docs/modules/servers/pages/distributed/architecture/specialized-instances.adoc +++ b/docs/modules/servers/pages/distributed/architecture/specialized-instances.adoc @@ -1,39 +1,7 @@ = Distributed James Server — Specialized instances :navtitle: Specialized instances -While it is perfectly possible to deploy homogeneous James instances, with the same configuration and thus the same -protocols and the same responsibilities one might want to investigate in 'Specialized instances'. +:server-name: Distributed James Server +:specialized-instances-file-name: specialized-instances-distributed.png -This deployment topology consists of Distributed James servers with heterogeneous configurations on top of shared -data-bases. Groups of James servers will thus handle various protocols and have different responsibilities. - -This approach limits cascading failures across protocols and services. Think of *OutOfMemoryErrors*, Cassandra driver -queue overuse, CPUs starvation, etc. - -However, we can't speak of micro-services here: all James instances runs the same code, James is still a monolith, and -databases need to be shared across instances. - -image::specialized-instances.png[Example of Specialized instances topology] - -We speak of: - - - **Front-line servers** serves protocols. James enables to easily turn protocols on and off. Typically, each protocol would - be isolated in its own group of James instances: james-imap, james-jmap, james-smtp, james-webadmin, etc... Refer to - protocols configuration files to learn more. - - - **Back-office servers** handles other services like: - - - Mail processing. - - Remote delivery. - - Event processing. - - Task execution. - -Front-line servers will likely not handle back office responsibilities (but be sure to have back-office servers that do!). - - - xref:distributed/configure/mailetcontainer.adoc[Mail processing can be switched off]. - - xref:distributed/configure/listeners.adoc[Mailbox event processing can be switched off]. - - xref:distributed/configure/rabbitmq.adoc[Task execution can be switched off]. - - Remote Delivery service is not started if the RemoteDelivery mailet is not positioned in mailetcontainer.xml. - -Of course, the above instances can be collocated at will, to reach some intermediate deployments with fewer -instances to mitigate costs. \ No newline at end of file +include::partial$architecture/specialized-instances.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/partials/architecture/consistency-model.adoc b/docs/modules/servers/partials/architecture/consistency-model.adoc new file mode 100644 index 00000000000..c104535b0a0 --- /dev/null +++ b/docs/modules/servers/partials/architecture/consistency-model.adoc @@ -0,0 +1,65 @@ +This page presents the consistency model used by the {server-name} and +points to the tools built around it. + +== Data Replication + +The {server-name} relies on different storage technologies, all having their own +consistency models. + +These data stores replicate data in order to enforce some level of availability. We call +this process replication. By consistency, we mean the ability for all replica to hold the +same data. By availability, we mean the ability for a replica to answer a request. + +In distributed systems, link:https://en.wikipedia.org/wiki/CAP_theorem[according to the CAP theorem], +as we will necessarily encounter network partitions, then trade-offs need to be made between +consistency and availability. + +This section details this trade-off for data stores used by the {server-name}. + +=== OpenSearch consistency model + +OpenSearch relies on link:https://opensearch.org/docs/latest/tuning-your-cluster/[strong consistency] +with home-grown algorithm. + +The 2.x release line, that the distributed server is using, is known to provide faster recovery. + +Be aware that data is asynchronously indexed in OpenSearch, changes will be eventually visible. + +=== RabbitMQ consistency model + +The {server-name} relies out of the box on a single RabbitMQ server, thus consistency concerns +are not (yet) applicable. Availability concerns are applicable. + +include::{data_replication_extend}[] + +== Consistency across data stores + +The {server-name} leverages several data stores: + + - {backend-name} is used for metadata storage + - OpenSearch for search + - Object Storage for large object storage + +Thus the {server-name} also offers mechanisms to enforce consistency across data stores. + +=== Write path organisation + +The primary data stores are composed of {backend-name} for metadata and Object storage for binary data. + +To ensure the data referenced in {backend-name} is pointing to a valid object in the object store, we write +the object store payload first, then write the corresponding metadata in {backend-name}. + +Such a procedure avoids metadata pointing to un existing blobs, however might lead to some unreferenced +blobs. + +=== {backend-name-cap} ↔ OpenSearch + +After being written to the primary stores (namely {backend-name} & Object Storage), email content is +asynchronously indexed into OpenSearch. + +This process, called the EventBus, which retries temporary errors, and stores transient errors for +later admin-triggered retries is described further xref:{xref-base}/operate/guide.adoc#_mailbox_event_bus[here]. +His role is to spread load and limit inconsistencies. + +Furthermore, some xref:{xref-base}/operate/guide.adoc#_usual_troubleshooting_procedures[re-indexing tasks] +enables to re-synchronise OpenSearch content with the primary data stores diff --git a/docs/modules/servers/partials/architecture/implemented-standards.adoc b/docs/modules/servers/partials/architecture/implemented-standards.adoc new file mode 100644 index 00000000000..707c1bcc7aa --- /dev/null +++ b/docs/modules/servers/partials/architecture/implemented-standards.adoc @@ -0,0 +1,117 @@ +This page details standards implemented by the {server-name}. + +== Message formats + + - link:https://datatracker.ietf.org/doc/html/rfc5322[RFC-5322] Internet Message Format (MIME) + - link:https://datatracker.ietf.org/doc/html/rfc2045[RFC-2045] Format of Internet Message Bodies + - link:https://datatracker.ietf.org/doc/html/rfc3464[RFC-3464] An Extensible Message Format for Delivery Status Notifications + - James allow emmit DSNs from the mailet container. + - link:https://datatracker.ietf.org/doc/html/rfc8098[RFC-8098] Message Disposition Notification + +== TLS & authentication + +- link:https://datatracker.ietf.org/doc/html/rfc2595.html[RFC-2595] TLS for IMAP, POP3, SMTP (StartTLS) +- link:https://datatracker.ietf.org/doc/html/rfc8314.html[RFC-8314] Implicit TLS +- link:https://www.rfc-editor.org/rfc/rfc4959.html[RFC-4959] SASL IR: Initial client response +- link:https://datatracker.ietf.org/doc/html/rfc4616[RFC-4616] SASL plain authentication +- link:https://datatracker.ietf.org/doc/html/rfc8314.html[RFC-7628] SASL for OAUTH +- Implemented for IMAP and SMTP +- Support for OIDC standard only. + +== SMTP + +- link:https://datatracker.ietf.org/doc/html/rfc5321[RFC-5321] SMTP Protocol +- link:https://datatracker.ietf.org/doc/html/rfc974[RFC-974] MAIL ROUTING AND THE DOMAIN SYSTEM +- link:https://www.rfc-editor.org/rfc/rfc3461[RFC-3461] Simple Mail Transfer Protocol (SMTP) Service Extension for Delivery Status Notifications (DSNs) + - Requires extra configuration. +- link:https://datatracker.ietf.org/doc/html/rfc1652[RFC-1652] SMTP Service Extension for 8bit-MIME transport +- link:https://datatracker.ietf.org/doc/html/rfc1830[RFC-1830] SMTP Service Extensions for Transmission of Large and Binary MIME Messages +- link:https://datatracker.ietf.org/doc/html/rfc1869[RFC-1869] SMTP Service Extensions +- link:https://datatracker.ietf.org/doc/html/rfc1870[RFC-1870] SMTP Service Extension for Message Size Declaration +- link:https://datatracker.ietf.org/doc/html/rfc1891[RFC-1891] SMTP Service Extension for Delivery Status Notifications +- link:https://datatracker.ietf.org/doc/html/rfc1893[RFC-1893] Enhanced Mail System Status Codes +- link:https://datatracker.ietf.org/doc/html/rfc2034[RFC-2034] SMTP Service Extension for Returning Enhanced Error Codes +- link:https://datatracker.ietf.org/doc/html/rfc2142[RFC-2142] Mailbox Names For Common Services, Roles And Functions +- link:https://datatracker.ietf.org/doc/html/rfc2197[RFC-2197] SMTP Service Extension for Command Pipelining +- link:https://datatracker.ietf.org/doc/html/rfc2554[RFC-2554] ESMTP Service Extension for Authentication +- link:https://datatracker.ietf.org/doc/html/rfc1893[RFC-1893] Enhanced Mail System Status Codes + +== LMTP + + - link:https://james.apache.org/server/rfclist/lmtp/rfc2033.txt[RFC-2033] LMTP Local Mail Transfer Protocol + +== IMAP + +The following IMAP specifications are implemented: + + - link:https://datatracker.ietf.org/doc/html/rfc3501.html[RFC-3501] INTERNET MESSAGE ACCESS PROTOCOL - VERSION 4rev1 + - link:https://datatracker.ietf.org/doc/html/rfc2177.html[RFC-2177] IMAP IDLE (mailbox scoped push notifications) + - link:https://www.rfc-editor.org/rfc/rfc9208.html[RFC-9208] IMAP QUOTA Extension + - link:https://datatracker.ietf.org/doc/html/rfc2342.html[RFC-2342] IMAP namespace + - link:https://datatracker.ietf.org/doc/html/rfc2088.html[RFC-2088] IMAP non synchronized literals + - link:https://datatracker.ietf.org/doc/html/rfc4315.html[RFC-4315] IMAP UIDPLUS + - link:https://datatracker.ietf.org/doc/html/rfc5464.html[RFC-5464] IMAP Metadata (annotations on mailboxes) + - link:https://datatracker.ietf.org/doc/html/rfc4551.html[RFC-4551] IMAP Condstore + - link:https://datatracker.ietf.org/doc/html/rfc5162.html[RFC-5162] IMAP QRESYNC (synchronisation semantic for deleted messages) + - We don't store a log of deleted modseq thus clients should rely on known sequences mechanism to optimize exchanges. + - link:https://datatracker.ietf.org/doc/html/rfc4978.html[RFC-4978] IMAP Compress (optional) + - link:https://datatracker.ietf.org/doc/html/rfc5161.html[RFC-5161] IMAP ENABLE + - link:https://datatracker.ietf.org/doc/html/rfc6851.html[RFC-6851] IMAP MOVE command + - link:https://datatracker.ietf.org/doc/html/rfc5182.html[RFC-5182] IMAP Extension for Referencing the Last SEARCH Result + - link:https://datatracker.ietf.org/doc/html/rfc5032.html[RFC-5032] IMAP WITHIN (for relative date search semantic) + - link:https://datatracker.ietf.org/doc/html/rfc4731.html[RFC-4731] IMAP ESEARCH: extentions for IMAP search: new options like min, max, count. + - link:https://datatracker.ietf.org/doc/html/rfc3348.html[RFC-3348] IMAP Child Mailbox Extension + - link:https://www.rfc-editor.org/rfc/rfc8508.html[RFC-8508] IMAP Replace Extension + - link:https://www.rfc-editor.org/rfc/rfc7889.html[RFC-7889] IMAP Extension for APPENDLIMIT + - link:https://www.rfc-editor.org/rfc/rfc8474.html[RFC-8474] IMAP Extension for Object Identifiers + - link:https://datatracker.ietf.org/doc/html/rfc2971.html[RFC-2971] IMAP ID Extension + - link:https://datatracker.ietf.org/doc/html/rfc8438.html[RFC-8438] IMAP Extension for STATUS=SIZE + - link:https://www.rfc-editor.org/rfc/rfc5258.html[RFC-5258] IMAP LIST Command Extensions + - link:https://www.rfc-editor.org/rfc/rfc5819.html[RFC-5819] IMAP4 Extension for Returning STATUS Information in Extended LIST + - link:https://www.rfc-editor.org/rfc/rfc8440.html[RFC-8440] IMAP4 Extension for Returning MYRIGHTS Information in Extended LIST + - link:https://www.rfc-editor.org/rfc/rfc8440.html[RFC-6154] IMAP LIST Extension for Special-Use Mailboxes + - link:https://www.rfc-editor.org/rfc/rfc8514.html[RFC-8514] IMAP SAVEDATE Extension + - link:https://www.rfc-editor.org/rfc/rfc8514.html[RFC-9394] IMAP PARTIAL Extension for Paged SEARCH and FETCH + +Partially implemented specifications: + + - link:https://datatracker.ietf.org/doc/html/rfc4314.html[RFC-4314] IMAP ACL + - ACLs can be created and managed but mailbox not belonging to one account cannot, as of today, be accessed in IMAP. + +== JMAP + + - link:https://datatracker.ietf.org/doc/html/rfc8620[RFC-8620] Json Metadata Application Protocol (JMAP) + - link:https://datatracker.ietf.org/doc/html/rfc8621[RFC-8621] JMAP for emails + - link:https://datatracker.ietf.org/doc/html/rfc8887[RFC-8887] JMAP over websockets + - link:https://datatracker.ietf.org/doc/html/rfc9007.html[RFC-9007] Message Delivery Notifications with JMAP. + - link:https://datatracker.ietf.org/doc/html/rfc8030.html[RFC-8030] Web PUSH: JMAP enable sending push notifications through a push gateway. + +https://jmap.io/[JMAP] is intended to be a new standard for email clients to connect to mail +stores. It therefore intends to primarily replace IMAP + SMTP submission. It is also designed to be more +generic. It does not replace MTA-to-MTA SMTP transmission. + +The link:https://github.com/apache/james-project/tree/master/server/protocols/jmap-rfc-8621/doc/specs/spec[annotated documentation] +presents the limits of the JMAP RFC-8621 implementation part of the Apache James project. + +Some methods / types are not yet implemented, some implementations are naive, and the PUSH is not supported yet. + +Users are invited to read these limitations before using actively the JMAP RFC-8621 implementation, and should ensure their +client applications only uses supported operations. + +== POP3 + + - link:https://www.ietf.org/rfc/rfc1939.txt[RFC-1939] Post Office Protocol - Version 3 + +== ManageSieve + +Support for manageSieve is experimental. + + - link:https://datatracker.ietf.org/doc/html/rfc5804[RFC-5804] A Protocol for Remotely Managing Sieve Scripts + +== Sieve + + - link:https://datatracker.ietf.org/doc/html/rfc5228[RFC-5228] Sieve: An Email Filtering Language + - link:https://datatracker.ietf.org/doc/html/rfc5173[RFC-5173] Sieve Email Filtering: Body Extension + - link:https://datatracker.ietf.org/doc/html/rfc5230[RFC-5230] Sieve Email Filtering: Vacation Extension + + diff --git a/docs/modules/servers/partials/architecture/index.adoc b/docs/modules/servers/partials/architecture/index.adoc new file mode 100644 index 00000000000..449a31c99e3 --- /dev/null +++ b/docs/modules/servers/partials/architecture/index.adoc @@ -0,0 +1,302 @@ +This sections presents the {server-name} architecture. + +== Storage + +In order to deliver its promises, the {server-name} leverages the following storage strategies: + +image::{storage-picture-file-name}[Storage responsibilities for the {server-name}] + + * {backend-storage-introduce} + * The *blob store* storage interface is responsible for storing potentially large binary data. For instance + email bodies, headers or attachments. Different technologies can be used: *{backend-name}*, or S3 compatible *Object Storage* +(S3 or Swift). + * *OpenSearch* component empowers full text search on emails. It also enables querying data with unplanned access +patterns. OpenSearch throughput do not however match the one of {backend-name} thus its use is avoided upon regular workloads. + * *RabbitMQ* enables James nodes of a same cluster to collaborate together. It is used to implement connected protocols, +notification patterns as well as distributed resilient work queues and mail queue. + * *Tika* (optional) enables text extraction from attachments, thus improving full text search results. + * *link:https://spamassassin.apache.org/[SpamAssassin] or link:https://rspamd.com/[Rspamd]* (optional) can be used for Spam detection and user feedback is supported. + +xref:{xref-base}/architecture/consistency-model.adoc[This page] further details {server-name} consistency model. + +== Protocols + +The following protocols are supported and can be used to interact with the {server-name}: + +* *SMTP* +* *IMAP* +* xref:{xref-base}/operate/webadmin.adoc[WebAdmin] REST Administration API +* *LMTP* +* *POP3* + +The following protocols should be considered experimental: + +* *JMAP* (RFC-8620 &RFC-8621 specifications and known limitations of the James implementation are defined link:https://github.com/apache/james-project/tree/master/server/protocols/jmap-rfc-8621/doc[here]) +* *ManagedSieve* + +Read more on xref:{xref-base}/architecture/implemented-standards.adoc[implemented standards]. + +== Topology + +While it is perfectly possible to deploy homogeneous James instances, with the same configuration and thus the same +protocols and the same responsibilities one might want to investigate in +xref:{xref-base}/architecture/specialized-instances.adoc['Specialized instances']. + +== Components + +This section presents the various components of the {server-name}, providing context about +their interactions, and about their implementations. + +=== High level view + +Here is a high level view of the various server components and their interactions: + +image::server-components.png[Server components mobilized for SMTP & IMAP] + + - The SMTP protocol receives a mail, and enqueue it on the MailQueue + - The MailetContainer will start processing the mail Asynchronously and will take business decisions like storing the + email locally in a user mailbox. The behaviour of the MailetContainer is highly customizable thanks to the Mailets and + the Matcher composibility. + - The Mailbox component is responsible of storing a user's mails. + - The user can use the IMAP or the JMAP protocol to retrieve and read his mails. + +These components will be presented more in depth below. + +=== Mail processing + +Mail processing allows to take asynchronously business decisions on +received emails. + +Here are its components: + +* The `spooler` takes mail out of the mailQueue and executes mail +processing within the `mailet container`. +* The `mailet container` synchronously executes the user defined logic. +This `logic' is written through the use of `mailet`, `matcher` and +`processor`. +* A `mailet` represents an action: mail modification, envelop +modification, a side effect, or stop processing. +* A `matcher` represents a condition to execute a mailet. +* A `processor` is a flow of pair of `matcher` and `mailet` executed +sequentially. The `ToProcessor` mailet is a `goto` instruction to start +executing another `processor` +* A `mail repository` allows storage of a mail as part of its +processing. Standard configuration relies on the following mail +repository: +** `{mailet-repository-path-prefix}://var/mail/error/` : unexpected errors that occurred +during mail processing. Emails impacted by performance related +exceptions, or logical bug within James code are typically stored here. +These mails could be reprocessed once the cause of the error is fixed. +The `Mail.error` field can help diagnose the issue. Correlation with +logs can be achieved via the use of the `Mail.name` field. +** `{mailet-repository-path-prefix}://var/mail/address-error/` : mail addressed to a +non-existing recipient of a handled local domain. These mails could be +reprocessed once the user is created, for instance. +** `{mailet-repository-path-prefix}://var/mail/relay-denied/` : mail for whom relay was +denied: missing authentication can, for instance, be a cause. In +addition to prevent disasters upon miss configuration, an email review +of this mail repository can help refine a host spammer blacklist. +** `{mailet-repository-path-prefix}://var/mail/rrt-error/` : runtime error upon Recipient +Rewriting occurred. This is typically due to a loop. + +=== Mail Queue + +An email queue is a mandatory component of SMTP servers. It is a system +that creates a queue of emails that are waiting to be processed for +delivery. Email queuing is a form of Message Queuing – an asynchronous +service-to-service communication. A message queue is meant to decouple a +producing process from a consuming one. An email queue decouples email +reception from email processing. It allows them to communicate without +being connected. As such, the queued emails wait for processing until +the recipient is available to receive them. As James is an Email Server, +it also supports mail queue as well. + +==== Why Mail Queue is necessary + +You might often need to check mail queue to make sure all emails are +delivered properly. At first, you need to know why email queues get +clogged. Here are the two core reasons for that: + +* Exceeded volume of emails + +Some mailbox providers enforce email rate limits on IP addresses. The +limits are based on the sender reputation. If you exceeded this rate and +queued too many emails, the delivery speed will decrease. + +* Spam-related issues + +Another common reason is that your email has been busted by spam +filters. The filters will let the emails gradually pass to analyze how +the rest of the recipients react to the message. If there is slow +progress, it’s okay. Your email campaign is being observed and assessed. +If it’s stuck, there could be different reasons including the blockage +of your IP address. + +==== Why combining RabbitMQ, Object storage {mailqueue-combined-extend-backend} for MailQueue + +* RabbitMQ ensures the messaging function, and avoids polling. +* Object Storage stores potentially large binary payload. + +include::{mailqueue-combined-extend}[] + +However, the current design do not implement delays. Delays allow to +define the time a mail have to be living in the mail queue before being +dequeued and is used for example for exponential wait delays upon remote +delivery retries, or + +=== Mailbox + +Storage for emails belonging for users. + +Metadata are stored in {backend-name} while headers, bodies and attachments are stored +within the xref:#_blobstore[BlobStore]. + +==== Search index + +Emails are indexed asynchronously in OpenSearch via the xref:#_event_bus[EventBus] +in order to empower advanced and fast email full text search. + +Text extraction can be set up using link:https://tika.apache.org/[Tika], allowing +to extract the text from attachment, allowing to search your emails based on the attachment +textual content. In such case, the OpenSearch indexer will call a Tika server prior +indexing. + +==== Quotas + +Current Quotas of users are hold in a {backend-name} projection. Limitations can be defined via +user, domain or globally. + +==== Event Bus + +{server-name} relies on an event bus system to enrich mailbox capabilities. Each +operation performed on the mailbox will trigger related events, that can +be processed asynchronously by potentially any James node on a +distributed system. + +Many different kind of events can be triggered during a mailbox +operation, such as: + +* `MailboxEvent`: event related to an operation regarding a mailbox: +** `MailboxDeletion`: a mailbox has been deleted +** `MailboxAdded`: a mailbox has been added +** `MailboxRenamed`: a mailbox has been renamed +** `MailboxACLUpdated`: a mailbox got its rights and permissions updated +* `MessageEvent`: event related to an operation regarding a message: +** `Added`: messages have been added to a mailbox +** `Expunged`: messages have been expunged from a mailbox +** `FlagsUpdated`: messages had their flags updated +** `MessageMoveEvent`: messages have been moved from a mailbox to another +* `QuotaUsageUpdatedEvent`: event related to quota update + +Mailbox listeners can register themselves on this event bus system to be +called when an event is fired, allowing to do different kind of extra +operations on the system, like: + +* Current quota calculation +* Message indexation with OpenSearch +* Mailbox annotations cleanup +* Ham/spam reporting to Spam filtering system +* … + +==== Deleted Messages Vault + +Deleted Messages Vault is an interesting feature that will help James +users have a chance to: + +* retain users deleted messages for some time. +* restore & export deleted messages by various criteria. +* permanently delete some retained messages. + +If the Deleted Messages Vault is enabled when users delete their mails, +and by that we mean when they try to definitely delete them by emptying +the trash, James will retain these mails into the Deleted Messages +Vault, before an email or a mailbox is going to be deleted. And only +administrators can interact with this component via +xref:{xref-base}/operate/webadmin.adoc#_deleted_messages_vault[WebAdmin] REST APIs. + +However, mails are not retained forever as you have to configure a +retention period before using it (with one-year retention by default if +not defined). It’s also possible to permanently delete a mail if needed. + +=== Data + +Storage for domains and users. + +Domains are persisted in {backend-name}. + +Users can be managed in {backend-name}, or via a LDAP (read only). + +=== Recipient rewrite tables + +Storage of Recipients Rewriting rules, in {backend-name}. + +==== Mapping types + +James allows using various mapping types for better expressing the intent of your address rewriting logic: + +* *Domain mapping*: Rewrites the domain of mail addresses. Use it for technical purposes, user will not +be allowed to use the source in their FROM address headers. Domain mappings can be managed via the CLI and +added via xref:{xref-base}/operate/webadmin.adoc#_domain_mappings[WebAdmin] +* *Domain aliases*: Rewrites the domain of mail addresses. Express the idea that both domains can be used +inter-changeably. User will be allowed to use the source in their FROM address headers. Domain aliases can +be managed via xref:{xref-base}/operate/webadmin.adoc#_get_the_list_of_aliases_for_a_domain[WebAdmin] +* *Forwards*: Replaces the source address by another one. Vehicles the intent of forwarding incoming mails +to other users. Listing the forward source in the forward destinations keeps a local copy. User will not be +allowed to use the source in their FROM address headers. Forward can +be managed via xref:{xref-base}/operate/webadmin.adoc#_address_forwards[WebAdmin] +* *Groups*: Replaces the source address by another one. Vehicles the intent of a group registration: group +address will be swapped by group member addresses (Feature poor mailing list). User will not be +allowed to use the source in their FROM address headers. Groups can +be managed via xref:{xref-base}/operate/webadmin.adoc#_address_group[WebAdmin] +* *Aliases*: Replaces the source address by another one. Represents user owned mail address, with which +he can interact as if it was his main mail address. User will be allowed to use the source in their FROM +address headers. Aliases can be managed via xref:{xref-base}/operate/webadmin.adoc#_address_aliases[WebAdmin] +* *Address mappings*: Replaces the source address by another one.Use for technical purposes, this mapping type do +not hold specific intent.Prefer using one of the above mapping types... User will not be allowed to use the source +in their FROM address headers.Address mappings can be managed via the CLI or via +xref:{xref-base}/operate/webadmin.adoc#_address_mappings[WebAdmin] +* *Regex mappings*: Applies the regex on the supplied address.User will not be allowed to use the source +in their FROM address headers.Regex mappings can be managed via the CLI or via +xref:{xref-base}/operate/webadmin.adoc#_regex_mapping[WebAdmin] +* *Error*: Throws an error upon processing.User will not be allowed to use the source +in their FROM address headers.Errors can be managed via the CLI + +[#_blobstore] +=== BlobStore + +Stores potentially large binary data. + +Mailbox component, Mail Queue component, Deleted Message Vault +component relies on it. + +Supported backends include S3 compatible ObjectStorage (link:https://wiki.openstack.org/wiki/Swift[Swift], S3 API). + +Encryption can be configured on top of ObjectStorage. + +Blobs can currently be deduplicated in order to reduce storage space. This means that two blobs with +the same content will be stored one once. + +The downside is that deletion is more complicated, and a garbage collection needs to be run. A first implementation +based on bloom filters can be used and triggered using the WebAdmin REST API. + +=== Task Manager + +Allows to control and schedule long running tasks run by other +components. Among other it enables scheduling, progress monitoring, +cancellation of long running tasks. + +{server-name} leverage a task manager using Event Sourcing and RabbitMQ for messaging. + +=== Event sourcing + +link:https://martinfowler.com/eaaDev/EventSourcing.html[Event sourcing] implementation +for the {server-name} stores events in {backend-name}. It enables components +to rely on event sourcing technics for taking decisions. + +A short list of usage are: + +* Data leak prevention storage +* JMAP filtering rules storage +* Validation of the MailQueue configuration +* Sending email warnings to user close to their quota +* Implementation of the TaskManager diff --git a/docs/modules/servers/partials/architecture/specialized-instances.adoc b/docs/modules/servers/partials/architecture/specialized-instances.adoc new file mode 100644 index 00000000000..d8e02b1dc75 --- /dev/null +++ b/docs/modules/servers/partials/architecture/specialized-instances.adoc @@ -0,0 +1,36 @@ +While it is perfectly possible to deploy homogeneous James instances, with the same configuration and thus the same +protocols and the same responsibilities one might want to investigate in 'Specialized instances'. + +This deployment topology consists of {server-name} with heterogeneous configurations on top of shared +databases. Groups of James servers will thus handle various protocols and have different responsibilities. + +This approach limits cascading failures across protocols and services. Think of *OutOfMemoryErrors*, CPUs starvation, +{backend-name} driver issue, etc. + +However, we can't speak of microservices here: all James instances runs the same code, James is still a monolith, and +databases need to be shared across instances. + +image::{specialized-instances-file-name}[Example of Specialized instances topology] + +We speak of: + + - **Front-line servers** serves protocols. James enables to easily turn protocols on and off. Typically, each protocol would + be isolated in its own group of James instances: james-imap, james-jmap, james-smtp, james-webadmin, etc... Refer to + protocols configuration files to learn more. + + - **Back-office servers** handles other services like: + + - Mail processing. + - Remote delivery. + - Event processing. + - Task execution. + +Front-line servers will likely not handle back office responsibilities (but be sure to have back-office servers that do!). + + - xref:{xref-base}/configure/mailetcontainer.adoc[Mail processing can be switched off]. + - xref:{xref-base}/configure/listeners.adoc[Mailbox event processing can be switched off]. + - xref:{xref-base}/configure/rabbitmq.adoc[Task execution can be switched off]. + - Remote Delivery service is not started if the RemoteDelivery mailet is not positioned in mailetcontainer.xml. + +Of course, the above instances can be collocated at will, to reach some intermediate deployments with fewer +instances to mitigate costs. \ No newline at end of file From 5cf8d6e9b8dd163e0875cb7e20f619948dc0eafc Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 1 Jul 2024 15:05:40 +0700 Subject: [PATCH 005/341] [Antora] Make partial for server Operate section & clean format --- .../pages/distributed/operate/cli.adoc | 335 +- .../pages/distributed/operate/guide.adoc | 298 +- .../pages/distributed/operate/index.adoc | 26 +- .../pages/distributed/operate/logging.adoc | 254 +- .../operate/logging/docker-compose-block.adoc | 78 + .../pages/distributed/operate/metrics.adoc | 182 +- .../pages/distributed/operate/migrating.adoc | 34 +- .../operate/performanceChecklist.adoc | 138 +- .../pages/distributed/operate/security.adoc | 249 +- .../pages/distributed/operate/webadmin.adoc | 4760 +---------------- .../webadmin/admin-mail-queues-extend.adoc | 14 + .../webadmin/admin-mailboxes-extend.adoc | 226 + .../webadmin/admin-messages-extend.adoc | 117 + .../modules/servers/partials/operate/cli.adoc | 332 ++ .../servers/partials/operate/guide.adoc | 270 + .../servers/partials/operate/index.adoc | 22 + .../servers/partials/operate/logging.adoc | 257 + .../servers/partials/operate/metrics.adoc | 179 + .../servers/partials/operate/migrating.adoc | 31 + .../operate/performanceChecklist.adoc | 80 + .../servers/partials/operate/security.adoc | 246 + .../servers/partials/operate/webadmin.adoc | 4517 ++++++++++++++++ 22 files changed, 6447 insertions(+), 6198 deletions(-) create mode 100644 docs/modules/servers/pages/distributed/operate/logging/docker-compose-block.adoc create mode 100644 docs/modules/servers/pages/distributed/operate/webadmin/admin-mail-queues-extend.adoc create mode 100644 docs/modules/servers/pages/distributed/operate/webadmin/admin-mailboxes-extend.adoc create mode 100644 docs/modules/servers/pages/distributed/operate/webadmin/admin-messages-extend.adoc create mode 100644 docs/modules/servers/partials/operate/cli.adoc create mode 100644 docs/modules/servers/partials/operate/guide.adoc create mode 100644 docs/modules/servers/partials/operate/index.adoc create mode 100644 docs/modules/servers/partials/operate/logging.adoc create mode 100644 docs/modules/servers/partials/operate/metrics.adoc create mode 100644 docs/modules/servers/partials/operate/migrating.adoc create mode 100644 docs/modules/servers/partials/operate/performanceChecklist.adoc create mode 100644 docs/modules/servers/partials/operate/security.adoc create mode 100644 docs/modules/servers/partials/operate/webadmin.adoc diff --git a/docs/modules/servers/pages/distributed/operate/cli.adoc b/docs/modules/servers/pages/distributed/operate/cli.adoc index 5bd2a2dded6..b312310244f 100644 --- a/docs/modules/servers/pages/distributed/operate/cli.adoc +++ b/docs/modules/servers/pages/distributed/operate/cli.adoc @@ -1,335 +1,6 @@ = Distributed James Server — Command Line Interface :navtitle: Command Line Interface -The distributed server is packed with a command line client. - -To run this command line client simply execute: - -.... -java -jar /root/james-cli.jar -h 127.0.0.1 -p 9999 COMMAND -.... - -The following document will explain you which are the available options -for *COMMAND*. - -Note: the above command line before *COMMAND* will be documented as _\{cli}_. - -== Manage Domains - -Domains represent the domain names handled by your server. - -You can add a domain: - -.... -{cli} AddDomain domain.tld -.... - -You can remove a domain: - -.... -{cli} RemoveDomain domain.tld -.... - -(Note: associated users are not removed automatically) - -Check if a domain is handled: - -.... -{cli} ContainsDomain domain.tld -.... - -And list your domains: - -.... -{cli} ListDomains -.... - -== Managing users - -Note: the following commands are explained with virtual hosting turned -on. - -Users are accounts on the mail server. James can maintain mailboxes for -them. - -You can add a user: - -.... -{cli} AddUser user@domain.tld password -.... - -Note: the domain used should have been previously created. - -You can delete a user: - -.... -{cli} RemoveUser user@domain.tld -.... - -(Note: associated mailboxes are not removed automatically) - -And change a user password: - -.... -{cli} SetPassword user@domain.tld password -.... - -Note: All these write operations can not be performed on LDAP backend, -as the implementation is read-only. - -Finally, you can list users: - -.... -{cli} ListUsers -.... - -=== Virtual hosting - -James supports virtualhosting. - -* If set to true in the configuration, then the username is the full -mail address. - -The domains then become a part of the user. - -_usera@domaina.com and_ _usera@domainb.com_ on a mail server with -_domaina.com_ and _domainb.com_ configured are mail addresses that -belongs to different users. - -* If set to false in the configurations, then the username is the mail -address local part. - -It means that a user is automatically created for all the domains -configured on your server. - -_usera@domaina.com and_ _usera@domainb.com_ on a mail server with -_domaina.com_ and _domainb.com_ configured are mail addresses that -belongs to the same users. - -Here are some sample commands for managing users when virtual hosting is -turned off: - -.... -{cli} AddUser user password -{cli} RemoveUser user -{cli} SetPassword user password -.... - -== Managing mailboxes - -An administrator can perform some basic operation on user mailboxes. - -Note on mailbox formatting: mailboxes are composed of three parts. - -* The namespace, indicating what kind of mailbox it is. (Shared or -not?). The value for users mailboxes is #private . Note that for now no -other values are supported as James do not support shared mailboxes. -* The username as stated above, depending on the virtual hosting value. -* And finally mailbox name. Be aware that `.' serves as mailbox -hierarchy delimiter. - -An administrator can delete all of the mailboxes of a user, which is not -done automatically when removing a user (to avoid data loss): - -.... -{cli} DeleteUserMailboxes user@domain.tld -.... - -He can delete a specific mailbox: - -.... -{cli} DeleteMailbox #private user@domain.tld INBOX.toBeDeleted -.... - -He can list the mailboxes of a specific user: - -.... -{cli} ListUserMailboxes user@domain.tld -.... - -And finally can create a specific mailbox: - -.... -{cli} CreateMailbox #private user@domain.tld INBOX.newFolder -.... - -== Adding a message in a mailbox - -The administrator can use the CLI to add a message in a mailbox. this -can be done using: - -.... -{cli} ImportEml #private user@domain.tld INBOX.newFolder /full/path/to/file.eml -.... - -This command will add a message having the content specified in file.eml -(that needs to be at the EML format). It will get added in the -INBOX.subFolder mailbox belonging to user user@domain.tld. - -== Managing mappings - -A mapping is a recipient rewriting rule. There is several kind of -rewriting rules: - -* address mapping: rewrite a given mail address into an other one. -* regex mapping. - -You can manage address mapping like (redirects email from -fromUser@fromDomain.tld to redirected@domain.new, then deletes the -mapping): - -.... -{cli} AddAddressMapping fromUser fromDomain.tld redirected@domain.new -{cli} RemoveAddressMapping fromUser fromDomain.tld redirected@domain.new -.... - -You can manage regex mapping like this: - -.... -{cli} AddRegexMapping redirected domain.new .*@domain.tld -{cli} RemoveRegexMapping redirected domain.new .*@domain.tld -.... - -You can view mapping for a mail address: - -.... -{cli} ListUserDomainMappings user domain.tld -.... - -And all mappings defined on the server: - -.... -{cli} ListMappings -.... - -== Manage quotas - -Quotas are limitations on a group of mailboxes. They can limit the -*size* or the *messages count* in a group of mailboxes. - -James groups by defaults mailboxes by user (but it can be overridden), -and labels each group with a quotaroot. - -To get the quotaroot a given mailbox belongs to: - -.... -{cli} GetQuotaroot #private user@domain.tld INBOX -.... - -Then you can get the specific quotaroot limitations. - -For the number of messages: - -.... -{cli} GetMessageCountQuota quotaroot -.... - -And for the storage space available: - -.... -{cli} GetStorageQuota quotaroot -.... - -You see the maximum allowed for these values: - -For the number of messages: - -.... -{cli} GetMaxMessageCountQuota quotaroot -.... - -And for the storage space available: - -.... -{cli} GetMaxStorageQuota quotaroot -.... - -You can also specify maximum for these values. - -For the number of messages: - -.... -{cli} SetMaxMessageCountQuota quotaroot value -.... - -And for the storage space available: - -.... -{cli} SetMaxStorageQuota quotaroot value -.... - -With value being an integer. Please note the use of units for storage -(K, M, G). For instance: - -.... -{cli} SetMaxStorageQuota someone@apache.org 4G -.... - -Moreover, James allows to specify global maximum values, at the server -level. Note: syntax is similar to what was exposed previously. - -.... -{cli} SetGlobalMaxMessageCountQuota value -{cli} GetGlobalMaxMessageCountQuota -{cli} SetGlobalMaxStorageQuota value -{cli} GetGlobalMaxStorageQuota -.... - -== Re-indexing - -James allow you to index your emails in a search engine, for making -search faster. - -For some reasons, you might want to re-index your mails (inconsistencies -across datastore, migrations). - -To re-index all mails of all mailboxes of all users, type: - -.... -{cli} ReindexAll -.... - -And for a specific mailbox: - -.... -{cli} Reindex #private user@domain.tld INBOX -.... - -== Sieve scripts quota - -James implements Sieve (RFC-5228). Your users can then write scripts -and upload them to the server. Thus they can define the desired behavior -upon email reception. James defines a Sieve mailet for this, and stores -Sieve scripts. You can update them via the ManageSieve protocol, or via -the ManageSieveMailet. - -You can define quota for the total size of Sieve scripts, per user. - -Syntax is similar to what was exposed for quotas. For defaults values: - -.... -{cli} GetSieveQuota -{cli} SetSieveQuota value -{cli} RemoveSieveQuota -.... - -And for specific user quotas: - -.... -{cli} GetSieveUserQuota user@domain.tld -{cli} SetSieveQuota user@domain.tld value -{cli} RemoveSieveUserQuota user@domain.tld -.... - -== Switching of mailbox implementation - -Migration is experimental for now. You would need to customize *Spring* -configuration to add a new mailbox manager with a different bean name. - -You can then copy data across mailbox managers using: - -.... -{cli} CopyMailbox srcBean dstBean -.... - -You will then need to reconfigure James to use the new mailbox manager. \ No newline at end of file +:xref-base: distributed +:server-name: Distributed James Server +include::partial$operate/cli.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/operate/guide.adoc b/docs/modules/servers/pages/distributed/operate/guide.adoc index d286ca8b453..7ecc8456a25 100644 --- a/docs/modules/servers/pages/distributed/operate/guide.adoc +++ b/docs/modules/servers/pages/distributed/operate/guide.adoc @@ -1,201 +1,12 @@ = Distributed James Server — Operator guide :navtitle: Operator guide -This guide aims to be an entry-point to the James documentation for user -managing a distributed Guice James server. - -It includes: - -* Simple architecture explanations -* Propose some diagnostics for some common issues -* Present procedures that can be set up to address these issues - -In order to not duplicate information, existing documentation will be -linked. - -Please note that this product is under active development, should be -considered experimental and thus targets advanced users. - -== Basic Monitoring - -A toolbox is available to help an administrator diagnose issues: - -* xref:distributed/operate/logging.adoc[Structured logging into Kibana] -* xref:distributed/operate/metrics.adoc[Metrics graphs into Grafana] -* xref:distributed/operate/webadmin.adoc#_healthcheck[WebAdmin HealthChecks] - -== Mail processing - -Currently, an administrator can monitor mail processing failure through `ERROR` log -review. We also recommend watching in Kibana INFO logs using the -`org.apache.james.transport.mailets.ToProcessor` value as their `logger`. Metrics about -mail repository size, and the corresponding Grafana boards are yet to be contributed. - -Furthermore, given the default mailet container configuration, we recommend monitoring -`cassandra://var/mail/error/` to be empty. - -WebAdmin exposes all utilities for -xref:distributed/operate/webadmin.adoc#_reprocessing_mails_from_a_mail_repository[reprocessing -all mails in a mail repository] or -xref:distributed/operate/webadmin.adoc#_reprocessing_a_specific_mail_from_a_mail_repository[reprocessing -a single mail in a mail repository]. - -In order to prevent unbounded processing that could consume unbounded resources. We can provide a CRON with `limit` parameter. -Ex: 10 reprocessed per minute -Note that it only support the reprocessing all mails. - -Also, one can decide to -xref:distributed/operate/webadmin.adoc#_removing_all_mails_from_a_mail_repository[delete -all the mails of a mail repository] or -xref:distributed/operate/webadmin.adoc#_removing_a_mail_from_a_mail_repository[delete -a single mail of a mail repository]. - -Performance of mail processing can be monitored via the -https://github.com/apache/james-project/blob/d2cf7c8e229d9ed30125871b3de5af3cb1553649/server/grafana-reporting/es-datasource/MAILET-1490071694187-dashboard.json[mailet -grafana board] and -https://github.com/apache/james-project/blob/d2cf7c8e229d9ed30125871b3de5af3cb1553649/server/grafana-reporting/es-datasource/MATCHER-1490071813409-dashboard.json[matcher -grafana board]. - -=== Recipient rewriting - -Given the default configuration, errors (like loops) uopn recipient rewritting will lead -to emails being stored in `cassandra://var/mail/rrt-error/`. - -We recommend monitoring the content of this mail repository to be empty. - -If it is not empty, we recommend -verifying user mappings via xref:distributed/operate/webadmin.adoc#_listing_user_mappings_[User Mappings webadmin API] then once identified break the loop by removing -some Recipient Rewrite Table entry via the -xref:distributed/operate/webadmin.adoc#_removing_an_alias_of_an_user[Delete Alias], -xref:distributed/operate/webadmin.adoc#_removing_a_group_member[Delete Group member], -xref:distributed/operate/webadmin.adoc#_removing_a_destination_of_a_forward[Delete forward], -xref:distributed/operate/webadmin.adoc#_remove_an_address_mapping[Delete Address mapping], -xref:distributed/operate/webadmin.adoc#_removing_a_domain_mapping[Delete Domain mapping] -or xref:distributed/operate/webadmin.adoc#_removing_a_regex_mapping[Delete Regex mapping] -APIs (as needed). - -The `Mail.error` field can help diagnose the issue as well. Then once -the root cause has been addressed, the mail can be reprocessed. - -== Mailbox Event Bus - -It is possible for the administrator of James to define the mailbox -listeners he wants to use, by adding them in the -https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/listeners.xml[listeners.xml] -configuration file. It’s possible also to add your own custom mailbox -listeners. This enables to enhance capabilities of James as a Mail -Delivery Agent. You can get more information about those -link:config-listeners.html[here]. - -Currently, an administrator can monitor listeners failures through -`ERROR` log review. Metrics regarding mailbox listeners can be monitored -via -https://github.com/apache/james-project/blob/d2cf7c8e229d9ed30125871b3de5af3cb1553649/server/grafana-reporting/es-datasource/MailboxListeners-1528958667486-dashboard.json[mailbox_listeners -grafana board] and -https://github.com/apache/james-project/blob/d2cf7c8e229d9ed30125871b3de5af3cb1553649/server/grafana-reporting/es-datasource/MailboxListeners%20rate-1552903378376.json[mailbox_listeners_rate -grafana board]. - -Upon exceptions, a bounded number of retries are performed (with -exponential backoff delays). If after those retries the listener is -still failing to perform its operation, then the event will be stored in -the xref:distributed/operate/webadmin.adoc#_event_dead_letter[Event Dead Letter]. This -API allows diagnosing issues, as well as redelivering the events. - -To check that you have undelivered events in your system, you can first -run the associated with -xref:distributed/operate/webadmin.adoc#_healthcheck[event dead letter health check] . -You can explore Event DeadLetter content through WebAdmin. For -this, xref:distributed/operate/webadmin.adoc#_listing_mailbox_listener_groups[list mailbox listener groups] -you will get a list of groups back, allowing -you to check if those contain registered events in each by -xref:distributed/operate/webadmin.adoc#_listing_failed_events[listing their failed events]. - -If you get failed events IDs back, you can as well -xref:distributed/operate/webadmin.adoc#_getting_event_details[check their details]. - -An easy way to solve this is just to trigger then the -xref:distributed/operate/webadmin.adoc#_redeliver_all_events[redeliver all events] -task. It will start reprocessing all the failed events registered in -event dead letters. - -In order to prevent unbounded processing that could consume unbounded resources. We can provide a CRON with `limit` parameter. -Ex: 10 redelivery per minute - -If for some other reason you don’t need to redeliver all events, you -have more fine-grained operations allowing you to -xref:distributed/operate/webadmin.adoc#_redeliver_group_events[redeliver group events] -or even just -xref:distributed/operate/webadmin.adoc#_redeliver_a_single_event[redeliver a single event]. - -== OpenSearch Indexing - -A projection of messages is maintained in OpenSearch via a listener -plugged into the mailbox event bus in order to enable search features. - -You can find more information about OpenSearch configuration -link:config-opensearch.html[here]. - -=== Usual troubleshooting procedures - -As explained in the link:#_mailbox_event_bus[Mailbox Event Bus] section, -processing those events can fail sometimes. - -Currently, an administrator can monitor indexation failures through -`ERROR` log review. You can as well -xref:distributed/operate/webadmin.adoc#_listing_failed_events[list failed events] by -looking with the group called -`org.apache.james.mailbox.opensearch.events.OpenSearchListeningMessageSearchIndex$OpenSearchListeningMessageSearchIndexGroup`. -A first on-the-fly solution could be to just -link:#_mailbox_event_bus[redeliver those group events with event dead letter]. - -If the event storage in dead-letters fails (for instance in the face of -Cassandra storage exceptions), then you might need to use our WebAdmin -reIndexing tasks. - -From there, you have multiple choices. You can -xref:distributed/operate/webadmin.adoc#_reindexing_all_mails[reIndex all mails], -xref:distributed/operate/webadmin.adoc#_reindexing_a_mailbox_mails[reIndex mails from a mailbox] or even just -xref:distributed/operate/webadmin.adoc#_reindexing_a_single_mail_by_messageid[reIndex a single mail]. - -When checking the result of a reIndexing task, you might have failed -reprocessed mails. You can still use the task ID to -xref:distributed/operate/webadmin.adoc#_fixing_previously_failed_reindexing[reprocess previously failed reIndexing mails]. - -=== On the fly OpenSearch Index setting update - -Sometimes you might need to update index settings. Cases when an -administrator might want to update index settings include: - -* Scaling out: increasing the shard count might be needed. -* Changing string analysers, for instance to target another language -* etc. - -In order to achieve such a procedure, you need to: - -* https://www.elastic.co/guide/en/elasticsearch/reference/7.10/indices-create-index.html[Create -the new index] with the right settings and mapping -* James uses two aliases on the mailbox index: one for reading -(`mailboxReadAlias`) and one for writing (`mailboxWriteAlias`). First -https://www.elastic.co/guide/en/elasticsearch/reference/7.10/indices-aliases.html[add -an alias] `mailboxWriteAlias` to that new index, so that now James -writes on the old and new indexes, while only keeping reading on the -first one -* Now trigger a -https://www.elastic.co/guide/en/elasticsearch/reference/7.10/docs-reindex.html[reindex] -from the old index to the new one (this actively relies on `_source` -field being present) -* When this is done, add the `mailboxReadAlias` alias to the new index -* Now that the migration to the new index is done, you can -https://www.elastic.co/guide/en/elasticsearch/reference/7.10/indices-delete-index.html[drop -the old index] -* You might want as well modify the James configuration file -https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/opensearch.properties[elasticsearch.properties] -by setting the parameter `opensearch.index.mailbox.name` to the name -of your new index. This is to avoid that James re-creates index upon -restart - -_Note_: keep in mind that reindexing can be a very long operation -depending on the volume of mails you have stored. +:xref-base: distributed +:mailet-repository-path-prefix: cassandra +:backend-name: cassandra +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration +:server-name: Distributed James Server +include::partial$operate/guide.adoc[] == Solving cassandra inconsistencies @@ -220,7 +31,7 @@ message reads and will temporarily decrease the performance. ==== How to detect the outdated projections You can watch the `MessageFastViewProjection` health check at -xref:distributed/operate/webadmin.adoc#_check_all_components[webadmin documentation]. +xref:{xref-base}/operate/webadmin.adoc#_check_all_components[webadmin documentation]. It provides a check based on the ratio of missed projection reads. ==== How to solve @@ -249,7 +60,7 @@ diagnostic and fixes. ==== How to solve An admin can run offline webadmin -xref:distributed/operate/webadmin.adoc#_fixing_mailboxes_inconsistencies[solve Cassandra mailbox object inconsistencies task] +xref:{xref-base}/operate/webadmin.adoc#_fixing_mailboxes_inconsistencies[solve Cassandra mailbox object inconsistencies task] in order to sanitize his mailbox denormalization. @@ -273,7 +84,7 @@ message prefix: `Invalid mailbox counters`. ==== How to solve Execute the -xref:distributed/operate/webadmin.adoc#_recomputing_mailbox_counters[recompute Mailbox counters task]. +xref:{xref-base}/operate/webadmin.adoc#_recomputing_mailbox_counters[recompute Mailbox counters task]. This task is not concurrent-safe. Concurrent increments & decrements will be ignored during a single mailbox processing. Re-running this task may eventually return the correct @@ -293,7 +104,7 @@ User can see a message in JMAP but not in IMAP, or mark a message as ==== How to solve Execute the -xref:distributed/operate/webadmin.adoc#_fixing_message_inconsistencies[solve Cassandra message inconsistencies task]. This task is not +xref:{xref-base}/operate/webadmin.adoc#_fixing_message_inconsistencies[solve Cassandra message inconsistencies task]. This task is not concurrent-safe. User actions concurrent to the inconsistency fixing task could result in new inconsistencies being created. However the source of truth `imapUidTable` will not be affected and thus re-running @@ -313,7 +124,7 @@ Incorrect quotas could be seen in the `Mail User Agent` (IMAP or JMAP). ==== How to solve Execute the -xref:distributed/operate/webadmin.adoc#_recomputing_current_quotas_for_users[recompute Quotas counters task]. This task is not concurrent-safe. Concurrent +xref:{xref-base}/operate/webadmin.adoc#_recomputing_current_quotas_for_users[recompute Quotas counters task]. This task is not concurrent-safe. Concurrent operations will result in an invalid quota to be persisted. Re-running this task may eventually return the correct result. @@ -333,7 +144,7 @@ the mean time, the recommendation is to execute the ==== How to solve Execute the Cassandra mapping `SolveInconsistencies` task described in -xref:distributed/operate/webadmin.adoc#_operations_on_mappings_sources[webadmin documentation] +xref:{xref-base}/operate/webadmin.adoc#_operations_on_mappings_sources[webadmin documentation] == Setting Cassandra user permissions @@ -487,35 +298,6 @@ the https://cassandra.apache.org/doc/latest/tools/cqlsh.html[cqlsh] utility. A full compaction might be needed in order for the changes to be taken into account. -== Mail Queue - -=== Fine tune configuration for RabbitMQ - -In order to adapt mail queue settings to the actual traffic load, an -administrator needs to perform fine configuration tunning as explained -in -https://github.com/apache/james-project/blob/master/src/site/xdoc/server/config-rabbitmq.xml[rabbitmq.properties]. - -Be aware that `MailQueue::getSize` is currently performing a browse and -thus is expensive. Size recurring metric reporting thus introduces -performance issues. As such, we advise setting -`mailqueue.size.metricsEnabled=false`. - -=== Managing email queues - -Managing an email queue is an easy task if you follow this procedure: - -* First, xref:distributed/operate/webadmin.adoc#_listing_mail_queues[List mail queues] -and xref:distributed/operate/webadmin.adoc#_getting_a_mail_queue_details[get a mail queue details]. -* And then -xref:distributed/operate/webadmin.adoc#_listing_the_mails_of_a_mail_queue[List the mails of a mail queue]. - -In case, you need to clear an email queue because there are only spam or -trash emails in the email queue you have this procedure to follow: - -* All mails from the given mail queue will be deleted with -xref:distributed/operate/webadmin.adoc#_clearing_a_mail_queue[Clearing a mail queue]. - == Updating Cassandra schema version A schema version indicates you which schema your James server is relying @@ -551,64 +333,18 @@ These schema updates can be triggered by webadmin using the Cassandra backend. Following steps are for updating Cassandra schema version: * At the very first step, you need to -xref:distributed/operate/webadmin.adoc#_retrieving_current_cassandra_schema_version[retrieve +xref:{xref-base}/operate/webadmin.adoc#_retrieving_current_cassandra_schema_version[retrieve current Cassandra schema version] * And then, you -xref:distributed/operate/webadmin.adoc#_retrieving_latest_available_cassandra_schema_version[retrieve +xref:{xref-base}/operate/webadmin.adoc#_retrieving_latest_available_cassandra_schema_version[retrieve latest available Cassandra schema version] to make sure there is a latest available version * Eventually, you can update the current schema version to the one you got with -xref:distributed/operate/webadmin.adoc#_upgrading_to_the_latest_version[upgrading to +xref:{xref-base}/operate/webadmin.adoc#_upgrading_to_the_latest_version[upgrading to the latest version] Otherwise, if you need to run the migrations to a specific version, you can use -xref:distributed/operate/webadmin.adoc#_upgrading_to_a_specific_version[Upgrading to a -specific version] - -== Deleted Message Vault - -We recommend the administrator to -xref:#_cleaning_expired_deleted_messages[run it] in cron job to save -storage volume. - -=== How to configure deleted messages vault - -To setup James with Deleted Messages Vault, you need to follow those -steps: - -* Enable Deleted Messages Vault by configuring Pre Deletion Hooks. -* Configuring the retention time for the Deleted Messages Vault. - -==== Enable Deleted Messages Vault by configuring Pre Deletion Hooks - -You need to configure this hook in -https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/listeners.xml[listeners.xml] -configuration file. More details about configuration & example can be -found at http://james.apache.org/server/config-listeners.html[Pre -Deletion Hook Configuration] - -==== Configuring the retention time for the Deleted Messages Vault - -In order to configure the retention time for the Deleted Messages Vault, -an administrator needs to perform fine configuration tunning as -explained in -https://github.com/apache/james-project/blob/master/server/apps/distributed-app/sample-configuration/deletedMessageVault.properties[deletedMessageVault.properties]. -Mails are not retained forever as you have to configure a retention -period (by `retentionPeriod`) before using it (with one-year retention -by default if not defined). - -=== Restore deleted messages after deletion - -After users deleted their mails and emptied the trash, the admin can use -xref:distributed/operate/webadmin.adoc#_restore_deleted_messages[Restore Deleted Messages] -to restore all the deleted mails. - -=== Cleaning expired deleted messages - -You can delete all deleted messages older than the configured -`retentionPeriod` by using -xref:distributed/operate/webadmin.adoc#_deleted_messages_vault[Purge Deleted Messages]. -We recommend calling this API in CRON job on 1st day each -month. +xref:{xref-base}/operate/webadmin.adoc#_upgrading_to_a_specific_version[Upgrading to a +specific version] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/operate/index.adoc b/docs/modules/servers/pages/distributed/operate/index.adoc index bcad596cdea..da76f0558b8 100644 --- a/docs/modules/servers/pages/distributed/operate/index.adoc +++ b/docs/modules/servers/pages/distributed/operate/index.adoc @@ -1,28 +1,10 @@ = Distributed James Server — Operate the Distributed server :navtitle: Operate the Distributed server -The following pages detail how to operate the Distributed server. - -Once you have a Distributed James server up and running you then need to ensure it operates correctly and has a decent performance. -You may also need to perform some operation maintenance or recover from incidents. This section covers -these topics. - -Read more about xref:distributed/operate/logging.adoc[Logging]. - -The xref:distributed/operate/webadmin.adoc[WebAdmin Restfull administration API] is the -recommended way to operate the Distributed James server. It allows managing and interacting with most -server components. - -The xref:distributed/operate/cli.adoc[Command line interface] allows to interact with some -server components. However it relies on JMX technologies and its use is discouraged. - -The xref:distributed/operate/metrics.adoc[metrics] allows to build latency and throughput -graphs, that can be visualized, for instance in *Grafana*. - -We did put together a xref:distributed/operate/guide.adoc[detailed guide] for -distributed James operators. We also propose a xref:distributed/operate/performanceChecklist.adoc[performance checklist]. - -We also included a guide for xref:distributed/operate/migrating.adoc[migrating existing data] into the distributed server. +:xref-base: distributed +:server-name: Distributed James Server +:server-tag: distributed +include::partial$operate/index.adoc[] Read more about xref:distributed/operate/cassandra-migration.adoc[Cassandra data migration]. diff --git a/docs/modules/servers/pages/distributed/operate/logging.adoc b/docs/modules/servers/pages/distributed/operate/logging.adoc index 43079e87aa6..5c93f32071f 100644 --- a/docs/modules/servers/pages/distributed/operate/logging.adoc +++ b/docs/modules/servers/pages/distributed/operate/logging.adoc @@ -1,251 +1,9 @@ = Distributed James Server — Logging :navtitle: Logging -We recommend to closely monitoring *ERROR* and *WARNING* logs. Those -logs should be considered not normal. - -If you encounter some suspicious logs: - -* If you have any doubt about the log being caused by a bug in James -source code, please reach us via the bug tracker, the user mailing list or our Gitter channel (see our -http://james.apache.org/#second[community page]) -* They can be due to insufficient performance from tier applications (eg -Cassandra timeouts). In such case we advise you to conduct a close -review of performances at the tier level. - -Leveraging filters in Kibana discover view can help to filter out -''already known'' frequently occurring logs. - -When reporting ERROR or WARNING logs, consider adding the full logs, and -related data (eg the raw content of a mail triggering an issue) to the -bug report in order to ease resolution. - -== Logging configuration - -Distributed James uses link:http://logback.qos.ch/[logback] as a logging library -and link:https://docs.fluentbit.io/[FluentBit] as centralize logging. - -Information about logback configuration can be found -link:http://logback.qos.ch/manual/configuration.html[here]. - -== Structured logging - -=== Using FluentBit as a log forwarder - -==== Using Docker - -Distributed Server leverages the use of MDC in order to achieve structured logging, and better add context to the logged information. We furthermore ship json logs to file with RollingFileAppender on the classpath to easily allow FluentBit to directly tail the log file. -Here is a sample conf/logback.xml configuration file for logback with the following pre-requisites: - -Logging in a structured json fashion and write to file for centralizing logging. -Centralize logging third party like FluentBit can tail from logging’s file then filter/process and put in to OpenSearch - -.... - - - - - true - - - - - logs/james.%d{yyyy-MM-dd}.%i.log - 1 - 200MB - 100MB - - - - - yyyy-MM-dd'T'HH:mm:ss.SSSX - Etc/UTC - - - true - - - false - - - - - - - - - - -.... - -First you need to create a `logs` folder, then mount it to James container and to FluentBit. - -docker-compose: -.... -version: "3" - -services: - james: - depends_on: - - opensearch - - cassandra - - rabbitmq - - s3 - entrypoint: bash -c "java -cp 'james-server.jar:extension-jars/*:james-server-memory-guice.lib/*' -Dworking.directory=/root/ -Dlogback.configurationFile=/root/conf/logback.xml org.apache.james.CassandraRabbitMQJamesServerMain" - image: linagora/james-rabbitmq-project:branch-master - container_name: james - hostname: james.local - volumes: - - ./extension-jars:/root/extension-jars - - ./conf/logback.xml:/root/conf/logback.xml - - ./logs:/root/logs - ports: - - "80:80" - - "25:25" - - "110:110" - - "143:143" - - "465:465" - - "587:587" - - "993:993" - - "8080:8000" - - opensearch: - image: opensearchproject/opensearch:2.14.0 - ports: - - "9200:9200" - environment: - - discovery.type=single-node - - cassandra: - image: cassandra:4.1.5 - ports: - - "9042:9042" - - rabbitmq: - image: rabbitmq:3.13.3-management - ports: - - "5672:5672" - - "15672:15672" - - s3: - image: registry.scality.com/cloudserver/cloudserver:8.7.25 - container_name: s3.docker.test - environment: - - SCALITY_ACCESS_KEY_ID=accessKey1 - - SCALITY_SECRET_ACCESS_KEY=secretKey1 - - S3BACKEND=mem - - LOG_LEVEL=trace - - REMOTE_MANAGEMENT_DISABLE=1 - - fluent-bit: - image: fluent/fluent-bit:1.5.7 - volumes: - - ./fluentbit/fluent-bit.conf:/fluent-bit/etc/fluent-bit.conf - - ./fluentbit/parsers.conf:/fluent-bit/etc/parsers.conf - - ./logs:/fluent-bit/log - ports: - - "24224:24224" - - "24224:24224/udp" - depends_on: - - opensearch - - opensearch-dashboards: - image: opensearchproject/opensearch-dashboards:2.16.0 - environment: - OPENSEARCH_HOSTS: http://opensearch:9200 - ports: - - "5601:5601" - depends_on: - - opensearch -.... - -FluentBit config as: -the `Host opensearch` pointing to `opensearch` service in docker-compose file. -.... -[SERVICE] - Parsers_File /fluent-bit/etc/parsers.conf - -[INPUT] - name tail - path /fluent-bit/log/*.log - Parser docker - docker_mode on - buffer_chunk_size 1MB - buffer_max_size 1MB - mem_buf_limit 64MB - Refresh_Interval 30 - -[OUTPUT] - Name stdout - Match * - - -[OUTPUT] - Name es - Match * - Host opensearch - Port 9200 - Index fluentbit - Logstash_Format On - Logstash_Prefix fluentbit-james - Type docker -.... - -FluentBit Parser config: -.... -[PARSER] - Name docker - Format json - Time_Key timestamp - Time_Format %Y-%m-%dT%H:%M:%S.%LZ - Time_Keep On - Decode_Field_As escaped_utf8 log do_next - Decode_Field_As escaped log do_next - Decode_Field_As json log -.... - -==== Using Kubernetes - -If using James in a Kubernetes environment, you can just append the logs to the console in a JSON formatted way -using Jackson to easily allow FluentBit to directly tail them. - -Here is a sample conf/logback.xml configuration file for achieving this: - -.... - - - - - true - - - - - - yyyy-MM-dd'T'HH:mm:ss.SSSX - Etc/UTC - - - true - - - false - - - - - - - - - - -.... - -Regarding FluentBit on Kubernetes, you need to install it as a DaemonSet. Some official template exist -with FluentBit outputting logs to OpenSearch. For more information on how to install it, -with your cluster, you can look at this https://docs.fluentbit.io/manual/installation/kubernetes[documentation]. - -As stated by the https://docs.fluentbit.io/manual/installation/kubernetes#details[detail] of the -official documentation, FluentBit is configured to consume out of the box logs from containers -on the same running node. So it should scrap your James logs without extra configuration. +:xref-base: distributed +:server-name: Distributed James Server +:server-tag: distributed +:docker-compose-code-block-sample: servers:distributed/operate/logging/docker-compose-block.adoc +:backend-name: cassandra +include::partial$operate/logging.adoc[] diff --git a/docs/modules/servers/pages/distributed/operate/logging/docker-compose-block.adoc b/docs/modules/servers/pages/distributed/operate/logging/docker-compose-block.adoc new file mode 100644 index 00000000000..7d32b9146dc --- /dev/null +++ b/docs/modules/servers/pages/distributed/operate/logging/docker-compose-block.adoc @@ -0,0 +1,78 @@ +[source,docker-compose] +---- +version: "3" + +services: + james: + depends_on: + - elasticsearch + - cassandra + - rabbitmq + - s3 + entrypoint: bash -c "java -cp 'james-server.jar:extension-jars/*:james-server-memory-guice.lib/*' -Dworking.directory=/root/ -Dlogback.configurationFile=/root/conf/logback.xml org.apache.james.CassandraRabbitMQJamesServerMain" + image: linagora/james-rabbitmq-project:branch-master + container_name: james + hostname: james.local + volumes: + - ./extension-jars:/root/extension-jars + - ./conf/logback.xml:/root/conf/logback.xml + - ./logs:/root/logs + ports: + - "80:80" + - "25:25" + - "110:110" + - "143:143" + - "465:465" + - "587:587" + - "993:993" + - "8080:8000" + + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:7.10.2 + ports: + - "9200:9200" + environment: + - discovery.type=single-node + + cassandra: + image: cassandra:4.1.5 + ports: + - "9042:9042" + + rabbitmq: + image: rabbitmq:3.13.3-management + ports: + - "5672:5672" + - "15672:15672" + + s3: + image: registry.scality.com/cloudserver/cloudserver:8.7.25 + container_name: s3.docker.test + environment: + - SCALITY_ACCESS_KEY_ID=accessKey1 + - SCALITY_SECRET_ACCESS_KEY=secretKey1 + - S3BACKEND=mem + - LOG_LEVEL=trace + - REMOTE_MANAGEMENT_DISABLE=1 + + fluent-bit: + image: fluent/fluent-bit:1.5.7 + volumes: + - ./fluentbit/fluent-bit.conf:/fluent-bit/etc/fluent-bit.conf + - ./fluentbit/parsers.conf:/fluent-bit/etc/parsers.conf + - ./logs:/fluent-bit/log + ports: + - "24224:24224" + - "24224:24224/udp" + depends_on: + - elasticsearch + + kibana: + image: docker.elastic.co/kibana/kibana:7.10.2 + environment: + ELASTICSEARCH_HOSTS: http://elasticsearch:9200 + ports: + - "5601:5601" + depends_on: + - elasticsearch +---- \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/operate/metrics.adoc b/docs/modules/servers/pages/distributed/operate/metrics.adoc index e99f718d5de..d75368916af 100644 --- a/docs/modules/servers/pages/distributed/operate/metrics.adoc +++ b/docs/modules/servers/pages/distributed/operate/metrics.adoc @@ -1,181 +1,7 @@ = Distributed James Server — Metrics :navtitle: Metrics -James relies on the https://metrics.dropwizard.io/4.1.2/manual/core.html[Dropwizard metric library] -for keeping track of some core metrics of James. - -Such metrics are made available via JMX. You can connect for instance using VisualVM and the associated -mbean plugins. - -We also support displaying them via https://grafana.com/[Grafana]. Two methods can be used to back grafana display: - - - Prometheus metric collection - Data are exposed on a HTTP endpoint for Prometheus scrape. - - ElasticSearch metric collection - This method is depreciated and will be removed in next version. - -== Expose metrics for Prometheus collection - -To enable James metrics, add ``extensions.routes`` to https://github.com/apache/james-project/blob/master/server/apps/distributed-app/docs/modules/ROOT/pages/configure/webadmin.adoc[webadmin.properties] file: -``` -extensions.routes=org.apache.james.webadmin.dropwizard.MetricsRoutes -``` -Connect to james-admin url to test the result: -.... -http://james-admin-url/metrics -.... - -== Configure Prometheus Data source -You need to set up https://prometheus.io/docs/prometheus/latest/getting_started/[Prometheus] first to scrape James metrics. + -Add Apache James WebAdmin Url or IP address to ``prometheus.yaml`` configuration file: -.... -scrape_configs: - # The job name is added as a label `job=` to any timeseries scraped from this config. - - job_name: 'WebAdmin url Example' - scrape_interval: 5s - metrics_path: /metrics - static_configs: - - targets: ['james-webamin-url'] - - job_name: 'WebAdmin IP Example' - scrape_interval: 5s - metrics_path: /metrics - static_configs: - - targets: ['192.168.100.10:8000'] -.... - -== Connect Prometheus to Grafana - -You can do this either from https://prometheus.io/docs/visualization/grafana/[Grafana UI] or from a https://grafana.com/docs/grafana/latest/datasources/prometheus/[configuration file]. + -The following `docker-compose.yaml` will help you install a simple Prometheus/ Grafana stack : - -``` -version: '3' -#Metric monitoring - grafana: - image: grafana/grafana:latest - container_name: grafana - ports: - - "3000:3000" - - prometheus: - image: prom/prometheus:latest - restart: unless-stopped - ports: - - "9090:9090" - volumes: - - ./conf/prometheus.yml:/etc/prometheus/prometheus.yml -``` - -== Getting dashboards -Now that the Promtheus/Grafana servers are up, go to this https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/[link] to get all dashboards JSON file. Import the different JSON files in this directory to Grafana via UI. - - -image::preload-dashboards.png[Pre-loaded dashboards] - -*Note: For communication between multiple docker-compose projects, see https://stackoverflow.com/questions/38088279/communication-between-multiple-docker-compose-projects[here] for example. An easier approach is to merge James and Metric docker-compose files together. - -== Available metrics - -Here are the available metrics : - - - James JVM metrics - - Number of active SMTP connections - - Number of SMTP commands received - - Number of active IMAP connections - - Number of IMAP commands received - - Number of active LMTP connections - - Number of LMTP commands received - - Number of per queue number of enqueued mails - - Number of sent emails - - Number of delivered emails - - Diverse Response time percentiles, counts and rates for JMAP - - Diverse Response time percentiles, counts and rates for IMAP - - Diverse Response time percentiles, counts and rates for SMTP - - Diverse Response time percentiles, counts and rates for WebAdmin - - Diverse Response time percentiles, counts and rates for each Mail Queue - - Per mailet and per matcher Response time percentiles - - Diverse Response time percentiles, counts and rates for DNS - - Cassandra Java driver metrics - - Tika HTTP client statistics - - SpamAssassin TCP client statistics - - Mailbox listeners statistics time percentiles - - Mailbox listeners statistics requests rate - - Pre-deletion hooks execution statistics time percentiles - -== Available Grafana boards - -Here are the various relevant Grafana boards for the Distributed Server: - -- https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/James_BlobStore.json[BlobStore] : -Rates and percentiles for the BlobStore component -- https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/James_DNS_Dashboard.json[DNS] : -Latencies and query counts for DNS resolution. -- https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/James_IMAP_Board.json[IMAP] : -Latencies for the IMAP protocol -- https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/James_IMAP_CountBoard.json[IMAP counts] : -Request counts for the IMAP protocol -- https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/James_JMAP_Board.json[JMAP] : -Latencies for the JMAP protocol -- https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/James_JMAP_CountBoard.json[JMAP counts] : -Request counts for the JMAP protocol -- https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/James_JVM.json[JVM] : -JVM statistics (heap, gcs, etc...) -- https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/James_MAILET.json[Mailets] : -Per-mailet execution timings. -- https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/James_MATCHER.json[Matchers] : -Per-matcher execution timings -- https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/James_MailQueue.json[MailQueue] : -MailQueue statistics -- https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/James_SMTP_Board.json[SMTP] : -SMTP latencies reports -- https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/James_SMTP_CountBoard.json[SMTP count] : -Request count for the SMTP protocol - -=== Dashboard samples -Latencies for the JMAP protocol + - -image::JMAP_board.png[JMAP] - -Latencies for the IMAP protocol + - -image::IMAP_board.png[IMAP] - -JVM Statistics + - -image::JVM_board.png[JVM] - -BlobStore Statistics + - -image::BlobStore.png[BlobStore] - -webAdmin Statistics + - -image::webAdmin.png[webAdmin] - -== Expose metrics for Elasticsearch collection - -The following command allow you to run a fresh grafana server : - -.... -docker run -i -p 3000:3000 grafana/grafana -.... - -Once running, you need to set up an ElasticSearch data-source : - select -proxy mode - Select version 2.x of ElasticSearch - make the URL point -your ES node - Specify the index name. By default, it should be : - -.... -[james-metrics-]YYYY-MM -.... - -Import the different dashboards you want. - -You then need to enable reporting through ElasticSearch. Modify your -James ElasticSearch configuration file accordingly. To help you doing -this, you can take a look to -link:https://github.com/apache/james-project/blob/3.7.x/server/apps/distributed-app/sample-configuration/elasticsearch.properties[elasticsearch.properties]. - -If some metrics seem abnormally slow despite in depth database -performance tuning, feedback is appreciated as well on the bug tracker, -the user mailing list or our Gitter channel (see our -http://james.apache.org/#second[community page]) . Any additional -details categorizing the slowness are appreciated as well (details of -the slow requests for instance). +:other-metrics: Cassandra Java driver metrics +:xref-base: distributed +:server-name: Distributed James Server +include::partial$operate/metrics.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/operate/migrating.adoc b/docs/modules/servers/pages/distributed/operate/migrating.adoc index ce3d8da8cbe..c79a77a004c 100644 --- a/docs/modules/servers/pages/distributed/operate/migrating.adoc +++ b/docs/modules/servers/pages/distributed/operate/migrating.adoc @@ -1,34 +1,6 @@ = Distributed James Server — Migrating existing data :navtitle: Migrating existing data -This page presents how operators can migrate your user mailbox and mails into the Distributed Server in order to adopt it. - -We assume you have a xref:distributed/configure/index.adoc[well configured] running Distributed server -at hand. We also assume existing mails are hosted on a tier mail server which can be accessed via IMAP and supports -impersonation. - -First, you want to create the domains handled by your server, as well as the users you will be hosting. This operation -can be performed via WebAdmin or the CLI. - - * Using webadmin : - ** Read xref:distributed/operate/webadmin.adoc#_create_a_domain[this section] for creating domains - ** Read xref:distributed/operate/webadmin.adoc#_create_a_user[this section] for creating users - * Using the CLI : - ** Read xref:distributed/operate/cli.adoc#_manage_domains[this section] for creating domains - ** Read xref:distributed/operate/cli.adoc#_managing_users[this section] for creating users - -Second, you want to allow an administrator account of your Distributed Server to have write access on other user mailboxes. -This can be setted up this the *administratorId* configuration option of the xref:distributed/configure/usersrepository.adoc[usersrepository.xml] configuration file. - -Then, it is time to run https://github.com/imapsync/imapsync[imapsync] script to copy the emails from the previous mail server -into the Distributed Server. Here is an example migrating a single user, relying on impersonation: - -.... -imapsync --host1 previous.server.domain.tld \ - --user1 user@domain.tld --authuser1 adminOldServer@domain.tld \ - --proxyauth1 --password1 passwordOfTheOldAdmin \ - --host2 distributed.james.domain.tld \ - --user2 use1@domain.tld \ - --authuser2 adminNewServer@domain.tld --proxyauth2 \ - --password2 passwordOfTheNewAdmin -.... \ No newline at end of file +:xref-base: distributed +:server-name: Distributed James Server +include::partial$operate/migrating.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/operate/performanceChecklist.adoc b/docs/modules/servers/pages/distributed/operate/performanceChecklist.adoc index 65b60229136..ec5b35ea74f 100644 --- a/docs/modules/servers/pages/distributed/operate/performanceChecklist.adoc +++ b/docs/modules/servers/pages/distributed/operate/performanceChecklist.adoc @@ -1,22 +1,42 @@ = Distributed James Server — Performance checklist :navtitle: Performance checklist -This guide aims to help James operators refine their James configuration and set up to achieve better performance. +:xref-base: distributed +:backend-name: Cassandra +:mail-queue-name: CassandraMailQueueView +include::partial$operate/performanceChecklist.adoc[] -== Database setup +=== RabbitMQ + +We recommend against the use of the CassandraMailQueueView, as browsing and advanced queue management features +is unnecessary for Mail Delivery Agent and are not meaningful in the absence of delays. + +Similarly, we recommend turning off queue size metrics, which are expensive to compute. -Cassandra, OpenSearch, RabbitMQ is a large topic in itself that we do not intend to cover here. Yet, here are some -very basic recommendation that are always beneficial to keep in mind. +We also recommend against the use of publish confirms, which comes at a high performance price. + +In `rabbitmq.properties`: -We recommend: +.... +cassandra.view.enabled=false -* Running Cassandra, OpenSearch on commodity hardware with attached SSD. SAN disks are known to cause performance -issues for these technologies. HDD disks are to be banned for these performance related applications. -* We recommend getting an Object Storage SaaS offering that suites your needs. Most generalist S3 offers will suite -James needs. -* We do provide a guide on xref:[Database benchmarks] that can help identify and fix issues. +mailqueue.size.metricsEnabled=false -== James configuration +event.bus.publish.confirm.enabled=false +mailqueue.publish.confirm.enabled=false +.... + +=== Object storage + +We recommend the use of the blob store cache, which will be populated by email headers which shall be treated as metadata. + +`blob.properties`: + +.... +cache.enable=true +cache.cassandra.ttl=1year +cache.sizeThresholdInBytes=16 KiB +.... === Cassandra @@ -64,100 +84,4 @@ Cassandra overload. max-concurrent-requests = 192 } -.... - -=== Object storage - -We recommend the use of the blob store cache, which will be populated by email headers which shall be treated as metadata. - -`blob.properties`: - -.... -cache.enable=true -cache.cassandra.ttl=1year -cache.sizeThresholdInBytes=16 KiB -.... - -=== RabbitMQ - -We recommend against the use of the CassandraMailQueueView, as browsing and advanced queue management features -is unnecessary for Mail Delivery Agent and are not meaningful in the absence of delays. - -Similarly, we recommend turning off queue size metrics, which are expensive to compute. - -We also recommend against the use of publish confirms, which comes at a high performance price. - -In `rabbitmq.properties`: - -.... -cassandra.view.enabled=false - -mailqueue.size.metricsEnabled=false - -event.bus.publish.confirm.enabled=false -mailqueue.publish.confirm.enabled=false -.... - -=== JMAP protocol - -If you are not using JMAP, disabling it will avoid you the cost of populating related projections and thus is recommended. -Within `jmap.properties`: - -.... -enabled=false -.... - -We recommend turning on EmailQueryView as it enables resolution of mailbox listing against Cassandra, thus unlocking massive -stability / performance gains. Within `jmap.properties`: - -.... -view.email.query.enabled=true -.... - -=== IMAP / SMTP - -We recommend against resolving client connection DNS names. This behaviour can be disabled via a system property within -`jvm.properties`: - -.... -james.protocols.mdc.hostname=false -.... - -Concurrent IMAP request count is the critical setting. In `imapServer.xml`: - -.... -200 -4096 -.... - -Other recommendation includes avoiding unecessary work upon IMAP IDLE, not starting dedicated BOSS threads: - -.... -false -0 -.... - -=== Other generic recommendations - -* Remove unneeded listeners / mailets -* Reduce duplication of Matchers within mailetcontainer.xml -* Limit usage of "DEBUG" loglevel. INFO should be more than decent in most cases. -* While GC tunning is a science in itself, we had good results with G1GC and a low pause time: - -.... --Xlog:gc*:file=/root/gc.log -XX:MaxGCPauseMillis=20 -XX:ParallelGCThreads=2 -.... - -* We recommand tunning bach sizes: `batchsizes.properties`. This allows, limiting parallel S3 reads, while loading many -messages concurrently on Cassandra, and improves IMAP massive operations support. - -.... -fetch.metadata=200 -fetch.headers=30 -fetch.body=30 -fetch.full=30 - -copy=8192 - -move=8192 .... \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/operate/security.adoc b/docs/modules/servers/pages/distributed/operate/security.adoc index b70db53553a..6d59fd82b24 100644 --- a/docs/modules/servers/pages/distributed/operate/security.adoc +++ b/docs/modules/servers/pages/distributed/operate/security.adoc @@ -1,249 +1,6 @@ = Security checklist :navtitle: Security checklist -This document aims as summarizing threats, security best practices as well as recommendations. - -== Threats - -Operating an email server exposes you to the following threats: - - - Spammers might attempt to use your servers to send their spam messages on their behalf. We speak of -*open relay*. In addition to the resources consumed being an open relay will affect the trust other mail -installations have in you, and thus will cause legitimate traffic to be rejected. - - Emails mostly consist of private data, which shall only be accessed by their legitimate user. Failure -to do so might result in *information disclosure*. - - *Email forgery*. An attacker might craft an email on the behalf of legitimate users. - - Email protocols allow user to authenticate and thus can be used as *oracles* to guess user passwords. - - *Spam*. Non legitimate traffic can be a real burden to your users. - - *Phishing*: Crafted emails that tricks the user into doing unintended actions. - - *Viruses*: An attacker sends an attachment that contains an exploit that could run if a user opens it. - - *Denial of service*: A small request may result in a very large response and require considerable work on the server... - - *Denial of service*: A malicious JMAP client may use the JMAP push subscription to attempt to flood a third party -server with requests, creating a denial-of-service attack and masking the attacker’s true identity. - - *Dictionary Harvest Attacks*: An attacker can rely on SMTP command reply code to know if a user exists or not. This - can be used to obtain the list of local users and later use those address as targets for other attacks. - -== Best practices - -The following sections ranks best practices. - -=== Best practices: Must - - - 1. Configure James in order not to be an xref:distributed/configure/smtp.adoc#_about_open_relays[open relay]. This should be the -case with the default configuration. - -Be sure in xref:distributed/configure/smtp.adoc[smtpserver.xml] to activate the following options: `verifyIdentity`. - -We then recommend to manually test your installation in order to ensure that: - - - Unauthenticated SMTP users cannot send mails to external email addresses (they are not relayed) - - Unauthenticated SMTP users can send mails to internal email addresses - - Unauthenticated SMTP users cannot use local addresses in their mail from, and send emails both locally and to distant targets. - - - 2. Avoid *STARTTLS* usage and favor SSL. Upgrade from a non encrypted channel into an encrypted channel is an opportunity -for additional vulnerabilities. This is easily prevented by requiring SSL connection upfront. link:https://nostarttls.secvuln.info/[Read more...] - -Please note that STARTTLS is still beneficial in the context of email relaying, which happens on SMTP port 25 unencrypted, -and enable opportunistic encryption upgrades that would not overwise be possible. We recommend keeping STARTTLS activated -for SMTP port 25. - - - 3. Use SSL for xref:distributed/configure/mailets.adoc#_remotedelivery[remote delivery] whenever you are using a gateway relaying SMTP server. - - - 4. Rely on an external identity service, dedicated to user credential storage. James supports xref:distributed/configure/usersrepository.adoc#_configuring_a_ldap[LDAP]. If you are -forced to store users in James be sure to choose `PBKDF2` as a hashing algorithm. Also, delays on authentication failures -are supported via the `verifyFailureDelay` property. Note that IMAP / SMTP connections are closed after 3 authentication -failures. - - - 5. Ensure that xref:distributed/configure/webadmin.adoc[WebAdmin] is not exposed unencrypted to the outer world. Doing so trivially -exposes yourself. You can either disable it, activate JWT security, or restrict it to listen only on localhost. - - - 6. Set up `HTTPS` for http based protocols, namely *JMAP* and *WebAdmin*. We recommend the use of a reverse proxy like Nginx. - - - 7. Set up link:https://james.apache.org/howTo/spf.html[SPF] and link:https://james.apache.org/howTo/dkim.html[DKIM] -for your outgoing emails to be trusted. - - - 8. Prevent access to JMX. This can be achieved through a strict firewalling policy -(link:https://nickbloor.co.uk/2017/10/22/analysis-of-cve-2017-12628/[blocking port 9999 is not enough]) -or xref:distributed/configure/jmx.adoc[disabling JMX]. JMX is needed to use the existing CLI application but webadmin do offer similar -features. Set the `jmx.remote.x.mlet.allow.getMBeansFromURL` to `false` to disable JMX remote code execution feature. - - - 9. If JMAP is enabled, be sure that JMAP PUSH cannot be used for server side request forgery. This can be -xref:distributed/configure/jmap.adoc[configured] using the `push.prevent.server.side.request.forgery=true` property, -forbidding push to private addresses. - -=== Best practice: Should - - - 1. Avoid advertising login/authenticate capabilities in clear channels. This might prevent some clients to attempt login -on clear channels, and can be configured for both xref:distributed/configure/smtp.adoc[SMTP] and xref:distributed/configure/imap.adoc[IMAP] -using `auth.plainAuthEnabled=false`. - - - 2. Verify link:https://james.apache.org/howTo/spf.html[SPF] and xref:distributed/configure/mailets.adoc#_dkimverify[DKIM] for your incoming emails. - - - 3. Set up reasonable xref:distributed/operate/webadmin.adoc#_administrating_quotas[storage quota] for your users. - - - 4. We recommend setting up anti-spam and anti-virus solutions. James comes with some xref:distributed/configure/spam.adoc[Rspamd and SpamAssassin] -integration, and some xref:distributed/configure/mailets.adoc#_clamavscan[ClamAV] tooling exists. -Rspamd supports anti-phishing modules. -Filtering with third party systems upstream is also possible. - - - 5. In order to limit your attack surface, disable protocols you or your users do not use. This includes the JMAP protocol, -POP3, ManagedSieve, etc... Be conservative on what you expose. - - - 6. If operating behind a load-balancer, set up the link:https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt[PROXY protocol] for -TCP based protocols (IMAP and SMTP `proxyRequired` option) - -=== Best practice: Could - - - 1. Set up link:https://openid.net/connect/[OIDC] for IMAP, SMTP and JMAP. Disable login/plain/basic authentication. - - - 2. You can configure xref:distributed/configure/ssl.adoc#_client_authentication_via_certificates[Client authentication via certificates]. - - - 3. You can xref:distributed/configure/mailets.adoc#_smimesign[sign], xref:distributed/configure/mailets.adoc#_smimechecksignature[verify] -and xref:distributed/configure/mailets.adoc#_smimedecrypt[decrypt] your email traffic using link:https://datatracker.ietf.org/doc/html/rfc5751[SMIME]. - -== Known vulnerabilities - -Several vulnerabilities have had been reported for previous releases of Apache James server. - -Be sure not to run those! We highly recommend running the latest release, which we put great effort in not to use -outdated dependencies. - -=== Reporting vulnerabilities - -We follow the standard procedures within the ASF regarding link:https://apache.org/security/committers.html#vulnerability-handling[vulnerability handling] - -=== CVE-2024-21742: Mime4J DOM header injection - -Apache JAMES MIME4J prior to version 0.8.10 allow attackers able to specify the value of a header field to craft other header fields. - -*Severity*: Moderate - -*Mitigation*: Release 0.8.10 rejects the use of LF inside a header field thus preventing the issue. - -Upgrading to Apache James MIME4J 0.8.10 is thus advised. - -=== CVE-2023-51747: SMTP smuggling in Apache James - -Apache James distribution prior to release 3.7.5 and release 3.8.1 is subject to SMTP smuggling, when used in combination -of antother vulnerable server and can result in SPF bypass, leading to email forgery. - -*Severity*: High - -*Mitigation*: Release 3.7.5 and 3.8.1 interpret strictly the CRLF delimiter and thus prevent the issue. - -Upgrading to Apache James 3.7.5 or 3.8.1 is thus advised. - -=== CVE-2023-51518: Privilege escalation via JMX pre-authentication deserialisation - -Apache James distribution prior to release 3.7.5 and 3.8.1 allow privilege escalation via JMX pre-authentication deserialisation. -An attacker would need to identify a deserialization glitch before triggering an exploit. - -*Severity*: Moderate - -*Mitigation*:We recommend turning off JMX whenever possible. - -Release 3.7.5 and 3.8.1 disable deserialization on unauthencited channels. - -Upgrading to Apache James 3.7.5 on 3.8.1 is thus advised. - - -=== CVE-2023-26269: Privilege escalation through unauthenticated JMX - -Apache James distribution prior to release 3.7.4 allows privilege escalation through the use of JMX. - -*Severity*: Moderate - -*Mitigation*: We recommend turning on authentication on. If the CLI is unused we recommend turning JMX off. - -Release 3.7.4 set up implicitly JMX authentication for Guice based products and addresses the underlying JMX exploits. - -Upgrading to Apache James 3.7.4 is thus advised. - -=== CVE-2022-45935: Temporary File Information Disclosure in Apache JAMES - -Apache James distribution prior to release 3.7.3 is vulnerable to a temporary File Information Disclosure. - -*Severity*: Moderate - -*Mitigation*: We recommend to upgrade to Apache James 3.7.3 or higher, which fixes this vulnerability. - - -=== CVE-2021-44228: STARTTLS command injection in Apache JAMES - -Apache James distribution prior to release 3.7.1 is vulnerable to a buffering attack relying on the use of the STARTTLS command. - -Fix of CVE-2021-38542, which solved similar problem from Apache James 3.6.1, is subject to a parser differential and do not take into account concurrent requests. - -*Severity*: Moderate - -*Mitigation*: We recommend to upgrade to Apache James 3.7.1 or higher, which fixes this vulnerability. - -=== CVE-2021-38542: Apache James vulnerable to STARTTLS command injection (IMAP and POP3) - -Apache James prior to release 3.6.1 is vulnerable to a buffering attack relying on the use of the STARTTLS -command. This can result in Man-in -the-middle command injection attacks, leading potentially to leakage -of sensible information. - -*Severity*: Moderate - -This issue is being tracked as link:https://issues.apache.org/jira/browse/JAMES-1862[JAMES-1862] - -*Mitigation*: We recommend upgrading to Apache James 3.6.1, which fixes this vulnerability. - -Furthermore, we recommend, if possible to dis-activate STARTTLS and rely solely on explicit TLS for mail protocols, including SMTP, IMAP and POP3. - -Read more link:https://nostarttls.secvuln.info/[about STARTTLS security here]. - -=== CVE-2021-40110: Apache James IMAP vulnerable to a ReDoS - -Using Jazzer fuzzer, we identified that an IMAP user can craft IMAP LIST commands to orchestrate a Denial -Of Service using a vulnerable Regular expression. This affected Apache James prior to 3.6.1 - -*Severity*: Moderate - -This issue is being tracked as link:https://issues.apache.org/jira/browse/JAMES-3635[JAMES-3635] - -*Mitigation*: We recommend upgrading to Apache James 3.6.1, which enforce the use of RE2J regular -expression engine to execute regex in linear time without back-tracking. - -=== CVE-2021-40111: Apache James IMAP parsing Denial Of Service - -While fuzzing with Jazzer the IMAP parsing stack we discover that crafted APPEND and STATUS IMAP command -could be used to trigger infinite loops resulting in expensive CPU computations and OutOfMemory exceptions. -This can be used for a Denial Of Service attack. The IMAP user needs to be authenticated to exploit this -vulnerability. This affected Apache James prior to version 3.6.1. - -*Severity*: Moderate - -This issue is being tracked as link:https://issues.apache.org/jira/browse/JAMES-3634[JAMES-3634] - -*Mitigation*: We recommend upgrading to Apache James 3.6.1, which fixes this vulnerability. - -=== CVE-2021-40525: Apache James: Sieve file storage vulnerable to path traversal attacks - -Apache James ManagedSieve implementation alongside with the file storage for sieve scripts is vulnerable -to path traversal, allowing reading and writing any file. - -*Severity*: Moderate - -This issue is being tracked as link:https://issues.apache.org/jira/browse/JAMES-3646[JAMES-3646] - -*Mitigation*:This vulnerability had been patched in Apache James 3.6.1 and higher. We recommend the upgrade. - -This could also be mitigated by ensuring manageSieve is disabled, which is the case by default. - -Distributed and Cassandra based products are also not impacted. - -=== CVE-2017-12628 Privilege escalation using JMX - -The Apache James Server prior version 3.0.1 is vulnerable to Java deserialization issues. -One can use this for privilege escalation. -This issue can be mitigated by: - - - Upgrading to James 3.0.1 onward - - Using a recent JRE (Exploit could not be reproduced on OpenJdk 8 u141) - - Exposing JMX socket only to localhost (default behaviour) - - Possibly running James in a container - - Disabling JMX all-together (Guice only) - -Read more link:http://james.apache.org//james/update/2017/10/20/james-3.0.1.html[here]. \ No newline at end of file +:xref-base: distributed +:backend-name: Cassandra +include::partial$operate/security.adoc[] diff --git a/docs/modules/servers/pages/distributed/operate/webadmin.adoc b/docs/modules/servers/pages/distributed/operate/webadmin.adoc index 8a452f37556..eccbabc759b 100644 --- a/docs/modules/servers/pages/distributed/operate/webadmin.adoc +++ b/docs/modules/servers/pages/distributed/operate/webadmin.adoc @@ -1,4291 +1,13 @@ = Distributed James Server — WebAdmin REST administration API :navtitle: WebAdmin REST administration API -The web administration supports for now the CRUD operations on the domains, the users, their mailboxes and their quotas, - managing mail repositories, performing cassandra migrations, and much more, as described in the following sections. - -*WARNING*: This API allow authentication only via the use of JWT. If not -configured with JWT, an administrator should ensure an attacker can not -use this API. - -By the way, some endpoints are not filtered by authentication. Those endpoints are not related to data stored in James, -for example: Swagger documentation & James health checks. - -In case of any error, the system will return an error message which is -json format like this: - -.... -{ - statusCode: , - type: , - message: - cause: -} -.... - -Also be aware that, in case things go wrong, all endpoints might return -a 500 internal error (with a JSON body formatted as exposed above). To -avoid information duplication, this is omitted on endpoint specific -documentation. - -Finally, please note that in case of a malformed URL the 400 bad request -response will contain an HTML body. - -== HealthCheck - -=== Check all components - -This endpoint is simple for now and is just returning the http status -code corresponding to the state of checks (see below). The user has to -check in the logs in order to have more information about failing -checks. - -.... -curl -XGET http://ip:port/healthcheck -.... - -Will return a list of healthChecks execution result, with an aggregated -result: - -.... -{ - "status": "healthy", - "checks": [ - { - "componentName": "Cassandra backend", - "escapedComponentName": "Cassandra%20backend", - "status": "healthy" - "cause": null - } - ] -} -.... - -*status* field can be: - -* *healthy*: Component works normally -* *degraded*: Component works in degraded mode. Some non-critical -services may not be working, or latencies are high, for example. Cause -contains explanations. -* *unhealthy*: The component is currently not working. Cause contains -explanations. - -Supported health checks include: - -* *Cassandra backend*: Cassandra storage. -* *OpenSearch Backend*: OpenSearch storage. -* *EventDeadLettersHealthCheck* -* *Guice application lifecycle* -* *JPA Backend*: JPA storage. -* *MailReceptionCheck* We rely on a configured user, send an email to him and -assert that the email is well received, and can be read within the given configured -period. Unhealthy means that the email could not be received before reacing the timeout. -* *MessageFastViewProjection* Health check of the component storing JMAP properties -which are fast to retrieve. Those properties are computed in advance -from messages and persisted in order to archive a better performance. -There are some latencies between a source update and its projections -updates. Incoherency problems arise when reads are performed in this -time-window. We piggyback the projection update on missed JMAP read in -order to decrease the outdated time window for a given entry. The health -is determined by the ratio of missed projection reads. (lower than 10% -causes `degraded`) -* *RabbitMQ backend*: RabbitMQ messaging. - -Response codes: - -* 200: All checks have answered with a Healthy or Degraded status. James -services can still be used. -* 503: At least one check have answered with a Unhealthy status - -=== Check single component - -Performs a health check for the given component. The component is -referenced by its URL encoded name. - -.... -curl -XGET http://ip:port/healthcheck/checks/Cassandra%20backend -.... - -Will return the component’s name, the component’s escaped name, the -health status and a cause. - -.... -{ - "componentName": "Cassandra backend", - "escapedComponentName": "Cassandra%20backend", - "status": "healthy" - "cause": null -} -.... - -Response codes: - -* 200: The check has answered with a Healthy or Degraded status. -* 404: A component with the given name was not found. -* 503: The check has answered with an Unhealthy status. - -=== List all health checks - -This endpoint lists all the available health checks. - -.... -curl -XGET http://ip:port/healthcheck/checks -.... - -Will return the list of all available health checks. - -.... -[ - { - "componentName": "Cassandra backend", - "escapedComponentName": "Cassandra%20backend" - } -] -.... - -Response codes: - -* 200: List of available health checks - -== Task management - -Some webadmin features schedule tasks. The task management API allow to -monitor and manage the execution of the following tasks. - -Note that the `taskId` used in the following APIs is returned by other -WebAdmin APIs scheduling tasks. - -=== Getting a task details - -.... -curl -XGET http://ip:port/tasks/3294a976-ce63-491e-bd52-1b6f465ed7a2 -.... - -An Execution Report will be returned: - -.... -{ - "submitDate": "2017-12-27T15:15:24.805+0700", - "startedDate": "2017-12-27T15:15:24.809+0700", - "completedDate": "2017-12-27T15:15:24.815+0700", - "cancelledDate": null, - "failedDate": null, - "taskId": "3294a976-ce63-491e-bd52-1b6f465ed7a2", - "additionalInformation": {}, - "status": "completed", - "type": "type-of-the-task" -} -.... - -Note that: - -* `status` can have the value: -** `waiting`: The task is scheduled but its execution did not start yet -** `inProgress`: The task is currently executed -** `cancelled`: The task had been cancelled -** `completed`: The task execution is finished, and this execution is a -success -** `failed`: The task execution is finished, and this execution is a -failure -* `additionalInformation` is a task specific object giving additional -information and context about that task. The structure of this -`additionalInformation` field is provided along the specific task -submission endpoint. - -Response codes: - -* 200: The specific task was found and the execution report exposed -above is returned -* 400: Invalid task ID -* 404: Task ID was not found - -=== Awaiting a task - -One can await the end of a task, then receive its final execution -report. - -That feature is especially usefully for testing purpose but still can -serve real-life scenario. - -.... -curl -XGET http://ip:port/tasks/3294a976-ce63-491e-bd52-1b6f465ed7a2/await?timeout=duration -.... - -An Execution Report will be returned. - -`timeout` is optional. By default it is set to 365 days (the maximum -value). The expected value is expressed in the following format: -`Nunit`. `N` should be strictly positive. `unit` could be either in the -short form (`s`, `m`, `h`, etc.), or in the long form (`day`, `week`, -`month`, etc.). - -Examples: - -* `30s` -* `5m` -* `7d` -* `1y` - -Response codes: - -* 200: The specific task was found and the execution report exposed -above is returned -* 400: Invalid task ID or invalid timeout -* 404: Task ID was not found -* 408: The timeout has been reached - -=== Cancelling a task - -You can cancel a task by calling: - -.... -curl -XDELETE http://ip:port/tasks/3294a976-ce63-491e-bd52-1b6f465ed7a2 -.... - -Response codes: - -* 204: Task had been cancelled -* 400: Invalid task ID - -=== Listing tasks - -A list of all tasks can be retrieved: - -.... -curl -XGET http://ip:port/tasks -.... - -Will return a list of Execution reports - -One can filter the above results by status. For example: - -.... -curl -XGET http://ip:port/tasks?status=inProgress -.... - -Will return a list of Execution reports that are currently in progress. This list is sorted by -reverse submitted date (recent tasks goes first). - -Response codes: - -* 200: A list of corresponding tasks is returned -* 400: Invalid status value - -Additional optional task parameters are supported: - -- `status` one of `waiting`, `inProgress`, `canceledRequested`, `completed`, `canceled`, `failed`. Only -tasks with the given status are returned. -- `type`: only tasks with the given type are returned. -- `submittedBefore`: Date. Returns only tasks submitted before this date. -- `submittedAfter`: Date. Returns only tasks submitted after this date. -- `startedBefore`: Date. Returns only tasks started before this date. -- `startedAfter`: Date. Returns only tasks started after this date. -- `completedBefore`: Date. Returns only tasks completed before this date. -- `completedAfter`: Date. Returns only tasks completed after this date. -- `failedBefore`: Date. Returns only tasks failed before this date. -- `failedAfter`: Date. Returns only tasks faield after this date. -- `offset`: Integer, number of tasks to skip in the response. Useful for paging. -- `limit`: Integer, maximum number of tasks to return in one call - -Example of date format: `2023-04-15T07:23:27.541254+07:00` and `2023-04-15T07%3A23%3A27.541254%2B07%3A00` once URL encoded. - -=== Endpoints returning a task - -Many endpoints do generate a task. - -Example: - -.... -curl -XPOST /endpoint?action={action} -.... - -The response to these requests will be the scheduled `taskId` : - -.... -{"taskId":"5641376-02ed-47bd-bcc7-76ff6262d92a"} -.... - -Positionned headers: - -* Location header indicates the location of the resource associated with -the scheduled task. Example: - -.... -Location: /tasks/3294a976-ce63-491e-bd52-1b6f465ed7a2 -.... - -Response codes: - -* 201: Task generation succeeded. Corresponding task id is returned. -* Other response codes might be returned depending on the endpoint - -The additional information returned depends on the scheduled task type -and is documented in the endpoint documentation. - -== Administrating domains - -=== Create a domain - -.... -curl -XPUT http://ip:port/domains/domainToBeCreated -.... - -Resource name domainToBeCreated: - -* can not be null or empty -* can not contain `@' -* can not be more than 255 characters -* can not contain `/' - -Response codes: - -* 204: The domain was successfully added -* 400: The domain name is invalid - -=== Delete a domain - -.... -curl -XDELETE http://ip:port/domains/{domainToBeDeleted} -.... - -Note: Deletion of an auto-detected domain, default domain or of an -auto-detected ip is not supported. We encourage you instead to review -your https://james.apache.org/server/config-domainlist.html[domain list -configuration]. - -Response codes: - -* 204: The domain was successfully removed - -=== Test if a domain exists - -.... -curl -XGET http://ip:port/domains/{domainName} -.... - -Response codes: - -* 204: The domain exists -* 404: The domain does not exist - -=== Get the list of domains - -.... -curl -XGET http://ip:port/domains -.... - -Possible response: - -.... -["domain1", "domain2"] -.... - -Response codes: - -* 200: The domain list was successfully retrieved - -=== Get the list of aliases for a domain - -.... -curl -XGET http://ip:port/domains/destination.domain.tld/aliases -.... - -Possible response: - -.... -[ - {"source": "source1.domain.tld"}, - {"source": "source2.domain.tld"} -] -.... - -When sending an email to an email address having `source1.domain.tld` or -`source2.domain.tld` as a domain part (example: -`user@source1.domain.tld`), then the domain part will be rewritten into -destination.domain.tld (so into `user@destination.domain.tld`). - -Response codes: - -* 200: The domain aliases was successfully retrieved -* 400: destination.domain.tld has an invalid syntax -* 404: destination.domain.tld is not part of handled domains and does -not have local domains as aliases. - -=== Create an alias for a domain - -To create a domain alias execute the following query: - -.... -curl -XPUT http://ip:port/domains/destination.domain.tld/aliases/source.domain.tld -.... - -When sending an email to an email address having `source.domain.tld` as -a domain part (example: `user@source.domain.tld`), then the domain part -will be rewritten into `destination.domain.tld` (so into -`user@destination.domain.tld`). - -Response codes: - -* 204: The redirection now exists -* 400: `source.domain.tld` or `destination.domain.tld` have an invalid -syntax -* 400: `source, domain` and `destination domain` are the same -* 404: `source.domain.tld` are not part of handled domains. - -Be aware that no checks to find possible loops that would result of this creation will be performed. - -=== Delete an alias for a domain - -To delete a domain alias execute the following query: - -.... -curl -XDELETE http://ip:port/domains/destination.domain.tld/aliases/source.domain.tld -.... - -When sending an email to an email address having `source.domain.tld` as -a domain part (example: `user@source.domain.tld`), then the domain part -will be rewritten into `destination.domain.tld` (so into -`user@destination.domain.tld`). - -Response codes: - -* 204: The redirection now no longer exists -* 400: `source.domain.tld` or destination.domain.tld have an invalid -syntax -* 400: source, domain and destination domain are the same -* 404: `source.domain.tld` are not part of handled domains. - -=== Delete all users data of a domain - -.... -curl -XPOST http://ip:port/domains/{domainToBeUsed}?action=deleteData -.... - -Would create a task that deletes data of all users of the domain. - -[More details about endpoints returning a task](#_endpoints_returning_a_task). - -Response codes: - -* 201: Success. Corresponding task id is returned. -* 400: Error in the request. Details can be found in the reported error. - -The scheduled task will have the following type `DeleteUsersDataOfDomainTask` and the following `additionalInformation`: - -.... -{ - "type": "DeleteUsersDataOfDomainTask", - "domain": "domain.tld", - "successfulUsersCount": 2, - "failedUsersCount": 1, - "failedUsers": ["faileduser@domain.tld"], - "timestamp": "2023-05-22T08:52:47.076261Z" -} -.... - -Notes: `failedUsers` only lists maximum 100 failed users. - -== Administrating users - -=== Create a user - -.... -curl -XPUT http://ip:port/users/usernameToBeUsed \ - -d '{"password":"passwordToBeUsed"}' \ - -H "Content-Type: application/json" -.... - -Resource name usernameToBeUsed representing valid users, hence it should -match the criteria at xref:distributed/configure/usersrepository.adoc[User Repositories documentation] - -Response codes: - -* 204: The user was successfully created -* 400: The user name or the payload is invalid -* 409: The user name already exists - -Note: If the user exists already, its password cannot be updated using this. -If you want to update a user's password, please have a look at *Update a user password* below. - -=== Updating a user password - -.... -curl -XPUT http://ip:port/users/usernameToBeUsed?force \ - -d '{"password":"passwordToBeUsed"}' \ - -H "Content-Type: application/json" -.... - -Response codes: - -- 204: The user's password was successfully updated -- 400: The user name or the payload is invalid - -This also can be used to create a new user. - -=== Verifying a user password - -.... -curl -XPOST http://ip:port/users/usernameToBeUsed/verify \ - -d '{"password":"passwordToBeVerified"}' \ - -H "Content-Type: application/json" -.... - -Response codes: - -- 204: The user's password was correct -- 401: Wrong password or user does not exist -- 400: The user name or the payload is invalid - -This intentionally treats non-existing users as unauthenticated, to prevent a username oracle attack. - -=== Testing a user existence - -.... -curl -XHEAD http://ip:port/users/usernameToBeUsed -.... - -Resource name ``usernameToBeUsed'' represents a valid user, hence it -should match the criteria at xref:distributed/configure/usersrepository.adoc[User Repositories documentation] - -Response codes: - -* 200: The user exists -* 400: The user name is invalid -* 404: The user does not exist - -=== Deleting a user - -.... -curl -XDELETE http://ip:port/users/{userToBeDeleted} -.... - -Response codes: - -* 204: The user was successfully deleted - -=== Retrieving the user list - -.... -curl -XGET http://ip:port/users -.... - -The answer looks like: - -.... -[{"username":"username@domain-jmapauthentication.tld"},{"username":"username@domain.tld"}] -.... - -Response codes: - -* 200: The user name list was successfully retrieved - -=== Retrieving the list of allowed `From` headers for a given user - -This endpoint allows to know which From headers a given user is allowed to use when sending mails. - -.... -curl -XGET http://ip:port/users/givenUser/allowedFromHeaders -.... - -The answer looks like: - -.... -["user@domain.tld","alias@domain.tld"] -.... - -Response codes: - -* 200: The list was successfully retrieved -* 400: The user is invalid -* 404: The user is unknown - -=== Add a delegated user of a base user - -.... -curl -XPUT http://ip:port/users/baseUser/authorizedUsers/delegatedUser -.... - -Response codes: - -* 200: Addition of the delegated user succeeded -* 404: The base user does not exist -* 400: The delegated user does not exist - -Note: Delegation is only available on top of Cassandra products and not implemented yet on top of JPA backends. - -=== Remove a delegated user of a base user - -.... -curl -XDELETE http://ip:port/users/baseUser/authorizedUsers/delegatedUser -.... - -Response codes: - -* 200: Removal of the delegated user succeeded -* 404: The base user does not exist -* 400: The delegated user does not exist - -Note: Delegation is only available on top of Cassandra products and not implemented yet on top of JPA backends. - -=== Retrieving the list of delegated users of a base user - -.... -curl -XGET http://ip:port/users/baseUser/authorizedUsers -.... - -The answer looks like: - -.... -["alice@domain.tld","bob@domain.tld"] -.... - -Response codes: - -* 200: The list was successfully retrieved -* 404: The base user does not exist - -Note: Delegation is only available on top of Cassandra products and not implemented yet on top of JPA backends. - -=== Remove all delegated users of a base user - -.... -curl -XDELETE http://ip:port/users/baseUser/authorizedUsers -.... - -Response codes: - -* 200: Removal of the delegated users succeeded -* 404: The base user does not exist - -Note: Delegation is only available on top of Cassandra products and not implemented yet on top of JPA backends. - -=== Change a username - -.... -curl -XPOST http://ip:port/users/oldUser/rename/newUser?action=rename -.... - -Would migrate account data from `oldUser` to `newUser`. - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -Implemented migration steps are: - - - `ForwardUsernameChangeTaskStep`: creates forward from old user to new user and migrates existing forwards - - `FilterUsernameChangeTaskStep`: migrates users filtering rules - - `DelegationUsernameChangeTaskStep`: migrates delegations where the impacted user is either delegatee or delegator - - `MailboxUsernameChangeTaskStep`: migrates mailboxes belonging to the old user to the account of the new user. It also - migrates user's mailbox subscriptions. - - `ACLUsernameChangeTaskStep`: migrates ACLs on mailboxes the migrated user has access to and updates subscriptions accordingly. - - `QuotaUsernameChangeTaskStep`: migrates quotas user from old user to new user. - -Response codes: - -* 201: Success. Corresponding task id is returned. -* 400: Error in the request. Details can be found in the reported error. If you encounter the error "'oldUser' parameter should be an existing user," please note that this validation can be bypassed by specifying the `force` query parameter. - -The `fromStep` query parameter allows skipping previous steps, allowing to resume the username change from a failed step. - -The scheduled task will have the following type `UsernameChangeTask` and the following `additionalInformation`: - -.... -{ - "type": "UsernameChangeTask", - "oldUser": "jessy.jones@domain.tld", - "newUser": "jessy.smith@domain.tld", - "status": { - "A": "DONE", - "B": "FAILED", - "C": "ABORTED" - }, - "fromStep": null, - "timestamp": "2023-02-17T02:54:01.246477Z" -} -.... - -Valid status includes: - - - `SKIPPED`: bypassed via `fromStep` setting - - `WAITING`: Awaits execution - - `IN_PROGRESS`: Currently executed - - `FAILED`: Error encountered while executing this step. Check the logs. - - `ABORTED`: Won't be executed because of previous step failures. - -=== Delete data of a user - -.... -curl -XPOST http://ip:port/users/usernameToBeUsed?action=deleteData -.... - -Would create a task that deletes data of the user. - -link:#_endpoints_returning_a_task[More details about endpoints returning a task]. - -Implemented deletion steps are: - - - `RecipientRewriteTableUserDeletionTaskStep`: deletes all rewriting rules related to this user. - - `FilterUserDeletionTaskStep`: deletes all filters belonging to the user. - - `DelegationUserDeletionTaskStep`: deletes all delegations from / to the user. - - `MailboxUserDeletionTaskStep`: deletes mailboxes of this user, all ACLs of this user, as well as his subscriptions. - - `WebPushUserDeletionTaskStep`: deletes push data registered for this user. - - `IdentityUserDeletionTaskStep`: deletes identities registered for this user. - - `VacationUserDeletionTaskStep`: deletes vacations registered for this user. - -Response codes: - -* 201: Success. Corresponding task id is returned. -* 400: Error in the request. Details can be found in the reported error. - -The `fromStep` query parameter allows skipping previous steps, allowing to resume the user data deletion from a failed step. - -The scheduled task will have the following type `DeleteUserDataTask` and the following `additionalInformation`: - -.... -{ - "type": "DeleteUserDataTask", - "username": "jessy.jones@domain.tld", - "status": { - "A": "DONE", - "B": "FAILED", - "C": "ABORTED" - }, - "fromStep": null, - "timestamp": "2023-02-17T02:54:01.246477Z" -} -.... - -Valid status includes: - - - `SKIPPED`: bypassed via `fromStep` setting - - `WAITING`: Awaits execution - - `IN_PROGRESS`: Currently executed - - `FAILED`: Error encountered while executing this step. Check the logs. - - `ABORTED`: Won't be executed because of previous step failures. - -=== Retrieving the user identities - -.... -curl -XGET http://ip:port/users/{baseUser}/identities?default=true -.... - -API to get the list of identities of a user - -The response will look like: - -``` -[ - { - "name":"identity name 1", - "email":"bob@domain.tld", - "id":"4c039533-75b9-45db-becc-01fb0e747aa8", - "mayDelete":true, - "textSignature":"textSignature 1", - "htmlSignature":"htmlSignature 1", - "sortOrder":1, - "bcc":[ - { - "emailerName":"bcc name 1", - "mailAddress":"bcc1@domain.org" - } - ], - "replyTo":[ - { - "emailerName":"reply name 1", - "mailAddress":"reply1@domain.org" - } - ] - } -] -``` - -Query parameters: - -* default: (Optional) allows getting the default identity of a user. In order to do that: `default=true` - -Response codes: - -* 200: The list was successfully retrieved -* 400: The user is invalid -* 404: The user is unknown or the default identity can not be found. - -The optional `default` query parameter allows getting the default identity of a user. -In order to do that: `default=true` - -The web-admin server will return `404` response code when the default identity can not be found. - -=== Creating a JMAP user identity - -API to create a new JMAP user identity -.... -curl -XPOST http://ip:port/users/{username}/identities \ --d '{ - "name": "Bob", - "email": "bob@domain.tld", - "mayDelete": true, - "htmlSignature": "a html signature", - "textSignature": "a text signature", - "bcc": [{ - "email": "boss2@domain.tld", - "name": "My Boss 2" - }], - "replyTo": [{ - "email": "boss@domain.tld", - "name": "My Boss" - }], - "sortOrder": 0 - }' \ --H "Content-Type: application/json" -.... - -Response codes: - -* 201: The new identity was successfully created -* 404: The username is unknown -* 400: The payload is invalid - -Resource name ``username'' represents a valid user - -=== Updating a JMAP user identity - -API to update an exist JMAP user identity -.... -curl -XPUT http://ip:port/users/{username}/identities/{identityId} \ --d '{ - "name": "Bob", - "htmlSignature": "a html signature", - "textSignature": "a text signature", - "bcc": [{ - "email": "boss2@domain.tld", - "name": "My Boss 2" - }], - "replyTo": [{ - "email": "boss@domain.tld", - "name": "My Boss" - }], - "sortOrder": 1 - }' \ --H "Content-Type: application/json" -.... - -Response codes: - -* 204: The identity were successfully updated -* 404: The username is unknown -* 400: The payload is invalid - -Resource name ``username'' represents a valid user -Resource name ``identityId'' represents a exist user identity - -== Administrating vacation settings - -=== Get vacation settings - -.... -curl -XGET http://ip:port/vacation/usernameToBeUsed -.... - -Resource name usernameToBeUsed representing valid users, hence it should -match the criteria at xref:distributed/configure/usersrepository.adoc[User Repositories documentation] - -The response will look like this: - -.... -{ - "enabled": true, - "fromDate": "2021-09-20T10:00:00Z", - "toDate": "2021-09-27T18:00:00Z", - "subject": "Out of office", - "textBody": "I am on vacation, will be back soon.", - "htmlBody": "

    I am on vacation, will be back soon.

    " -} -.... - -Response codes: - -* 200: The vacation settings were successfully retrieved -* 404: The user name is unknown - -=== Update vacation settings - -.... -curl -XPOST http://ip:port/vacation/usernameToBeUsed -.... - -Request body must be a JSON structure as described above. - -If any field is not set in the request, the corresponding field in the existing vacation message is left unchanged. - -Response codes: - -* 204: The vacation settings were successfully updated -* 404: The user name is unknown -* 400: The payload is invalid - -=== Delete vacation settings - -.... -curl -XDELETE http://ip:port/vacation/usernameToBeUsed -.... - -For convenience, this disables and clears the existing vacation settings of the user. - -Response codes: - -* 204: The vacation settings were successfully disabled -* 404: The user name is unknown - -== Administrating mailboxes - -=== All mailboxes - -Several actions can be performed on the server mailboxes. - -Request pattern is: - -.... -curl -XPOST /mailboxes?action={action1},... -.... - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -Response codes: - -* 201: Success. Corresponding task id is returned. -* 400: Error in the request. Details can be found in the reported error. - -The kind of task scheduled depends on the action parameter. See below -for details. - -==== Fixing mailboxes inconsistencies - -.... -curl -XPOST /mailboxes?task=SolveInconsistencies -.... - -Will schedule a task for fixing inconsistencies for the mailbox -deduplicated object stored in Cassandra. - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -The `I-KNOW-WHAT-I-M-DOING` header is mandatory (you can read more -information about it in the warning section below). - -The scheduled task will have the following type -`solve-mailbox-inconsistencies` and the following -`additionalInformation`: - -.... -{ - "type":"solve-mailbox-inconsistencies", - "processedMailboxEntries": 3, - "processedMailboxPathEntries": 3, - "fixedInconsistencies": 2, - "errors": 1, - "conflictingEntries":[{ - "mailboxDaoEntry":{ - "mailboxPath":"#private:user:mailboxName", - "mailboxId":"464765a0-e4e7-11e4-aba4-710c1de3782b" - }," + - "mailboxPathDaoEntry":{ - "mailboxPath":"#private:user:mailboxName2", - "mailboxId":"464765a0-e4e7-11e4-aba4-710c1de3782b" - } - }] -} -.... - -Note that conflicting entry inconsistencies will not be fixed and will -require to explicitly use link:#_correcting_ghost_mailbox[ghost mailbox] -endpoint in order to merge the conflicting mailboxes and prevent any -message loss. - -*WARNING*: this task can cancel concurrently running legitimate user -operations upon dirty read. As such this task should be run offline. - -A dirty read is when data is read between the two writes of the -denormalization operations (no isolation). - -In order to ensure being offline, stop the traffic on SMTP, JMAP and -IMAP ports, for example via re-configuration or firewall rules. - -Due to all of those risks, a `I-KNOW-WHAT-I-M-DOING` header should be -positioned to `ALL-SERVICES-ARE-OFFLINE` in order to prevent accidental -calls. - -==== Recomputing mailbox counters - -.... -curl -XPOST /mailboxes?task=RecomputeMailboxCounters -.... - -Will recompute counters (unseen & total count) for the mailbox object -stored in Cassandra. - -Cassandra maintains a per mailbox projection for message count and -unseen message count. As with any projection, it can go out of sync, -leading to inconsistent results being returned to the client. - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -The scheduled task will have the following type -`recompute-mailbox-counters` and the following `additionalInformation`: - -.... -{ - "type":"recompute-mailbox-counters", - "processedMailboxes": 3, - "failedMailboxes": ["464765a0-e4e7-11e4-aba4-710c1de3782b"] -} -.... - -Note that conflicting inconsistencies entries will not be fixed and will -require to explicitly use link:#_correcting_ghost_mailbox[ghost mailbox] -endpoint in order to merge the conflicting mailboxes and prevent any -message loss. - -*WARNING*: this task do not take into account concurrent modifications -upon a single mailbox counter recomputation. Rerunning the task will -_eventually_ provide the consistent result. As such we advise to run -this task offline. - -In order to ensure being offline, stop the traffic on SMTP, JMAP and -IMAP ports, for example via re-configuration or firewall rules. - -`trustMessageProjection` query parameter can be set to `true`. Content -of `messageIdTable` (listing messages by their mailbox context) table -will be trusted and not compared against content of `imapUidTable` table -(listing messages by their messageId mailbox independent identifier). -This will result in a better performance running the task at the cost of -safety in the face of message denormalization inconsistencies. - -Defaults to false, which generates additional checks. You can read -https://github.com/apache/james-project/blob/master/src/adr/0022-cassandra-message-inconsistency.md[this -ADR] to better understand the message projection and how it can become -inconsistent. - -==== Recomputing Global JMAP fast message view projection - -Message fast view projection stores message properties expected to be -fast to fetch but are actually expensive to compute, in order for -GetMessages operation to be fast to execute for these properties. - -These projection items are asynchronously computed on mailbox events. - -You can force the full projection recomputation by calling the following -endpoint: - -.... -curl -XPOST /mailboxes?task=recomputeFastViewProjectionItems -.... - -Will schedule a task for recomputing the fast message view projection -for all mailboxes. - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -An admin can specify the concurrency that should be used when running -the task: - -* `messagesPerSecond` rate at which messages should be processed, per -second. Defaults to 10. - -This optional parameter must have a strictly positive integer as a value -and be passed as query parameters. - -Example: - -.... -curl -XPOST /mailboxes?task=recomputeFastViewProjectionItems&messagesPerSecond=20 -.... - -The scheduled task will have the following type -`RecomputeAllFastViewProjectionItemsTask` and the following -`additionalInformation`: - -.... -{ - "type":"RecomputeAllPreviewsTask", - "processedUserCount": 3, - "processedMessageCount": 3, - "failedUserCount": 2, - "failedMessageCount": 1, - "runningOptions": { - "messagesPerSecond":20 - } -} -.... - -Response codes: - -* 201: Success. Corresponding task id is returned. -* 400: Error in the request. Details can be found in the reported error. - -==== Populate email query view - -Email query view is an optional projection to offload common JMAP `Email/query` requests used for listing mails on Cassandra -and not on the search index thus improving the overall reliability / performance on this operation. - -These projection items are asynchronously computed on mailbox events. - -You can populate this projection with the following request: - -.... -curl -XPOST /mailboxes?task=populateEmailQueryView -.... - -Will schedule a task for recomputing the fast message view projection -for all mailboxes. - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -An admin can specify the concurrency that should be used when running -the task: - -* `messagesPerSecond` rate at which messages should be processed, per -second. Defaults to 10. - -This optional parameter must have a strictly positive integer as a value -and be passed as query parameters. - -Example: - -.... -curl -XPOST /mailboxes?task=populateEmailQueryView&messagesPerSecond=20 -.... - -The scheduled task will have the following type -`PopulateEmailQueryViewTask` and the following -`additionalInformation`: - -.... -{ - "type":"PopulateEmailQueryViewTask", - "processedUserCount": 3, - "processedMessageCount": 3, - "failedUserCount": 2, - "failedMessageCount": 1, - "runningOptions": { - "messagesPerSecond":20 - } -} -.... - -Response codes: - -* 201: Success. Corresponding task id is returned. -* 400: Error in the request. Details can be found in the reported error. - -==== Recomputing Cassandra filtering projection - -You can force the reset of the Cassandra filtering projection by calling the following -endpoint: - -.... -curl -XPOST /mailboxes?task=populateFilteringProjection -.... - -Will schedule a task. - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -The scheduled task will have the following type -`PopulateFilteringProjectionTask` and the following -`additionalInformation`: - -.... -{ - "type":"RecomputeAllPreviewsTask", - "processedUserCount": 3, - "failedUserCount": 2 -} -.... - -Response codes: - -* 201: Success. Corresponding task id is returned. -* 400: Error in the request. Details can be found in the reported error. - -==== ReIndexing action - -Be also aware of the limits of this API: - -Warning: During the re-indexing, the result of search operations might -be altered. - -Warning: Canceling this task should be considered unsafe as it will -leave the currently reIndexed mailbox as partially indexed. - -Warning: While we have been trying to reduce the inconsistency window to -a maximum (by keeping track of ongoing events), concurrent changes done -during the reIndexing might be ignored. - -===== ReIndexing all mails - -.... -curl -XPOST http://ip:port/mailboxes?task=reIndex -.... - -Will schedule a task for reIndexing all the mails stored on this James -server. - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -An admin can specify the concurrency that should be used when running -the task: - -* `messagesPerSecond` rate at which messages should be processed per -second. Default is 50. - -This optional parameter must have a strictly positive integer as a value -and be passed as query parameter. - -An admin can also specify the reindexing mode it wants to use when -running the task: - -* `mode` the reindexing mode used. There are 2 modes for the moment: -** `rebuildAll` allows to rebuild all indexes. This is the default mode. -** `fixOutdated` will check for outdated indexed document and reindex -only those. - -This optional parameter must be passed as query parameter. - -It’s good to note as well that there is a limitation with the -`fixOutdated` mode. As we first collect metadata of stored messages to -compare them with the ones in the index, a failed `expunged` operation -might not be well corrected (as the message might not exist anymore but -still be indexed). - -Example: - - curl -XPOST http://ip:port/mailboxes?task=reIndex&messagesPerSecond=200&mode=rebuildAll - -The scheduled task will have the following type `full-reindexing` and -the following `additionalInformation`: - -.... -{ - "type":"full-reindexing", - "runningOptions":{ - "messagesPerSecond":200, - "mode":"REBUILD_ALL" - }, - "successfullyReprocessedMailCount":18, - "failedReprocessedMailCount": 3, - "mailboxFailures": ["12", "23" ], - "messageFailures": [ - { - "mailboxId": "1", - "uids": [1, 36] - }] -} -.... - -===== Fixing previously failed ReIndexing - -Will schedule a task for reIndexing all the mails which had failed to be -indexed from the ReIndexingAllMails task. - -Given `bbdb69c9-082a-44b0-a85a-6e33e74287a5` being a `taskId` generated -for a reIndexing tasks - -.... -curl -XPOST 'http://ip:port/mailboxes?task=reIndex&reIndexFailedMessagesOf=bbdb69c9-082a-44b0-a85a-6e33e74287a5' -.... - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -An admin can specify the concurrency that should be used when running -the task: - -* `messagesPerSecond` rate at which messages should be processed per -second. Default is 50. - -This optional parameter must have a strictly positive integer as a value -and be passed as query parameter. - -An admin can also specify the reindexing mode it wants to use when -running the task: - -* `mode` the reindexing mode used. There are 2 modes for the moment: -** `rebuildAll` allows to rebuild all indexes. This is the default mode. -** `fixOutdated` will check for outdated indexed document and reindex -only those. - -This optional parameter must be passed as query parameter. - -It’s good to note as well that there is a limitation with the -`fixOutdated` mode. As we first collect metadata of stored messages to -compare them with the ones in the index, a failed `expunged` operation -might not be well corrected (as the message might not exist anymore but -still be indexed). - -Example: - -.... -curl -XPOST http://ip:port/mailboxes?task=reIndex&reIndexFailedMessagesOf=bbdb69c9-082a-44b0-a85a-6e33e74287a5&messagesPerSecond=200&mode=rebuildAll -.... - -The scheduled task will have the following type -`error-recovery-indexation` and the following `additionalInformation`: - -.... -{ - "type":"error-recovery-indexation" - "runningOptions":{ - "messagesPerSecond":200, - "mode":"REBUILD_ALL" - }, - "successfullyReprocessedMailCount":18, - "failedReprocessedMailCount": 3, - "mailboxFailures": ["12", "23" ], - "messageFailures": [{ - "mailboxId": "1", - "uids": [1, 36] - }] -} -.... - -===== Create missing parent mailboxes - -Will schedule a task for creating all the missing parent mailboxes in a hierarchical mailbox tree, which is the result -of a partially failed rename operation of a child mailbox. - -.... -curl -XPOST http://ip:port/mailboxes?task=createMissingParents -.... - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -Response codes: - -* 201: Success. Corresponding task id is returned. -* 400: Error in the request. Details can be found in the reported error. - -The scheduled task will have the following type `createMissingParents` and the following `additionalInformation`: - -.... -{ - "type":"createMissingParents" - "created": ["1", "2" ], - "totalCreated": 2, - "failures": [], - "totalFailure": 0 -} -.... - -=== Single mailbox - -==== ReIndexing a mailbox mails - -.... -curl -XPOST http://ip:port/mailboxes/{mailboxId}?task=reIndex -.... - -Will schedule a task for reIndexing all the mails in one mailbox. - -Note that `mailboxId' path parameter needs to be a (implementation -dependent) valid mailboxId. - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -An admin can specify the concurrency that should be used when running -the task: - -* `messagesPerSecond` rate at which messages should be processed per -second. Default is 50. - -This optional parameter must have a strictly positive integer as a value -and be passed as query parameter. - -An admin can also specify the reindexing mode it wants to use when -running the task: - -* `mode` the reindexing mode used. There are 2 modes for the moment: -** `rebuildAll` allows to rebuild all indexes. This is the default mode. -** `fixOutdated` will check for outdated indexed document and reindex -only those. - -This optional parameter must be passed as query parameter. - -It’s good to note as well that there is a limitation with the -`fixOutdated` mode. As we first collect metadata of stored messages to -compare them with the ones in the index, a failed `expunged` operation -might not be well corrected (as the message might not exist anymore but -still be indexed). - -Example: - -.... -curl -XPOST http://ip:port/mailboxes/{mailboxId}?task=reIndex&messagesPerSecond=200&mode=fixOutdated -.... - -Response codes: - -* 201: Success. Corresponding task id is returned. -* 400: Error in the request. Details can be found in the reported error. - -The scheduled task will have the following type `mailbox-reindexing` and -the following `additionalInformation`: - -.... -{ - "type":"mailbox-reindexing", - "runningOptions":{ - "messagesPerSecond":200, - "mode":"FIX_OUTDATED" - }, - "mailboxId":"{mailboxId}", - "successfullyReprocessedMailCount":18, - "failedReprocessedMailCount": 3, - "mailboxFailures": ["12"], - "messageFailures": [ - { - "mailboxId": "1", - "uids": [1, 36] - }] -} -.... - -Warning: During the re-indexing, the result of search operations might -be altered. - -Warning: Canceling this task should be considered unsafe as it will -leave the currently reIndexed mailbox as partially indexed. - -Warning: While we have been trying to reduce the inconsistency window to -a maximum (by keeping track of ongoing events), concurrent changes done -during the reIndexing might be ignored. - -== Administrating Messages - -=== ReIndexing a single mail by messageId - -.... -curl -XPOST http://ip:port/messages/{messageId}?task=reIndex -.... - -Will schedule a task for reIndexing a single email in all the mailboxes -containing it. - -Note that `messageId' path parameter needs to be a (implementation -dependent) valid messageId. - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -Response codes: - -* 201: Success. Corresponding task id is returned. -* 400: Error in the request. Details can be found in the reported error. - -The scheduled task will have the following type `messageId-reindexing` -and the following `additionalInformation`: - -.... -{ - "messageId":"18" -} -.... - -Warning: During the re-indexing, the result of search operations might -be altered. - -=== Fixing message inconsistencies - -This task is only available on top of Guice Cassandra products. - -.... -curl -XPOST /messages?task=SolveInconsistencies -.... - -Will schedule a task for fixing message inconsistencies created by the -message denormalization process. - -Messages are denormalized and stored in separated data tables in -Cassandra, so they can be accessed by their unique identifier or mailbox -identifier & local mailbox identifier through different protocols. - -Failure in the denormalization process will lead to inconsistencies, for -example: - -.... -BOB receives a message -The denormalization process fails -BOB can read the message via JMAP -BOB cannot read the message via IMAP - -BOB marks a message as SEEN -The denormalization process fails -The message is SEEN via JMAP -The message is UNSEEN via IMAP -.... - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -An admin can specify the concurrency that should be used when running -the task: - -* `messagesPerSecond` rate of messages to be processed per second. -Default is 100. - -This optional parameter must have a strictly positive integer as a value -and be passed as query parameter. - -An admin can also specify the reindexing mode it wants to use when -running the task: - -* `mode` the reindexing mode used. There are 2 modes for the moment: -** `rebuildAll` allows to rebuild all indexes. This is the default mode. -** `fixOutdated` will check for outdated indexed document and reindex -only those. - -This optional parameter must be passed as query parameter. - -It’s good to note as well that there is a limitation with the -`fixOutdated` mode. As we first collect metadata of stored messages to -compare them with the ones in the index, a failed `expunged` operation -might not be well corrected (as the message might not exist anymore but -still be indexed). - -Example: - -.... -curl -XPOST /messages?task=SolveInconsistencies&messagesPerSecond=200&mode=rebuildAll -.... - -Response codes: - -* 201: Success. Corresponding task id is returned. -* 400: Error in the request. Details can be found in the reported error. - -The scheduled task will have the following type -`solve-message-inconsistencies` and the following -`additionalInformation`: - -.... -{ - "type":"solve-message-inconsistencies", - "timestamp":"2007-12-03T10:15:30Z", - "processedImapUidEntries": 2, - "processedMessageIdEntries": 1, - "addedMessageIdEntries": 1, - "updatedMessageIdEntries": 0, - "removedMessageIdEntries": 1, - "runningOptions":{ - "messagesPerSecond": 200, - "mode":"REBUILD_ALL" - }, - "fixedInconsistencies": [ - { - "mailboxId": "551f0580-82fb-11ea-970e-f9c83d4cf8c2", - "messageId": "d2bee791-7e63-11ea-883c-95b84008f979", - "uid": 1 - }, - { - "mailboxId": "551f0580-82fb-11ea-970e-f9c83d4cf8c2", - "messageId": "d2bee792-7e63-11ea-883c-95b84008f979", - "uid": 2 - } - ], - "errors": [ - { - "mailboxId": "551f0580-82fb-11ea-970e-f9c83d4cf8c2", - "messageId": "ffffffff-7e63-11ea-883c-95b84008f979", - "uid": 3 - } - ] -} -.... - -User actions concurrent to the inconsistency fixing task could result in -concurrency issues. New inconsistencies could be created. - -However the source of truth will not be impacted, hence rerunning the -task will eventually fix all issues. - -This task could be run safely online and can be scheduled on a recurring -basis outside of peak traffic by an admin to ensure Cassandra message -consistency. - -=== Deleting old messages of all users - -*Note:* -Consider enabling the xref:distributed/configure/vault.adoc[Deleted Messages Vault] -if you use this feature. - -Old messages tend to pile up in user INBOXes. An admin might want to delete -these on behalf of the users, e.g. all messages older than 30 days: -.... -curl -XDELETE http://ip:port/messages?olderThan=30d -.... - -link:#_endpoints_returning_a_task[More details about endpoints returning a task]. - -The `olderThan` parameter should be expressed in the following format: `Nunit`. -`N` should be strictly positive. `unit` could be either in the short form -(`d`, `w`, `y` etc.), or in the long form (`days`, `weeks`, `months`, `years`). -The default unit is `days`. - -Response codes: - -* 201: Success. Corresponding task id is returned. -* 400: Error in the request. Details can be found in the reported error. - -The scheduled task will have the type `ExpireMailboxTask` and the following `additionalInformation`: - -.... -{ - "type": "ExpireMailboxTask" - "mailboxesExpired": 5, - "mailboxesFailed": 2, - "mailboxesProcessed": 10, - "messagesDeleted": 23, -} -.... - -To delete old mails from a different mailbox than INBOX, e.g. a mailbox -named "Archived" : -.... -curl -XDELETE http://ip:port/messages?mailbox=Archived&olderThan=30d -.... - -Since this is a somewhat expensive operation, the task is throttled to one user -per second. You may speed it up via `usersPerSecond=10` for example. But keep -in mind that a high rate might overwhelm your database or blob store. - -*Scanning search only:* (unsupported for Lucene and OpenSearch search implementations) + -Some mail clients can add an `Expires` header (RFC 4021) to their messages. -Instead of specifying an absolute age, you may choose to delete only such -messages where the expiration date from this header lies in the past: -.... -curl -XDELETE http://ip:port/messages?byExpiresHeader -.... -In this case you should also add the xref:distributed/configure/mailets.adoc[mailet] -`Expires` to your mailet container, which can sanitize expiration date headers. - - -== Administrating user mailboxes - -=== Creating a mailbox - -.... -curl -XPUT http://ip:port/users/{usernameToBeUsed}/mailboxes/{mailboxNameToBeCreated} -.... - -Resource name `usernameToBeUsed` should be an existing user Resource -name `mailboxNameToBeCreated` should not be empty, nor contain % * characters, nor starting with #. - -Response codes: - -* 204: The mailbox now exists on the server -* 400: Invalid mailbox name -* 404: The user name does not exist. Note that this check can be bypassed by specifying the `force` query parameter. - -To create nested mailboxes, for instance a work mailbox inside the INBOX -mailbox, people should use the . separator. The sample query is: - -.... -curl -XDELETE http://ip:port/users/{usernameToBeUsed}/mailboxes/INBOX.work -.... - -=== Deleting a mailbox and its children - -.... -curl -XDELETE http://ip:port/users/{usernameToBeUsed}/mailboxes/{mailboxNameToBeDeleted} -.... - -Resource name `usernameToBeUsed` should be an existing user Resource -name `mailboxNameToBeDeleted` should not be empty - -Response codes: - -* 204: The mailbox now does not exist on the server -* 400: Invalid mailbox name -* 404: The user name does not exist. Note that this check can be bypassed by specifying the `force` query parameter. - -=== Testing existence of a mailbox - -.... -curl -XGET http://ip:port/users/{usernameToBeUsed}/mailboxes/{mailboxNameToBeTested} -.... - -Resource name `usernameToBeUsed` should be an existing user Resource -name `mailboxNameToBeTested` should not be empty - -Response codes: - -* 204: The mailbox exists -* 400: Invalid mailbox name -* 404: The user name does not exist, the mailbox does not exist - -=== Listing user mailboxes - -.... -curl -XGET http://ip:port/users/{usernameToBeUsed}/mailboxes -.... - -The answer looks like: - -.... -[{"mailboxName":"INBOX"},{"mailboxName":"outbox"}] -.... - -Resource name `usernameToBeUsed` should be an existing user - -Response codes: - -* 200: The mailboxes list was successfully retrieved -* 404: The user name does not exist, the mailbox does not exist. Note that this check can be bypassed by specifying the `force` query parameter. - - -=== Deleting user mailboxes - -.... -curl -XDELETE http://ip:port/users/{usernameToBeUsed}/mailboxes -.... - -Resource name `usernameToBeUsed` should be an existing user - -Response codes: - -* 204: The user do not have mailboxes anymore -* 404: The user name does not exist. Note that this check can be bypassed by specifying the `force` query parameter. - -=== Exporting user mailboxes - -.... -curl -XPOST http://ip:port/users/{usernameToBeUsed}/mailboxes?action=export -.... - -Resource name `usernameToBeUsed` should be an existing user - -Response codes: - -* 201: Success. Corresponding task id is returned -* 404: The user name does not exist - -The scheduled task will have the following type `MailboxesExportTask` -and the following `additionalInformation`: - -.... -{ - "type":"MailboxesExportTask", - "timestamp":"2007-12-03T10:15:30Z", - "username": "user", - "stage": "STARTING" -} -.... - -=== ReIndexing a user mails - -.... -curl -XPOST http://ip:port/users/{usernameToBeUsed}/mailboxes?task=reIndex -.... - -Will schedule a task for reIndexing all the mails in ``user@domain.com'' -mailboxes (encoded above). - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -An admin can specify the concurrency that should be used when running -the task: - -* `messagesPerSecond` rate at which messages should be processed per -second. Default is 50. - -This optional parameter must have a strictly positive integer as a value -and be passed as query parameter. - -An admin can also specify the reindexing mode it wants to use when -running the task: - -* `mode` the reindexing mode used. There are 2 modes for the moment: -** `rebuildAll` allows to rebuild all indexes. This is the default mode. -** `fixOutdated` will check for outdated indexed document and reindex -only those. - -This optional parameter must be passed as query parameter. - -It’s good to note as well that there is a limitation with the -`fixOutdated` mode. As we first collect metadata of stored messages to -compare them with the ones in the index, a failed `expunged` operation -might not be well corrected (as the message might not exist anymore but -still be indexed). - -Example: - -.... -curl -XPOST http://ip:port/users/{usernameToBeUsed}/mailboxes?task=reIndex&messagesPerSecond=200&mode=fixOutdated -.... - -Response codes: - -* 201: Success. Corresponding task id is returned. -* 400: Error in the request. Details can be found in the reported error. - -The scheduled task will have the following type `user-reindexing` and -the following `additionalInformation`: - -.... -{ - "type":"user-reindexing", - "runningOptions":{ - "messagesPerSecond":200, - "mode":"FIX_OUTDATED" - }, - "user":"user@domain.com", - "successfullyReprocessedMailCount":18, - "failedReprocessedMailCount": 3, - "mailboxFailures": ["12", "23" ], - "messageFailures": [ - { - "mailboxId": "1", - "uids": [1, 36] - }] -} -.... - -Warning: During the re-indexing, the result of search operations might -be altered. - -Warning: Canceling this task should be considered unsafe as it will -leave the currently reIndexed mailbox as partially indexed. - -Warning: While we have been trying to reduce the inconsistency window to -a maximum (by keeping track of ongoing events), concurrent changes done -during the reIndexing might be ignored. - -=== Counting emails - -.... -curl -XGET http://ip:port/users/{usernameToBeUsed}/mailboxes/{mailboxName}/messageCount -.... - -Will return the total count of messages within the mailbox of that user. - -Resource name `usernameToBeUsed` should be an existing user. - -Resource name `mailboxName` should not be empty, nor contain `% *` characters, nor starting with `#`. - -Response codes: - -* 200: The number of emails in a given mailbox -* 400: Invalid mailbox name -* 404: Invalid get on user mailboxes. The `usernameToBeUsed` or `mailboxName` does not exit' - -=== Counting unseen emails - -.... -curl -XGET http://ip:port/users/{usernameToBeUsed}/mailboxes/{mailboxName}/unseenMessageCount -.... - -Will return the total count of unseen messages within the mailbox of that user. - -Resource name `usernameToBeUsed` should be an existing user. - -Resource name `mailboxName` should not be empty, nor contain `% *` characters, nor starting with `#`. - -Response codes: - -* 200: The number of unseen emails in a given mailbox -* 400: Invalid mailbox name -* 404: Invalid get on user mailboxes. The `usernameToBeUsed` or `mailboxName` does not exit' - -=== Clearing mailbox content - -.... -curl -XDELETE http://ip:port/users/{usernameToBeUsed}/mailboxes/{mailboxName}/messages -.... - -Will schedule a task for clearing all the mails in ``mailboxName`` mailbox of ``usernameToBeUsed``. - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -Resource name `usernameToBeUsed` should be an existing user. - -Resource name `mailboxName` should not be empty, nor contain `% *` characters, nor starting with `#`. - -Response codes: - -* 201: Success. Corresponding task id is returned. -* 400: Invalid mailbox name -* 404: Invalid get on user mailboxes. The `username` or `mailboxName` does not exit - -The scheduled task will have the following type `ClearMailboxContentTask` and -the following `additionalInformation`: - -.... -{ - "mailboxName": "mbx1", - "messagesFailCount": 9, - "messagesSuccessCount": 10, - "timestamp": "2007-12-03T10:15:30Z", - "type": "ClearMailboxContentTask", - "username": "bob@domain.tld" -} -.... - -=== Subscribing a user to all of its mailboxes - -.... -curl -XPOST http://ip:port/users/{usernameToBeUsed}/mailboxes?task=subscribeAll -.... - -Will schedule a task for subscribing a user to all of its mailboxes. - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -Most users are unaware of what an IMAP subscription is, nor how they can manage it. If the subscription list gets out -of sync with the mailbox list, it could result in downgraded user experience (see MAILBOX-405). This task allow -to reset the subscription list to the mailbox list on a per user basis thus working around the aforementioned issues. - -Response codes: - -- 201: Success. Corresponding task id is returned. -- 404: No such user - -The scheduled task will have the following type `SubscribeAllTask` and the following `additionalInformation`: - -.... -{ - "type":"SubscribeAllTask", - "username":"user@domain.com", - "subscribedCount":18, - "unsubscribedCount": 3 -} -.... - -=== Recomputing User JMAP fast message view projection - -This action is only available for backends supporting JMAP protocol. - -Message fast view projection stores message properties expected to be -fast to fetch but are actually expensive to compute, in order for -GetMessages operation to be fast to execute for these properties. - -These projection items are asynchronously computed on mailbox events. - -You can force the full projection recomputation by calling the following -endpoint: - -.... -curl -XPOST /users/{usernameToBeUsed}/mailboxes?task=recomputeFastViewProjectionItems -.... - -Will schedule a task for recomputing the fast message view projection -for all mailboxes of `usernameToBeUsed`. - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -An admin can specify the concurrency that should be used when running -the task: - -* `messagesPerSecond` rate at which messages should be processed, per -second. Defaults to 10. - -This optional parameter must have a strictly positive integer as a value -and be passed as query parameters. - -Example: - -.... -curl -XPOST /mailboxes?task=recomputeFastViewProjectionItems&messagesPerSecond=20 -.... - -The scheduled task will have the following type -`RecomputeUserFastViewProjectionItemsTask` and the following -`additionalInformation`: - -.... -{ - "type":"RecomputeUserFastViewProjectionItemsTask", - "username": "{usernameToBeUsed}", - "processedMessageCount": 3, - "failedMessageCount": 1, - "runningOptions": { - "messagesPerSecond":20 - } -} -.... - -Response codes: - -* 201: Success. Corresponding task id is returned. -* 400: Error in the request. Details can be found in the reported error. -* 404: User not found. - -== Administrating quotas - -=== Administrating quotas by users - -==== Getting the quota for a user - -.... -curl -XGET http://ip:port/quota/users/{usernameToBeUsed} -.... - -Resource name `usernameToBeUsed` should be an existing user - -The answer is the details of the quota of that user. - -.... -{ - "global": { - "count":252, - "size":242 - }, - "domain": { - "count":152, - "size":142 - }, - "user": { - "count":52, - "size":42 - }, - "computed": { - "count":52, - "size":42 - }, - "occupation": { - "size":13, - "count":21, - "ratio": { - "size":0.25, - "count":0.5, - "max":0.5 - } - } -} -.... - -* The `global` entry represent the quota limit allowed on this James -server. -* The `domain` entry represent the quota limit allowed for the user of -that domain. -* The `user` entry represent the quota limit allowed for this specific -user. -* The `computed` entry represent the quota limit applied for this user, -resolved from the upper values. -* The `occupation` entry represent the occupation of the quota for this -user. This includes used count and size as well as occupation ratio -(used / limit). - -Note that `quota` object can contain a fixed value, an empty value -(null) or an unlimited value (-1): - -.... -{"count":52,"size":42} - -{"count":null,"size":null} - -{"count":52,"size":-1} -.... - -Response codes: - -* 200: The user’s quota was successfully retrieved -* 404: The user does not exist - -==== Updating the quota for a user - -.... -curl -XPUT http://ip:port/quota/users/{usernameToBeUsed} -.... - -Resource name `usernameToBeUsed` should be an existing user - -The body can contain a fixed value, an empty value (null) or an -unlimited value (-1): - -.... -{"count":52,"size":42} - -{"count":null,"size":null} - -{"count":52,"size":-1} -.... - -Response codes: - -* 204: The quota has been updated -* 400: The body is not a positive integer neither an unlimited value -(-1). -* 404: The user does not exist - -==== Getting the quota count for a user - -.... -curl -XGET http://ip:port/quota/users/{usernameToBeUsed}/count -.... - -Resource name `usernameToBeUsed` should be an existing user - -The answer looks like: - -.... -52 -.... - -Response codes: - -* 200: The user’s quota was successfully retrieved -* 204: No quota count limit is defined at the user level for this user -* 404: The user does not exist - -==== Updating the quota count for a user - -.... -curl -XPUT http://ip:port/quota/users/{usernameToBeUsed}/count -.... - -Resource name `usernameToBeUsed` should be an existing user - -The body can contain a fixed value or an unlimited value (-1): - -.... -52 -.... - -Response codes: - -* 204: The quota has been updated -* 400: The body is not a positive integer neither an unlimited value -(-1). -* 404: The user does not exist - -==== Deleting the quota count for a user - -.... -curl -XDELETE http://ip:port/quota/users/{usernameToBeUsed}/count -.... - -Resource name `usernameToBeUsed` should be an existing user - -Response codes: - -* 204: The quota has been updated to unlimited value. -* 404: The user does not exist - -==== Getting the quota size for a user - -.... -curl -XGET http://ip:port/quota/users/{usernameToBeUsed}/size -.... - -Resource name `usernameToBeUsed` should be an existing user - -The answer looks like: - -.... -52 -.... - -Response codes: - -* 200: The user’s quota was successfully retrieved -* 204: No quota size limit is defined at the user level for this user -* 404: The user does not exist - -==== Updating the quota size for a user - -.... -curl -XPUT http://ip:port/quota/users/{usernameToBeUsed}/size -.... - -Resource name `usernameToBeUsed` should be an existing user - -The body can contain a fixed value or an unlimited value (-1): - -.... -52 -.... - -Response codes: - -* 204: The quota has been updated -* 400: The body is not a positive integer neither an unlimited value -(-1). -* 404: The user does not exist - -==== Deleting the quota size for a user - -.... -curl -XDELETE http://ip:port/quota/users/{usernameToBeUsed}/size -.... - -Resource name `usernameToBeUsed` should be an existing user - -Response codes: - -* 204: The quota has been updated to unlimited value. -* 404: The user does not exist - -==== Searching user by quota ratio - -.... -curl -XGET 'http://ip:port/quota/users?minOccupationRatio=0.8&maxOccupationRatio=0.99&limit=100&offset=200&domain=domain.com' -.... - -Will return: - -.... -[ - { - "username":"user@domain.com", - "detail": { - "global": { - "count":252, - "size":242 - }, - "domain": { - "count":152, - "size":142 - }, - "user": { - "count":52, - "size":42 - }, - "computed": { - "count":52, - "size":42 - }, - "occupation": { - "size":48, - "count":21, - "ratio": { - "size":0.9230, - "count":0.5, - "max":0.9230 - } - } - } - }, - ... -] -.... - -Where: - -* *minOccupationRatio* is a query parameter determining the minimum -occupation ratio of users to be returned. -* *maxOccupationRatio* is a query parameter determining the maximum -occupation ratio of users to be returned. -* *domain* is a query parameter determining the domain of users to be -returned. -* *limit* is a query parameter determining the maximum number of users -to be returned. -* *offset* is a query parameter determining the number of users to skip. - -Please note that users are alphabetically ordered on username. - -The response is a list of usernames, with attached quota details as -defined link:#_getting_the_quota_for_a_user[here]. - -Response codes: - -* 200: List of users had successfully been returned. -* 400: Validation issues with parameters - -==== Recomputing current quotas for users - -.... -curl -XPOST /quota/users?task=RecomputeCurrentQuotas -.... - -Will recompute current quotas (count and size) for all users stored in -James. - -James maintains per quota a projection for current quota count and size. -As with any projection, it can go out of sync, leading to inconsistent -results being returned to the client. - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -An admin can specify the concurrency that should be used when running -the task: - -* `usersPerSecond` rate at which users quotas should be reprocessed, per -second. Defaults to 1. - -This optional parameter must have a strictly positive integer as a value -and be passed as query parameters. - -An admin can select which quota component he wants to recompute: - -* `quotaComponent` component whose quota need to be reprocessed. It could be one of values: MAILBOX, SIEVE, JMAP_UPLOADS. - -The admin could select several quota components. If he does not select, quotas of all components would be recomputed. - -Example: - -.... -curl -XPOST /quota/users?task=RecomputeCurrentQuotas&usersPerSecond=20"aComponent=MAILBOX"aComponent=JMAP_UPLOADS -.... - -The scheduled task will have the following type -`recompute-current-quotas` and the following `additionalInformation`: - -.... -{ - "type":"recompute-current-quotas", - "recomputeSingleQuotaComponentResults": [ - { - "quotaComponent": "MAILBOX", - "processedIdentifierCount": 3, - "failedIdentifiers": ["#private&bob@localhost"] - }, - { - "quotaComponent": "JMAP_UPLOADS", - "processedIdentifierCount": 3, - "failedIdentifiers": ["bob@localhost"] - } - ], - "runningOptions": { - "usersPerSecond":20 - } -} -.... - -*WARNING*: this task do not take into account concurrent modifications -upon a single current quota re-computation. Rerunning the task will -_eventually_ provide the consistent result. - -=== Administrating quotas by domains - -==== Getting the quota for a domain - -.... -curl -XGET http://ip:port/quota/domains/{domainToBeUsed} -.... - -Resource name `domainToBeUsed` should be an existing domain. For -example: - -.... -curl -XGET http://ip:port/quota/domains/james.org -.... - -The answer will detail the default quota applied to users belonging to -that domain: - -.... -{ - "global": { - "count":252, - "size":null - }, - "domain": { - "count":null, - "size":142 - }, - "computed": { - "count":252, - "size":142 - } -} -.... - -* The `global` entry represents the quota limit defined on this James -server by default. -* The `domain` entry represents the quota limit allowed for the user of -that domain by default. -* The `computed` entry represents the quota limit applied for the users -of that domain, by default, resolved from the upper values. - -Note that `quota` object can contain a fixed value, an empty value -(null) or an unlimited value (-1): - -.... -{"count":52,"size":42} - -{"count":null,"size":null} - -{"count":52,"size":-1} -.... - -Response codes: - -* 200: The domain’s quota was successfully retrieved -* 404: The domain does not exist -* 405: Domain Quota configuration not supported when virtual hosting is -deactivated. - -==== Updating the quota for a domain - -.... -curl -XPUT http://ip:port/quota/domains/{domainToBeUsed} -.... - -Resource name `domainToBeUsed` should be an existing domain. - -The body can contain a fixed value, an empty value (null) or an -unlimited value (-1): - -.... -{"count":52,"size":42} - -{"count":null,"size":null} - -{"count":52,"size":-1} -.... - -Response codes: - -* 204: The quota has been updated -* 400: The body is not a positive integer neither an unlimited value -(-1). -* 404: The domain does not exist -* 405: Domain Quota configuration not supported when virtual hosting is -deactivated. - -==== Getting the quota count for a domain - -.... -curl -XGET http://ip:port/quota/domains/{domainToBeUsed}/count -.... - -Resource name `domainToBeUsed` should be an existing domain. - -The answer looks like: - -.... -52 -.... - -Response codes: - -* 200: The domain’s quota was successfully retrieved -* 204: No quota count limit is defined at the domain level for this -domain -* 404: The domain does not exist -* 405: Domain Quota configuration not supported when virtual hosting is -desactivated. - -==== Updating the quota count for a domain - -.... -curl -XPUT http://ip:port/quota/domains/{domainToBeUsed}/count -.... - -Resource name `domainToBeUsed` should be an existing domain. - -The body can contain a fixed value or an unlimited value (-1): - -.... -52 -.... - -Response codes: - -* 204: The quota has been updated -* 400: The body is not a positive integer neither an unlimited value -(-1). -* 404: The domain does not exist -* 405: Domain Quota configuration not supported when virtual hosting is -desactivated. - -==== Deleting the quota count for a domain - -.... -curl -XDELETE http://ip:port/quota/domains/{domainToBeUsed}/count -.... - -Resource name `domainToBeUsed` should be an existing domain. - -Response codes: - -* 204: The quota has been updated to unlimited value. -* 404: The domain does not exist -* 405: Domain Quota configuration not supported when virtual hosting is -deactivated. - -==== Getting the quota size for a domain - -.... -curl -XGET http://ip:port/quota/domains/{domainToBeUsed}/size -.... - -Resource name `domainToBeUsed` should be an existing domain. - -The answer looks like: - -.... -52 -.... - -Response codes: - -* 200: The domain’s quota was successfully retrieved -* 204: No quota size limit is defined at the domain level for this -domain -* 404: The domain does not exist -* 405: Domain Quota configuration not supported when virtual hosting is -deactivated. - -==== Updating the quota size for a domain - -.... -curl -XPUT http://ip:port/quota/domains/{domainToBeUsed}/size -.... - -Resource name `domainToBeUsed` should be an existing domain. - -The body can contain a fixed value or an unlimited value (-1): - -.... -52 -.... - -Response codes: - -* 204: The quota has been updated -* 400: The body is not a positive integer neither an unlimited value -(-1). -* 404: The domain does not exist -* 405: Domain Quota configuration not supported when virtual hosting is -deactivated. - -==== Deleting the quota size for a domain - -.... -curl -XDELETE http://ip:port/quota/domains/{domainToBeUsed}/size -.... - -Resource name `domainToBeUsed` should be an existing domain. - -Response codes: - -* 204: The quota has been updated to unlimited value. -* 404: The domain does not exist - -=== Administrating global quotas - -==== Getting the global quota - -.... -curl -XGET http://ip:port/quota -.... - -The answer is the details of the global quota. - -.... -{ - "count":252, - "size":242 -} -.... - -Note that `quota` object can contain a fixed value, an empty value -(null) or an unlimited value (-1): - -.... -{"count":52,"size":42} - -{"count":null,"size":null} - -{"count":52,"size":-1} -.... - -Response codes: - -* 200: The quota was successfully retrieved - -==== Updating global quota - -.... -curl -XPUT http://ip:port/quota -.... - -The body can contain a fixed value, an empty value (null) or an -unlimited value (-1): - -.... -{"count":52,"size":42} - -{"count":null,"size":null} - -{"count":52,"size":-1} -.... - -Response codes: - -* 204: The quota has been updated -* 400: The body is not a positive integer neither an unlimited value -(-1). - -==== Getting the global quota count - -.... -curl -XGET http://ip:port/quota/count -.... - -Resource name usernameToBeUsed should be an existing user - -The answer looks like: - -.... -52 -.... - -Response codes: - -* 200: The quota was successfully retrieved -* 204: No quota count limit is defined at the global level - -==== Updating the global quota count - -.... -curl -XPUT http://ip:port/quota/count -.... - -The body can contain a fixed value or an unlimited value (-1): - -.... -52 -.... - -Response codes: - -* 204: The quota has been updated -* 400: The body is not a positive integer neither an unlimited value -(-1). - -==== Deleting the global quota count - -.... -curl -XDELETE http://ip:port/quota/count -.... - -Response codes: - -* 204: The quota has been updated to unlimited value. - -==== Getting the global quota size - -.... -curl -XGET http://ip:port/quota/size -.... - -The answer looks like: - -.... -52 -.... - -Response codes: - -* 200: The quota was successfully retrieved -* 204: No quota size limit is defined at the global level - -==== Updating the global quota size - -.... -curl -XPUT http://ip:port/quota/size -.... - -The body can contain a fixed value or an unlimited value (-1): - -.... -52 -.... - -Response codes: - -* 204: The quota has been updated -* 400: The body is not a positive integer neither an unlimited value -(-1). - -==== Deleting the global quota size - -.... -curl -XDELETE http://ip:port/quota/size -.... - -Response codes: - -* 204: The quota has been updated to unlimited value. - -=== Administrating Sieve quotas - -Some limitations on space Users Sieve script can occupy can be -configured by default, and overridden by user. - -==== Retrieving global sieve quota - -This endpoints allows to retrieve the global Sieve quota, which will be -users default: - -.... -curl -XGET http://ip:port/sieve/quota/default -.... - -Will return the bytes count allowed by user per default on this server. - -.... -102400 -.... - -Response codes: - -* 200: Request is a success and the value is returned -* 204: No default quota is being configured - -==== Updating global sieve quota - -This endpoints allows to update the global Sieve quota, which will be -users default: - -.... -curl -XPUT http://ip:port/sieve/quota/default -.... - -With the body being the bytes count allowed by user per default on this -server. - -.... -102400 -.... - -Response codes: - -* 204: Operation succeeded -* 400: Invalid payload - -==== Removing global sieve quota - -This endpoints allows to remove the global Sieve quota. There will no -more be users default: - -.... -curl -XDELETE http://ip:port/sieve/quota/default -.... - -Response codes: - -* 204: Operation succeeded - -==== Retrieving user sieve quota - -This endpoints allows to retrieve the Sieve quota of a user, which will -be this users quota: - -.... -curl -XGET http://ip:port/sieve/quota/users/user@domain.com -.... - -Will return the bytes count allowed for this user. - -.... -102400 -.... - -Response codes: - -* 200: Request is a success and the value is returned -* 204: No quota is being configured for this user - -==== Updating user sieve quota - -This endpoints allows to update the Sieve quota of a user, which will be -users default: - -.... -curl -XPUT http://ip:port/sieve/quota/users/user@domain.com -.... - -With the body being the bytes count allowed for this user on this -server. - -.... -102400 -.... - -Response codes: - -* 204: Operation succeeded -* 400: Invalid payload - -==== Removing user sieve quota - -This endpoints allows to remove the Sieve quota of a user. There will no -more quota for this user: - -.... -curl -XDELETE http://ip:port/sieve/quota/users/user@domain.com -.... - -Response codes: - -* 204: Operation succeeded - -== Administrating Jmap Uploads - -=== Cleaning upload repository - -.... -curl -XDELETE http://ip:port/jmap/uploads?scope=expired -.... - -Will schedule a task for clearing expired upload entries. - - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - - -Query parameter `scope` is required and have the value `expired`. - -Response codes: - -* 201: Success. Corresponding task id is returned. -* 400: Scope invalid - -The scheduled task will have the following type `UploadRepositoryCleanupTask` and -the following `additionalInformation`: - -.... -{ - "scope": "expired", - "timestamp": "2007-12-03T10:15:30Z", - "type": "UploadRepositoryCleanupTask" -} -.... - -== Running blob garbage collection - -When deduplication is enabled one needs to explicitly run a garbage collection in order to delete no longer referenced -blobs. - -To do so: - -.... -curl -XDELETE http://ip:port/blobs?scope=unreferenced -.... - -link:#_endpoints_returning_a_task[More details about endpoints returning a task]. - -Additional parameters include Bloom filter tuning parameters: - - - *associatedProbability*: Allow to define the targeted false positive rate. Note that subsequent runs do not have the -same false-positives. Defaults to `0.01`. - - *expectedBlobCount*: Expected count of blobs used to size the bloom filters. Defaults to `1.000.000`. - -These settings directly impacts the memory footprint of the bloom filter. link:https://hur.st/bloomfilter/[Simulators] can -help understand those parameters. - -The created task has the following additional information: - -.... -{ - "referenceSourceCount": 3456, - "blobCount": 5678, - "gcedBlobCount": 1234, - "bloomFilterExpectedBlobCount": 10000, - "bloomFilterAssociatedProbability": 0.01 -} -.... - -Where: - - - *bloomFilterExpectedBlobCount* correspond to the supplied *expectedBlobCount* query parameter. - - *bloomFilterAssociatedProbability* correspond to the supplied *associatedProbability* query parameter. - - *referenceSourceCount* is the count of distinct blob references encountered while populating the bloom filter. - - *blobCount* is the count of blobs tried against the bloom filter. This value can be used to better size the bloom -filter in later runs. - - *gcedBlobCount* is the count of blobs that were garbage collected. - -== Administrating Recipient rewriting - -=== Address group - -You can use *webadmin* to define address groups. - -When a specific email is sent to the group mail address, every group -member will receive it. - -Note that the group mail address is virtual: it does not correspond to -an existing user. - -This feature uses xref:distributed/architecture/index.adoc#_recipient_rewrite_tables[Recipients rewrite table] -and requires the -https://github.com/apache/james-project/blob/master/server/mailet/mailets/src/main/java/org/apache/james/transport/mailets/RecipientRewriteTable.java[RecipientRewriteTable -mailet] to be configured. - -Note that email addresses are restricted to ASCII character set. Mail -addresses not matching this criteria will be rejected. - -==== Listing groups - -.... -curl -XGET http://ip:port/address/groups -.... - -Will return the groups as a list of JSON Strings representing mail -addresses. For instance: - -.... -["group1@domain.com", "group2@domain.com"] -.... - -Response codes: - -* 200: Success - -==== Listing members of a group - -.... -curl -XGET http://ip:port/address/groups/group@domain.com -.... - -Will return the group members as a list of JSON Strings representing -mail addresses. For instance: - -.... -["member1@domain.com", "member2@domain.com"] -.... - -Response codes: - -* 200: Success -* 400: Group structure is not valid -* 404: The group does not exist - -==== Adding a group member - -.... -curl -XPUT http://ip:port/address/groups/group@domain.com/member@domain.com -.... - -Will add member@domain.com to group@domain.com, creating the group if -needed - -Response codes: - -* 204: Success -* 400: Group structure or member is not valid -* 400: Domain in the source is not managed by the DomainList -* 409: Requested group address is already used for another purpose -* 409: The addition of the group member would lead to a loop and thus cannot be performed - -==== Removing a group member - -.... -curl -XDELETE http://ip:port/address/groups/group@domain.com/member@domain.com -.... - -Will remove member@domain.com from group@domain.com, removing the group -if group is empty after deletion - -Response codes: - -* 204: Success -* 400: Group structure or member is not valid - -=== Address forwards - -You can use *webadmin* to define address forwards. - -When a specific email is sent to the base mail address, every forward -destination addresses will receive it. - -Please note that the base address can be optionaly part of the forward -destination. In that case, the base recipient also receive a copy of the -mail. Otherwise he is omitted. - -Forwards can be defined for existing users. It then defers from -``groups''. - -This feature uses xref:distributed/architecture/index.adoc#_recipient_rewrite_tables[Recipients rewrite table] -and requires the -https://github.com/apache/james-project/blob/master/server/mailet/mailets/src/main/java/org/apache/james/transport/mailets/RecipientRewriteTable.java[RecipientRewriteTable -mailet] to be configured. - -Note that email addresses are restricted to ASCII character set. Mail -addresses not matching this criteria will be rejected. - -==== Listing Forwards - -.... -curl -XGET http://ip:port/address/forwards -.... - -Will return the users having forwards configured as a list of JSON -Strings representing mail addresses. For instance: - -.... -["user1@domain.com", "user2@domain.com"] -.... - -Response codes: - -* 200: Success - -==== Listing destinations in a forward - -.... -curl -XGET http://ip:port/address/forwards/user@domain.com -.... - -Will return the destination addresses of this forward as a list of JSON -Strings representing mail addresses. For instance: - -.... -[ - {"mailAddress":"destination1@domain.com"}, - {"mailAddress":"destination2@domain.com"} -] -.... - -Response codes: - -* 200: Success -* 400: Forward structure is not valid -* 404: The given user don’t have forwards or does not exist - -==== Adding a new destination to a forward - -.... -curl -XPUT http://ip:port/address/forwards/user@domain.com/targets/destination@domain.com -.... - -Will add destination@domain.com to user@domain.com, creating the forward -if needed - -Response codes: - -* 204: Success -* 400: Forward structure or member is not valid -* 400: Domain in the source is not managed by the DomainList -* 404: Requested forward address does not match an existing user -* 409: The creation of the forward would lead to a loop and thus cannot be performed - -==== Removing a destination of a forward - -.... -curl -XDELETE http://ip:port/address/forwards/user@domain.com/targets/destination@domain.com -.... - -Will remove destination@domain.com from user@domain.com, removing the -forward if forward is empty after deletion - -Response codes: - -* 204: Success -* 400: Forward structure or member is not valid - -=== Address aliases - -You can use *webadmin* to define aliases for an user. - -When a specific email is sent to the alias address, the destination -address of the alias will receive it. - -Aliases can be defined for existing users. - -This feature uses xref:distributed/architecture/index.adoc#_recipient_rewrite_tables[Recipients rewrite table] -and requires the -https://github.com/apache/james-project/blob/master/server/mailet/mailets/src/main/java/org/apache/james/transport/mailets/RecipientRewriteTable.java[RecipientRewriteTable -mailet] to be configured. - -Note that email addresses are restricted to ASCII character set. Mail -addresses not matching this criteria will be rejected. - -==== Listing users with aliases - -.... -curl -XGET http://ip:port/address/aliases -.... - -Will return the users having aliases configured as a list of JSON -Strings representing mail addresses. For instance: - -.... -["user1@domain.com", "user2@domain.com"] -.... - -Response codes: - -* 200: Success - -==== Listing alias sources of an user - -.... -curl -XGET http://ip:port/address/aliases/user@domain.com -.... - -Will return the aliases of this user as a list of JSON Strings -representing mail addresses. For instance: - -.... -[ - {"source":"alias1@domain.com"}, - {"source":"alias2@domain.com"} -] -.... - -Response codes: - -* 200: Success -* 400: Alias structure is not valid - -==== Adding a new alias to an user - -.... -curl -XPUT http://ip:port/address/aliases/user@domain.com/sources/alias@domain.com -.... - -Will add alias@domain.com to user@domain.com, creating the alias if -needed - -Response codes: - -* 204: OK -* 400: Alias structure or member is not valid -* 400: Source and destination can’t be the same! -* 400: Domain in the destination or source is not managed by the -DomainList -* 409: The alias source exists as an user already -* 409: The addition of the alias would lead to a loop and thus cannot be performed - -==== Removing an alias of an user - -.... -curl -XDELETE http://ip:port/address/aliases/user@domain.com/sources/alias@domain.com -.... - -Will remove alias@domain.com from user@domain.com, removing the alias if -needed - -Response codes: - -* 204: OK -* 400: Alias structure or member is not valid - -=== Domain mappings - -You can use *webadmin* to define domain mappings. - -Given a configured source (from) domain and a destination (to) domain, -when an email is sent to an address belonging to the source domain, then -the domain part of this address is overwritten, the destination domain -is then used. A source (from) domain can have many destination (to) -domains. - -For example: with a source domain `james.apache.org` maps to two -destination domains `james.org` and `apache-james.org`, when a mail is -sent to `admin@james.apache.org`, then it will be routed to -`admin@james.org` and `admin@apache-james.org` - -This feature uses xref:distributed/architecture/index.adoc#_recipient_rewrite_tables[Recipients rewrite table] -and requires the -https://github.com/apache/james-project/blob/master/server/mailet/mailets/src/main/java/org/apache/james/transport/mailets/RecipientRewriteTable.java[RecipientRewriteTable -mailet] to be configured. - -Note that email addresses are restricted to ASCII character set. Mail -addresses not matching this criteria will be rejected. - -==== Listing all domain mappings - -.... -curl -XGET http://ip:port/domainMappings -.... - -Will return all configured domain mappings - -.... -{ - "firstSource.org" : ["firstDestination.com", "secondDestination.net"], - "secondSource.com" : ["thirdDestination.com", "fourthDestination.net"], -} -.... - -Response codes: - -* 200: OK - -==== Listing all destination domains for a source domain - -.... -curl -XGET http://ip:port/domainMappings/sourceDomain.tld -.... - -With `sourceDomain.tld` as the value passed to `fromDomain` resource -name, the API will return all destination domains configured to that -domain - -.... -["firstDestination.com", "secondDestination.com"] -.... - -Response codes: - -* 200: OK -* 400: The `fromDomain` resource name is invalid -* 404: The `fromDomain` resource name is not found - -==== Adding a domain mapping - -.... -curl -XPUT http://ip:port/domainMappings/sourceDomain.tld -.... - -Body: - -.... -destination.tld -.... - -With `sourceDomain.tld` as the value passed to `fromDomain` resource -name, the API will add a destination domain specified in the body to -that domain - -Response codes: - -* 204: OK -* 400: The `fromDomain` resource name is invalid -* 400: The destination domain specified in the body is invalid - -Be aware that no checks to find possible loops that would result of this creation will be performed. - -==== Removing a domain mapping - -.... -curl -XDELETE http://ip:port/domainMappings/sourceDomain.tld -.... - -Body: - -.... -destination.tld -.... - -With `sourceDomain.tld` as the value passed to `fromDomain` resource -name, the API will remove a destination domain specified in the body -mapped to that domain - -Response codes: - -* 204: OK -* 400: The `fromDomain` resource name is invalid -* 400: The destination domain specified in the body is invalid - -=== Regex mapping - -You can use *webadmin* to create regex mappings. - -A regex mapping contains a mapping source and a Java Regular Expression -(regex) in String as the mapping value. Everytime, if a mail containing -a recipient matched with the mapping source, then that mail will be -re-routed to a new recipient address which is re written by the regex. - -This feature uses xref:distributed/architecture/index.adoc#_recipient_rewrite_tables[Recipients rewrite table] -and requires the -https://github.com/apache/james-project/blob/master/server/mailet/mailets/src/main/java/org/apache/james/transport/mailets/RecipientRewriteTable.java[RecipientRewriteTable -API] to be configured. - -==== Adding a regex mapping - -.... -POST /mappings/regex/mappingSource/targets/regex -.... - -Where: - -* the `mappingSource` is the path parameter represents for the Regex -Mapping mapping source -* the `regex` is the path parameter represents for the Regex Mapping -regex - -The route will add a regex mapping made from `mappingSource` and `regex` -to RecipientRewriteTable. - -Example: - -.... -curl -XPOST http://ip:port/mappings/regex/james@domain.tld/targets/james@.*:james-intern@james.org -.... - -Response codes: - -* 204: Mapping added successfully. -* 400: Invalid `mappingSource` path parameter. -* 400: Invalid `regex` path parameter. - -Be aware that no checks to find possible loops that would result of this creation will be performed. - -==== Removing a regex mapping - -.... -DELETE /mappings/regex/{mappingSource}/targets/{regex} -.... - -Where: - -* the `mappingSource` is the path parameter representing the Regex -Mapping mapping source -* the `regex` is the path parameter representing the Regex Mapping regex - -The route will remove the regex mapping made from `regex` from the -mapping source `mappingSource` to RecipientRewriteTable. - -Example: - -.... -curl -XDELETE http://ip:port/mappings/regex/james@domain.tld/targets/[O_O]:james-intern@james.org -.... - -Response codes: - -* 204: Mapping deleted successfully. -* 400: Invalid `mappingSource` path parameter. -* 400: Invalid `regex` path parameter. - -=== Address Mappings - -You can use *webadmin* to define address mappings. - -When a specific email is sent to the base mail address, every -destination addresses will receive it. - -This feature uses xref:distributed/architecture/index.adoc#_recipient_rewrite_tables[Recipients rewrite table] -and requires the -https://github.com/apache/james-project/blob/master/server/mailet/mailets/src/main/java/org/apache/james/transport/mailets/RecipientRewriteTable.java[RecipientRewriteTable -mailet] to be configured. - -Note that email addresses are restricted to ASCII character set. Mail -addresses not matching this criteria will be rejected. - -Please use address mappings with caution, as it’s not a typed address. -If you know the type of your address (forward, alias, domain, group, -etc), prefer using the corresponding routes to those types. - -Here are the following actions available on address mappings: - -==== Add an address mapping - -.... -curl -XPOST http://ip:port/mappings/address/{mappingSource}/targets/{destinationAddress} -.... - -Add an address mapping to the Recipients rewrite table -Mapping source is the value of \{mappingSource} Mapping destination is -the value of \{destinationAddress} Type of mapping destination is -Address - -Response codes: - -* 204: Action successfully performed -* 400: Invalid parameters -* 409: The addition of the address mapping would lead to a loop and thus cannot be performed - -==== Remove an address mapping - -.... -curl -XDELETE http://ip:port/mappings/address/{mappingSource}/targets/{destinationAddress} -.... - -* Remove an address mapping from the Recipients rewrite table -* Mapping source is the value of `mappingSource` -* Mapping destination is the value of `destinationAddress` -* Type of mapping destination is Address - -Response codes: - -* 204: Action successfully performed -* 400: Invalid parameters - -=== List all mappings - -.... -curl -XGET http://ip:port/mappings -.... - -Get all mappings from the -xref:distributed/architecture/index.adoc#_recipient_rewrite_tables[Recipients rewrite table]. - -Response body: - -.... -{ - "alias@domain.tld": [ - { - "type": "Alias", - "mapping": "user@domain.tld" - }, - { - "type": "Group", - "mapping": "group-user@domain.tld" - } - ], - "aliasdomain.tld": [ - { - "type": "Domain", - "mapping": "realdomain.tld" - } - ], - "group@domain.tld": [ - { - "type": "Address", - "mapping": "user@domain.tld" - } - ] -} -.... - -Response code: - -* 200: OK - -=== Listing User Mappings - -This endpoint allows receiving all mappings of a corresponding user. - -.... -curl -XGET http://ip:port/mappings/user/{userAddress} -.... - -Return all mappings of a user where: - -* `userAddress`: is the selected user - -Response body: - -.... -[ - { - "type": "Address", - "mapping": "user123@domain.tld" - }, - { - "type": "Alias", - "mapping": "aliasuser123@domain.tld" - }, - { - "type": "Group", - "mapping": "group123@domain.tld" - } -] -.... - -Response codes: - -* 200: OK -* 400: Invalid parameter value - -== Administrating mail repositories - -=== Create a mail repository - -.... -curl -XPUT http://ip:port/mailRepositories/{encodedPathOfTheRepository}?protocol={someProtocol} -.... - -Resource name `encodedPathOfTheRepository` should be the resource path -of the created mail repository. Example: - -.... -curl -XPUT http://ip:port/mailRepositories/mailRepo?protocol=file -.... - -Response codes: - -* 204: The repository is created - -=== Listing mail repositories - -.... -curl -XGET http://ip:port/mailRepositories -.... - -The answer looks like: - -.... -[ - { - "repository": "var/mail/error/", - "path": "var%2Fmail%2Ferror%2F" - }, - { - "repository": "var/mail/relay-denied/", - "path": "var%2Fmail%2Frelay-denied%2F" - }, - { - "repository": "var/mail/spam/", - "path": "var%2Fmail%2Fspam%2F" - }, - { - "repository": "var/mail/address-error/", - "path": "var%2Fmail%2Faddress-error%2F" - } -] -.... - -You can use `id`, the encoded URL of the repository, to access it in -later requests. - -Response codes: - -* 200: The list of mail repositories - -=== Getting additional information for a mail repository - -.... -curl -XGET http://ip:port/mailRepositories/{encodedPathOfTheRepository} -.... - -Resource name `encodedPathOfTheRepository` should be the resource path -of an existing mail repository. Example: - -.... -curl -XGET http://ip:port/mailRepositories/var%2Fmail%2Ferror%2F -.... - -The answer looks like: - -.... -{ - "repository": "var/mail/error/", - "path": "mail%2Ferror%2F", - "size": 243 -} -.... - -Response codes: - -* 200: Additonnal information for that repository -* 404: This repository can not be found - -=== Listing mails contained in a mail repository - -.... -curl -XGET http://ip:port/mailRepositories/{encodedPathOfTheRepository}/mails -.... - -Resource name `encodedPathOfTheRepository` should be the resource path -of an existing mail repository. Example: - -.... -curl -XGET http://ip:port/mailRepositories/var%2Fmail%2Ferror%2F/mails -.... - -The answer will contains all mailKey contained in that repository. - -.... -[ - "mail-key-1", - "mail-key-2", - "mail-key-3" -] -.... - -Note that this can be used to read mail details. - -You can pass additional URL parameters to this call in order to limit -the output: - A limit: no more elements than the specified limit will be -returned. This needs to be strictly positive. If no value is specified, -no limit will be applied. - An offset: allow to skip elements. This -needs to be positive. Default value is zero. - -Example: - -.... -curl -XGET 'http://ip:port/mailRepositories/var%2Fmail%2Ferror%2F/mails?limit=100&offset=500' -.... - -Response codes: - -* 200: The list of mail keys contained in that mail repository -* 400: Invalid parameters -* 404: This repository can not be found - -=== Reading/downloading a mail details - -.... -curl -XGET http://ip:port/mailRepositories/{encodedPathOfTheRepository}/mails/mailKey -.... - -Resource name `encodedPathOfTheRepository` should be the resource path -of an existing mail repository. Resource name `mailKey` should be the -key of a mail stored in that repository. Example: - -.... -curl -XGET http://ip:port/mailRepositories/var%2Fmail%2Ferror%2F/mails/mail-key-1 -.... - -If the Accept header in the request is ``application/json'', then the -response looks like: - -.... -{ - "name": "mail-key-1", - "sender": "sender@domain.com", - "recipients": ["recipient1@domain.com", "recipient2@domain.com"], - "state": "address-error", - "error": "A small message explaining what happened to that mail...", - "remoteHost": "111.222.333.444", - "remoteAddr": "127.0.0.1", - "lastUpdated": null -} -.... - -If the Accept header in the request is ``message/rfc822'', then the -response will be the _eml_ file itself. - -Additional query parameter `additionalFields` add the existing -information to the response for the supported values (only work with -``application/json'' Accept header): - -* attributes -* headers -* textBody -* htmlBody -* messageSize -* perRecipientsHeaders - -.... -curl -XGET http://ip:port/mailRepositories/file%3A%2F%2Fvar%2Fmail%2Ferror%2F/mails/mail-key-1?additionalFields=attributes,headers,textBody,htmlBody,messageSize,perRecipientsHeaders -.... - -Give the following kind of response: - -.... -{ - "name": "mail-key-1", - "sender": "sender@domain.com", - "recipients": ["recipient1@domain.com", "recipient2@domain.com"], - "state": "address-error", - "error": "A small message explaining what happened to that mail...", - "remoteHost": "111.222.333.444", - "remoteAddr": "127.0.0.1", - "lastUpdated": null, - "attributes": { - "name2": "value2", - "name1": "value1" - }, - "perRecipientsHeaders": { - "third@party": { - "headerName1": [ - "value1", - "value2" - ], - "headerName2": [ - "value3", - "value4" - ] - } - }, - "headers": { - "headerName4": [ - "value6", - "value7" - ], - "headerName3": [ - "value5", - "value8" - ] - }, - "textBody": "My body!!", - "htmlBody": "My body!!", - "messageSize": 42424242 -} -.... - -Response codes: - -* 200: Details of the mail -* 404: This repository or mail can not be found - -=== Removing a mail from a mail repository - -.... -curl -XDELETE http://ip:port/mailRepositories/{encodedPathOfTheRepository}/mails/mailKey -.... - -Resource name `encodedPathOfTheRepository` should be the resource path -of an existing mail repository. Resource name `mailKey` should be the -key of a mail stored in that repository. Example: - -.... -curl -XDELETE http://ip:port/mailRepositories/var%2Fmail%2Ferror%2F/mails/mail-key-1 -.... - -Response codes: - -* 204: This mail no longer exists in this repository -* 404: This repository can not be found - -=== Removing all mails from a mail repository - -.... -curl -XDELETE http://ip:port/mailRepositories/{encodedPathOfTheRepository}/mails -.... - -Resource name `encodedPathOfTheRepository` should be the resource path -of an existing mail repository. Example: - -.... -curl -XDELETE http://ip:port/mailRepositories/var%2Fmail%2Ferror%2F/mails -.... - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -Response codes: - -* 201: Task generation succeeded. Corresponding task id is returned. -* 404: Could not find that mail repository - -The scheduled task will have the following type `clear-mail-repository` -and the following `additionalInformation`: - -.... -{ - "mailRepositoryPath":"var/mail/error/", - "initialCount": 243, - "remainingCount": 17 -} -.... - -=== Reprocessing mails from a mail repository - -Sometime, you want to re-process emails stored in a mail repository. For -instance, you can make a configuration error, or there can be a James -bug that makes processing of some mails fail. Those mail will be stored -in a mail repository. Once you solved the problem, you can reprocess -them. - -To reprocess mails from a repository: - -.... -curl -XPATCH http://ip:port/mailRepositories/{encodedPathOfTheRepository}/mails?action=reprocess -.... - -Resource name `encodedPathOfTheRepository` should be the resource path -of an existing mail repository. Example: - -For instance: - -.... -curl -XPATCH http://ip:port/mailRepositories/var%2Fmail%2Ferror%2F/mails?action=reprocess -.... - -Additional query parameters are supported: - -- `queue` allows you to -target the mail queue you want to enqueue the mails in. Defaults to -`spool`. -- `processor` allows you to overwrite the state of the -reprocessing mails, and thus select the processors they will start their -processing in. Defaults to the `state` field of each processed email. -- `consume` (boolean defaulting to `true`) whether the reprocessing should consume the mail in its originating mail repository. Passing -this value to `false` allows non destructive reprocessing as you keep a copy of the email in the mail repository and can be valuable -when debugging. -- `limit` (integer value. Optional, default is empty). It enables to limit the count of elements reprocessed. -If unspecified the count of the processed elements is unbounded. -- `maxRetries` Optional integer, defaults to no max retries limit. Only processed emails that had been retried less -than this value. Ignored by default. - -redeliver_group_events - -.... -curl -XPATCH 'http://ip:port/mailRepositories/var%2Fmail%2Ferror%2F/mails?action=reprocess&processor=transport&queue=spool' -.... - -Note that the `action` query parameter is compulsary and can only take -value `reprocess`. - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -Response codes: - -* 201: Task generation succeeded. Corresponding task id is returned. -* 404: Could not find that mail repository - -The scheduled task will have the following type `reprocessing-all` and -the following `additionalInformation`: - -.... -{ - "mailRepositoryPath":"var/mail/error/", - "targetQueue":"spool", - "targetProcessor":"transport", - "initialCount": 243, - "remainingCount": 17 -} -.... - -=== Reprocessing a specific mail from a mail repository - -To reprocess a specific mail from a mail repository: - -.... -curl -XPATCH http://ip:port/mailRepositories/{encodedPathOfTheRepository}/mails/mailKey?action=reprocess -.... - -Resource name `encodedPathOfTheRepository` should be the resource id of -an existing mail repository. Resource name `mailKey` should be the key -of a mail stored in that repository. Example: - -For instance: - -.... -curl -XPATCH http://ip:port/mailRepositories/var%2Fmail%2Ferror%2F/mails/name1?action=reprocess -.... - -Additional query parameters are supported: - -- `queue` allows you to -target the mail queue you want to enqueue the mails in. Defaults to -`spool`. -- `processor` allows you to overwrite the state of the -reprocessing mails, and thus select the processors they will start their -processing in. Defaults to the `state` field of each processed email. -- `consume` (boolean defaulting to `true`) whether the reprocessing should consume the mail in its originating mail repository. Passing -this value to `false` allows non destructive reprocessing as you keep a copy of the email in the mail repository and can be valuable -when debugging. - -While `processor` being an optional parameter, not specifying it will -result reprocessing the mails in their current state -(https://james.apache.org/server/feature-mailetcontainer.html#Processors[see -documentation about processors and state]). Consequently, only few cases -will give a different result, definitively storing them out of the mail -repository. - -For instance: - -.... -curl -XPATCH 'http://ip:port/mailRepositories/var%2Fmail%2Ferror%2F/mails/name1?action=reprocess&processor=transport&queue=spool' -.... - -Note that the `action` query parameter is compulsary and can only take -value `reprocess`. - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -Response codes: - -* 201: Task generation succeeded. Corresponding task id is returned. -* 404: Could not find that mail repository - -The scheduled task will have the following type `reprocessing-one` and -the following `additionalInformation`: - -.... -{ - "mailRepositoryPath":"var/mail/error/", - "targetQueue":"spool", - "targetProcessor":"transport", - "mailKey":"name1" -} -.... - -== Administrating mail queues - -=== Listing mail queues - -.... -curl -XGET http://ip:port/mailQueues -.... - -The answer looks like: - -.... -["outgoing","spool"] -.... - -Response codes: - -* 200: The list of mail queues - -=== Getting a mail queue details - -.... -curl -XGET http://ip:port/mailQueues/{mailQueueName} -.... - -Resource name `mailQueueName` is the name of a mail queue, this command -will return the details of the given mail queue. For instance: - -.... -{"name":"outgoing","size":0} -.... - -Response codes: - -* 200: Success -* 400: Mail queue is not valid -* 404: The mail queue does not exist - -=== Listing the mails of a mail queue - -.... -curl -XGET http://ip:port/mailQueues/{mailQueueName}/mails -.... - -Additional URL query parameters: - -* `limit`: Maximum number of mails returned in a single call. Only -strictly positive integer values are accepted. Example: - -.... -curl -XGET http://ip:port/mailQueues/{mailQueueName}/mails?limit=100 -.... - -The answer looks like: - -.... -[{ - "name": "Mail1516976156284-8b3093b9-eebf-4c40-9c26-1450f4fcdc3c-to-test.com", - "sender": "user@james.linagora.com", - "recipients": ["someone@test.com"], - "nextDelivery": "1969-12-31T23:59:59.999Z" -}] -.... - -Response codes: - -* 200: Success -* 400: Mail queue is not valid or limit is invalid -* 404: The mail queue does not exist - -=== Deleting mails from a mail queue - -.... -curl -XDELETE http://ip:port/mailQueues/{mailQueueName}/mails?sender=senderMailAddress -.... - -This request should have exactly one query parameter from the following -list: - -* sender: which is a mail address (i.e. sender@james.org) -* name: which is a string -* recipient: which is a mail address (i.e. recipient@james.org) - -The mails from the given mail queue matching the query parameter will be -deleted. - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -Response codes: - -* 201: Task generation succeeded. Corresponding task id is returned. -* 400: Invalid request -* 404: The mail queue does not exist - -The scheduled task will have the following type -`delete-mails-from-mail-queue` and the following -`additionalInformation`: - -.... -{ - "queue":"outgoing", - "initialCount":10, - "remainingCount": 5, - "sender": "sender@james.org", - "name": "Java Developer", - "recipient: "recipient@james.org" -} -.... - -=== Clearing a mail queue - -.... -curl -XDELETE http://ip:port/mailQueues/{mailQueueName}/mails -.... - -All mails from the given mail queue will be deleted. - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -Response codes: - -* 201: Task generation succeeded. Corresponding task id is returned. -* 400: Invalid request -* 404: The mail queue does not exist - -The scheduled task will have the following type `clear-mail-queue` and -the following `additionalInformation`: - -.... -{ - "queue":"outgoing", - "initialCount":10, - "remainingCount": 0 -} -.... - -=== Flushing mails from a mail queue - -.... -curl -XPATCH http://ip:port/mailQueues/{mailQueueName}?delayed=true \ - -d '{"delayed": false}' \ - -H "Content-Type: application/json" -.... - -This request should have the query parameter _delayed_ set to _true_, in -order to indicate only delayed mails are affected. The payload should -set the `delayed` field to false inorder to remove the delay. This is -the only supported combination, and it performs a flush. - -The mails delayed in the given mail queue will be flushed. - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -Response codes: - -* 204: Success (No content) -* 400: Invalid request -* 404: The mail queue does not exist - -=== RabbitMQ republishing a mail queue from cassandra - -.... -curl -XPOST 'http://ip:port/mailQueues/{mailQueueName}?action=RepublishNotProcessedMails&olderThan=1d' -.... - -This method is specific to the distributed flavor of James, which relies -on Cassandra and RabbitMQ for implementing a mail queue. In case of a -RabbitMQ crash resulting in a loss of messages, this task can be -launched to repopulate the `mailQueueName` queue in RabbitMQ using the -information stored in Cassandra. - -The `olderThan` parameter is mandatory. It filters the mails to be -restored, by taking into account only the mails older than the given -value. The expected value should be expressed in the following format: -`Nunit`. `N` should be strictly positive. `unit` could be either in the -short form (`h`, `d`, `w`, etc.), or in the long form (`day`, `week`, -`month`, etc.). - -Examples: - -* `5h` -* `7d` -* `1y` - -Response codes: - -* 201: Task created -* 400: Invalid request - -The response body contains the id of the republishing task. -`{ "taskId": "a650a66a-5984-431e-bdad-f1baad885856" }` - -=== Cassandra view of the RabbitMQ mailQueue: browse start update - -.... -curl -XPOST 'http://ip:port/mailQueues/{mailQueueName}?action=updateBrowseStart -.... - -Will return a task that updates the browse start of the aforementioned mailQueue, regardless of the configuration. - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -This is an advanced, potentially expensive operation which requires a good understanding of the RabbitMQMailQueue design -(https://github.com/apache/james-project/blob/master/src/adr/0031-distributed-mail-queue.md). Especially, care needs to -be taken to call this at most once per slice (not doing so might be expensive). - -== Sending email over webAdmin - -.... -curl -XPOST /mail-transfer-service - -{MIME message} -.... - -Will send the following email to the recipients specified in the MIME message. - -The `{MIME message}` payload must match `message/rfc822` format. - -== Event Dead Letter - -The EventBus allows to register `group listeners' that are called in a -distributed fashion. These group listeners enable the implementation of -some advanced mailbox manager feature like indexing, spam reporting, -quota management and the like. - -Upon exceptions, a bounded number of retries are performed (with -exponential backoff delays). If after those retries the listener is -still failing, then the event will be stored in the ``Event Dead -Letter''. This API allows diagnosing issues, as well as performing event -replay. - -=== Listing mailbox listener groups - -This endpoint allows discovering the list of mailbox listener groups. - -.... -curl -XGET http://ip:port/events/deadLetter/groups -.... - -Will return a list of group names that can be further used to interact -with the dead letter API: - -.... -["org.apache.james.mailbox.events.EventBusTestFixture$GroupA", "org.apache.james.mailbox.events.GenericGroup-abc"] -.... - -Response codes: - -* 200: Success. A list of group names is returned. - -=== Listing failed events - -This endpoint allows listing failed events for a given group: - -.... -curl -XGET http://ip:port/events/deadLetter/groups/org.apache.james.mailbox.events.EventBusTestFixture$GroupA -.... - -Will return a list of insertionIds: - -.... -["6e0dd59d-660e-4d9b-b22f-0354479f47b4", "58a8f59d-660e-4d9b-b22f-0354486322a2"] -.... - -Response codes: - -* 200: Success. A list of insertion ids is returned. -* 400: Invalid group name - -=== Getting event details - -.... -curl -XGET http://ip:port/events/deadLetter/groups/org.apache.james.mailbox.events.EventBusTestFixture$GroupA/6e0dd59d-660e-4d9b-b22f-0354479f47b4 -.... - -Will return the full JSON associated with this event. - -Response codes: - -* 200: Success. A JSON representing this event is returned. -* 400: Invalid group name or `insertionId` -* 404: No event with this `insertionId` - -=== Deleting an event - -.... -curl -XDELETE http://ip:port/events/deadLetter/groups/org.apache.james.mailbox.events.EventBusTestFixture$GroupA/6e0dd59d-660e-4d9b-b22f-0354479f47b4 -.... - -Will delete this event. - -Response codes: - -* 204: Success -* 400: Invalid group name or `insertionId` - -=== Deleting all events of a group - -.... -curl -XDELETE http://ip:port/events/deadLetter/groups/org.apache.james.mailbox.events.EventBusTestFixture$GroupA -.... - -Will delete all events of this group. - -Response codes: - -* 204: Success -* 400: Invalid group name - -=== Redeliver all events - -.... -curl -XPOST http://ip:port/events/deadLetter?action=reDeliver -.... - -Additional query parameters are supported: - -- `limit` (integer value. Optional, default is empty). It enables to limit the count of elements redelivered. -If unspecified the count of the processed elements is unbounded - -For instance: - -.... -curl -XPOST http://ip:port/events/deadLetter?action=reDeliver&limit=10 -.... - -Will create a task that will attempt to redeliver all events stored in -``Event Dead Letter''. If successful, redelivered events will then be -removed from ``Dead Letter''. - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -Response codes: - -* 201: the taskId of the created task -* 400: Invalid action argument - -=== Redeliver group events - -.... -curl -XPOST http://ip:port/events/deadLetter/groups/org.apache.james.mailbox.events.EventBusTestFixture$GroupA?action=reDeliver -.... - -Will create a task that will attempt to redeliver all events of a -particular group stored in ``Event Dead Letter''. If successful, -redelivered events will then be removed from ``Dead Letter''. - -Additional query parameters are supported: - -- `limit` (integer value. Optional, default is empty). It enables to limit the count of elements redelivered. -If unspecified the count of the processed elements is unbounded - -For instance: - -.... -curl -XPOST http://ip:port/events/deadLetter/groups/org.apache.james.mailbox.events.EventBusTestFixture$GroupA?action=reDeliver&limit=10 -.... - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -Response codes: - -* 201: the taskId of the created task -* 400: Invalid group name or action argument - -=== Redeliver a single event - -.... -curl -XPOST http://ip:port/events/deadLetter/groups/org.apache.james.mailbox.events.EventBusTestFixture$GroupA/6e0dd59d-660e-4d9b-b22f-0354479f47b4?action=reDeliver -.... - -Will create a task that will attempt to redeliver a single event of a -particular group stored in ``Event Dead Letter''. If successful, -redelivered event will then be removed from ``Dead Letter''. - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -Response codes: - -* 201: the taskId of the created task -* 400: Invalid group name, insertion id or action argument -* 404: No event with this insertionId +:server-name: Distributed James Server +:xref-base: distributed +:backend-name: Cassandra +:admin-mail-queues-extend: servers:distributed/operate/webadmin/admin-mail-queues-extend.adoc +:admin-messages-extend: servers:distributed/operate/webadmin/admin-messages-extend.adoc +:admin-mailboxes-extend: servers:distributed/operate/webadmin/admin-mailboxes-extend.adoc +include::partial$operate/webadmin.adoc[] == Cassandra extra operations @@ -4497,471 +219,3 @@ the following `additionalInformation`: "messageFailedCount": 0 } .... - -== Deleted Messages Vault - -The `Deleted Message Vault plugin' allows you to keep users deleted -messages during a given retention time. This set of routes allow you to -_restore_ users deleted messages or export them in an archive. - -To move deleted messages in the vault, you need to specifically -configure the DeletedMessageVault PreDeletionHook. - -=== Restore Deleted Messages - -Deleted messages of a specific user can be restored by calling the -following endpoint: - -.... -curl -XPOST http://ip:port/deletedMessages/users/userToRestore@domain.ext?action=restore - -{ - "combinator": "and", - "criteria": [ - { - "fieldName": "subject", - "operator": "containsIgnoreCase", - "value": "Apache James" - }, - { - "fieldName": "deliveryDate", - "operator": "beforeOrEquals", - "value": "2014-10-30T14:12:00Z" - }, - { - "fieldName": "deletionDate", - "operator": "afterOrEquals", - "value": "2015-10-20T09:08:00Z" - }, - { - "fieldName": "recipients"," - "operator": "contains"," - "value": "recipient@james.org" - }, - { - "fieldName": "hasAttachment", - "operator": "equals", - "value": "false" - }, - { - "fieldName": "sender", - "operator": "equals", - "value": "sender@apache.org" - }, - { - "fieldName": "originMailboxes", - "operator": "contains", - "value": "02874f7c-d10e-102f-acda-0015176f7922" - } - ] -}; -.... - -The requested Json body is made from a list of criterion objects which -have the following structure: - -.... -{ - "fieldName": "supportedFieldName", - "operator": "supportedOperator", - "value": "A plain string representing the matching value of the corresponding field" -} -.... - -Deleted Messages which are matched with the *all* criterion in the query -body will be restored. Here are a list of supported fieldName for the -restoring: - -* subject: represents for deleted message `subject` field matching. -Supports below string operators: -** contains -** containsIgnoreCase -** equals -** equalsIgnoreCase -* deliveryDate: represents for deleted message `deliveryDate` field -matching. Tested value should follow the right date time with zone -offset format (ISO-8601) like `2008-09-15T15:53:00+05:00` or -`2008-09-15T15:53:00Z` Supports below date time operators: -** beforeOrEquals: is the deleted message’s `deliveryDate` before or -equals the time of tested value. -** afterOrEquals: is the deleted message’s `deliveryDate` after or -equals the time of tested value -* deletionDate: represents for deleted message `deletionDate` field -matching. Tested value & Supports operators: similar to `deliveryDate` -* sender: represents for deleted message `sender` field matching. Tested -value should be a valid mail address. Supports mail address operator: -** equals: does the tested sender equal to the sender of the tested -deleted message ? + -* recipients: represents for deleted message `recipients` field -matching. Tested value should be a valid mail address. Supports list -mail address operator: -** contains: does the tested deleted message’s recipients contain tested -recipient ? -* hasAttachment: represents for deleted message `hasAttachment` field -matching. Tested value could be `false` or `true`. Supports boolean -operator: -** equals: does the tested deleted message’s hasAttachment property -equal to the tested hasAttachment value? -* originMailboxes: represents for deleted message `originMailboxes` -field matching. Tested value is a string serialized of mailbox id. -Supports list mailbox id operators: -** contains: does the tested deleted message’s originMailbox ids contain -tested mailbox id ? - -Messages in the Deleted Messages Vault of a specified user that are -matched with Query Json Object in the body will be appended to his -`Restored-Messages' mailbox, which will be created if needed. - -*Note*: - -* Query parameter `action` is required and should have the value -`restore` to represent the restoring feature. Otherwise, a bad request -response will be returned -* Query parameter `action` is case sensitive -* fieldName & operator passed to the routes are case sensitive -* Currently, we only support query combinator `and` value, otherwise, -requests will be rejected -* If you only want to restore by only one criterion, the json body could -be simplified to a single criterion: - -.... -{ - "fieldName": "subject", - "operator": "containsIgnoreCase", - "value": "Apache James" -} -.... - -* For restoring all deleted messages, passing a query json with an empty -criterion list to represent `matching all deleted messages`: - -.... -{ - "combinator": "and", - "criteria": [] -} -.... - -* For limiting the number of restored messages, you can use the `limit` query property: - -.... -{ - "combinator": "and", - "limit": 99 - "criteria": [] -} -.... - -*Warning*: Current web-admin uses `US` locale as the default. Therefore, -there might be some conflicts when using String `containsIgnoreCase` -comparators to apply on the String data of other special locales stored -in the Vault. More details at -https://issues.apache.org/jira/browse/MAILBOX-384[JIRA] - -Response code: - -* 201: Task for restoring deleted has been created -* 400: Bad request: -** action query param is not present -** action query param is not a valid action -** user parameter is invalid -** can not parse the JSON body -** Json query object contains unsupported operator, fieldName -** Json query object values violate parsing rules -* 404: User not found - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -The scheduled task will have the following type -`deleted-messages-restore` and the following `additionalInformation`: - -.... -{ - "successfulRestoreCount": 47, - "errorRestoreCount": 0, - "user": "userToRestore@domain.ext" -} -.... - -while: - -* successfulRestoreCount: number of restored messages -* errorRestoreCount: number of messages that failed to restore -* user: owner of deleted messages need to restore - -=== Export Deleted Messages - -Retrieve deleted messages matched with requested query from an user then -share the content to a targeted mail address (exportTo) - -.... -curl -XPOST 'http://ip:port/deletedMessages/users/userExportFrom@domain.ext?action=export&exportTo=userReceiving@domain.ext' - -BODY: is the json query has the same structure with Restore Deleted Messages section -.... - -*Note*: Json query passing into the body follows the same rules & -restrictions like in link:#_restore_deleted_messages[Restore Deleted -Messages] - -Response code: - -* 201: Task for exporting has been created -* 400: Bad request: -** exportTo query param is not present -** exportTo query param is not a valid mail address -** action query param is not present -** action query param is not a valid action -** user parameter is invalid -** can not parse the JSON body -** Json query object contains unsupported operator, fieldName -** Json query object values violate parsing rules -* 404: User not found - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -The scheduled task will have the following type -`deleted-messages-export` and the following `additionalInformation`: - -.... -{ - "userExportFrom": "userToRestore@domain.ext", - "exportTo": "userReceiving@domain.ext", - "totalExportedMessages": 1432 -} -.... - -while: - -* userExportFrom: export deleted messages from this user -* exportTo: content of deleted messages have been shared to this mail -address -* totalExportedMessages: number of deleted messages match with -json query, then being shared to sharee. - -=== Purge Deleted Messages - -You can overwrite `retentionPeriod' configuration in -`deletedMessageVault' configuration file or use the default value of 1 -year. - -Purge all deleted messages older than the configured `retentionPeriod' - -.... -curl -XDELETE http://ip:port/deletedMessages?scope=expired -.... - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -Response code: - -* 201: Task for purging has been created -* 400: Bad request: -** action query param is not present -** action query param is not a valid action - -You may want to call this endpoint on a regular basis. - -=== Permanently Remove Deleted Message - -Delete a Deleted Message with `MessageId` - -.... -curl -XDELETE http://ip:port/deletedMessages/users/user@domain.ext/messages/3294a976-ce63-491e-bd52-1b6f465ed7a2 -.... - -link:#_endpoints_returning_a_task[More details about endpoints returning -a task]. - -Response code: - -* 201: Task for deleting message has been created -* 400: Bad request: -** user parameter is invalid -** messageId parameter is invalid -* 404: User not found - -The scheduled task will have the following type -`deleted-messages-delete` and the following `additionalInformation`: - -.... - { - "userName": "user@domain.ext", - "messageId": "3294a976-ce63-491e-bd52-1b6f465ed7a2" - } -.... - -while: - user: delete deleted messages from this user - deleteMessageId: -messageId of deleted messages will be delete - -== Administrating DLP Configuration - -DLP (stands for Data Leak Prevention) is supported by James. A DLP -matcher will, on incoming emails, execute regular expressions on email -sender, recipients or content, in order to report suspicious emails to -an administrator. WebAdmin can be used to manage these DLP rules on a -per `senderDomain` basis. - -`senderDomain` is domain of the sender of incoming emails, for example: -`apache.org`, `james.org`,… Each `senderDomain` correspond to a distinct -DLP configuration. - -=== List DLP configuration by sender domain - -Retrieve a DLP configuration for corresponding `senderDomain`, a -configuration contains list of configuration items - -.... -curl -XGET http://ip:port/dlp/rules/{senderDomain} -.... - -Response codes: - -* 200: A list of dlp configuration items is returned -* 400: Invalid `senderDomain` or payload in request -* 404: The domain does not exist. - -This is an example of returned body. The rules field is a list of rules -as described below. - -.... -{"rules : [ - { - "id": "1", - "expression": "james.org", - "explanation": "Find senders or recipients containing james[any char]org", - "targetsSender": true, - "targetsRecipients": true, - "targetsContent": false - }, - { - "id": "2", - "expression": "Find senders containing apache[any char]org", - "explanation": "apache.org", - "targetsSender": true, - "targetsRecipients": false, - "targetsContent": false - } -]} -.... - -=== Store DLP configuration by sender domain - -Store a DLP configuration for corresponding `senderDomain`, if any item -of DLP configuration in the request is stored before, it will not be -stored anymore - -.... -curl -XPUT http://ip:port/dlp/rules/{senderDomain} -.... - -The body can contain a list of DLP configuration items formed by those -fields: - `id`(String) is mandatory, unique identifier of the -configuration item - `expression`(String) is mandatory, regular -expression to match contents of targets - `explanation`(String) is -optional, description of the configuration item - -`targetsSender`(boolean) is optional and defaults to false. If true, -`expression` will be applied to Sender and to From headers of the mail - -`targetsContent`(boolean) is optional and defaults to false. If true, -`expression` will be applied to Subject headers and textual bodies -(text/plain and text/html) of the mail - `targetsRecipients`(boolean) is -optional and defaults to false. If true, `expression` will be applied to -recipients of the mail - -This is an example of returned body. The rules field is a list of rules -as described below. - -.... -{"rules": [ - { - "id": "1", - "expression": "james.org", - "explanation": "Find senders or recipients containing james[any char]org", - "targetsSender": true, - "targetsRecipients": true, - "targetsContent": false - }, - { - "id": "2", - "expression": "Find senders containing apache[any char]org", - "explanation": "apache.org", - "targetsSender": true, - "targetsRecipients": false, - "targetsContent": false - } -]} -.... - -Response codes: - -* 204: List of dlp configuration items is stored -* 400: Invalid `senderDomain` or payload in request -* 404: The domain does not exist. - -=== Remove DLP configuration by sender domain - -Remove a DLP configuration for corresponding `senderDomain` - -.... -curl -XDELETE http://ip:port/dlp/rules/{senderDomain} -.... - -Response codes: - -* 204: DLP configuration is removed -* 400: Invalid `senderDomain` or payload in request -* 404: The domain does not exist. - -=== Fetch a DLP configuration item by sender domain and rule id - -Retrieve a DLP configuration rule for corresponding `senderDomain` and a -`ruleId` - -.... -curl -XGET http://ip:port/dlp/rules/{senderDomain}/rules/{ruleId} -.... - -Response codes: - -* 200: A dlp configuration item is returned -* 400: Invalid `senderDomain` or payload in request -* 404: The domain and/or the rule does not exist. - -This is an example of returned body. - -.... -{ - "id": "1", - "expression": "james.org", - "explanation": "Find senders or recipients containing james[any char]org", - "targetsSender": true, - "targetsRecipients": true, - "targetsContent": false -} -.... - -== Reloading server certificates - -Certificates for TCP based protocols (IMAP, SMTP, POP3, LMTP and ManageSieve) can be updated at -runtime, without service interuption and without closing existing connections. - -In order to do so: - - - Generate / retrieve your cryptographic materials and replace the ones specified in James configuration. - - Then call the following endpoint: - -.... -curl -XPOST http://ip:port/servers?reload-certificate -.... - -Optional query parameters: - - - `port`: positive integer (valid port number). Only reload certificates for the specific port. - -Return code: - - - 204: the certificate is reloaded - - 400: Invalid request. \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/operate/webadmin/admin-mail-queues-extend.adoc b/docs/modules/servers/pages/distributed/operate/webadmin/admin-mail-queues-extend.adoc new file mode 100644 index 00000000000..377be5637bf --- /dev/null +++ b/docs/modules/servers/pages/distributed/operate/webadmin/admin-mail-queues-extend.adoc @@ -0,0 +1,14 @@ +=== Cassandra view of the RabbitMQ mailQueue: browse start update + +.... +curl -XPOST 'http://ip:port/mailQueues/{mailQueueName}?action=updateBrowseStart +.... + +Will return a task that updates the browse start of the aforementioned mailQueue, regardless of the configuration. + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +This is an advanced, potentially expensive operation which requires a good understanding of the RabbitMQMailQueue design +(https://github.com/apache/james-project/blob/master/src/adr/0031-distributed-mail-queue.md). Especially, care needs to +be taken to call this at most once per slice (not doing so might be expensive). \ No newline at end of file diff --git a/docs/modules/servers/pages/distributed/operate/webadmin/admin-mailboxes-extend.adoc b/docs/modules/servers/pages/distributed/operate/webadmin/admin-mailboxes-extend.adoc new file mode 100644 index 00000000000..9342dc4341e --- /dev/null +++ b/docs/modules/servers/pages/distributed/operate/webadmin/admin-mailboxes-extend.adoc @@ -0,0 +1,226 @@ +==== Fixing mailboxes inconsistencies + +.... +curl -XPOST /mailboxes?task=SolveInconsistencies +.... + +Will schedule a task for fixing inconsistencies for the mailbox +deduplicated object stored in Cassandra. + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +The `I-KNOW-WHAT-I-M-DOING` header is mandatory (you can read more +information about it in the warning section below). + +The scheduled task will have the following type +`solve-mailbox-inconsistencies` and the following +`additionalInformation`: + +.... +{ + "type":"solve-mailbox-inconsistencies", + "processedMailboxEntries": 3, + "processedMailboxPathEntries": 3, + "fixedInconsistencies": 2, + "errors": 1, + "conflictingEntries":[{ + "mailboxDaoEntry":{ + "mailboxPath":"#private:user:mailboxName", + "mailboxId":"464765a0-e4e7-11e4-aba4-710c1de3782b" + }," + + "mailboxPathDaoEntry":{ + "mailboxPath":"#private:user:mailboxName2", + "mailboxId":"464765a0-e4e7-11e4-aba4-710c1de3782b" + } + }] +} +.... + +Note that conflicting entry inconsistencies will not be fixed and will +require to explicitly use link:#_correcting_ghost_mailbox[ghost mailbox] +endpoint in order to merge the conflicting mailboxes and prevent any +message loss. + +*WARNING*: this task can cancel concurrently running legitimate user +operations upon dirty read. As such this task should be run offline. + +A dirty read is when data is read between the two writes of the +denormalization operations (no isolation). + +In order to ensure being offline, stop the traffic on SMTP, JMAP and +IMAP ports, for example via re-configuration or firewall rules. + +Due to all of those risks, a `I-KNOW-WHAT-I-M-DOING` header should be +positioned to `ALL-SERVICES-ARE-OFFLINE` in order to prevent accidental +calls. + +==== Recomputing mailbox counters + +.... +curl -XPOST /mailboxes?task=RecomputeMailboxCounters +.... + +Will recompute counters (unseen & total count) for the mailbox object +stored in Cassandra. + +Cassandra maintains a per mailbox projection for message count and +unseen message count. As with any projection, it can go out of sync, +leading to inconsistent results being returned to the client. + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +The scheduled task will have the following type +`recompute-mailbox-counters` and the following `additionalInformation`: + +.... +{ + "type":"recompute-mailbox-counters", + "processedMailboxes": 3, + "failedMailboxes": ["464765a0-e4e7-11e4-aba4-710c1de3782b"] +} +.... + +Note that conflicting inconsistencies entries will not be fixed and will +require to explicitly use link:#_correcting_ghost_mailbox[ghost mailbox] +endpoint in order to merge the conflicting mailboxes and prevent any +message loss. + +*WARNING*: this task do not take into account concurrent modifications +upon a single mailbox counter recomputation. Rerunning the task will +_eventually_ provide the consistent result. As such we advise to run +this task offline. + +In order to ensure being offline, stop the traffic on SMTP, JMAP and +IMAP ports, for example via re-configuration or firewall rules. + +`trustMessageProjection` query parameter can be set to `true`. Content +of `messageIdTable` (listing messages by their mailbox context) table +will be trusted and not compared against content of `imapUidTable` table +(listing messages by their messageId mailbox independent identifier). +This will result in a better performance running the task at the cost of +safety in the face of message denormalization inconsistencies. + +Defaults to false, which generates additional checks. You can read +https://github.com/apache/james-project/blob/master/src/adr/0022-cassandra-message-inconsistency.md[this +ADR] to better understand the message projection and how it can become +inconsistent. + +=== Fixing message inconsistencies + +This task is only available on top of Guice Cassandra products. + +.... +curl -XPOST /messages?task=SolveInconsistencies +.... + +Will schedule a task for fixing message inconsistencies created by the +message denormalization process. + +Messages are denormalized and stored in separated data tables in +Cassandra, so they can be accessed by their unique identifier or mailbox +identifier & local mailbox identifier through different protocols. + +Failure in the denormalization process will lead to inconsistencies, for +example: + +.... +BOB receives a message +The denormalization process fails +BOB can read the message via JMAP +BOB cannot read the message via IMAP + +BOB marks a message as SEEN +The denormalization process fails +The message is SEEN via JMAP +The message is UNSEEN via IMAP +.... + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +An admin can specify the concurrency that should be used when running +the task: + +* `messagesPerSecond` rate of messages to be processed per second. +Default is 100. + +This optional parameter must have a strictly positive integer as a value +and be passed as query parameter. + +An admin can also specify the reindexing mode it wants to use when +running the task: + +* `mode` the reindexing mode used. There are 2 modes for the moment: +** `rebuildAll` allows to rebuild all indexes. This is the default mode. +** `fixOutdated` will check for outdated indexed document and reindex +only those. + +This optional parameter must be passed as query parameter. + +It’s good to note as well that there is a limitation with the +`fixOutdated` mode. As we first collect metadata of stored messages to +compare them with the ones in the index, a failed `expunged` operation +might not be well corrected (as the message might not exist anymore but +still be indexed). + +Example: + +.... +curl -XPOST /messages?task=SolveInconsistencies&messagesPerSecond=200&mode=rebuildAll +.... + +Response codes: + +* 201: Success. Corresponding task id is returned. +* 400: Error in the request. Details can be found in the reported error. + +The scheduled task will have the following type +`solve-message-inconsistencies` and the following +`additionalInformation`: + +.... +{ + "type":"solve-message-inconsistencies", + "timestamp":"2007-12-03T10:15:30Z", + "processedImapUidEntries": 2, + "processedMessageIdEntries": 1, + "addedMessageIdEntries": 1, + "updatedMessageIdEntries": 0, + "removedMessageIdEntries": 1, + "runningOptions":{ + "messagesPerSecond": 200, + "mode":"REBUILD_ALL" + }, + "fixedInconsistencies": [ + { + "mailboxId": "551f0580-82fb-11ea-970e-f9c83d4cf8c2", + "messageId": "d2bee791-7e63-11ea-883c-95b84008f979", + "uid": 1 + }, + { + "mailboxId": "551f0580-82fb-11ea-970e-f9c83d4cf8c2", + "messageId": "d2bee792-7e63-11ea-883c-95b84008f979", + "uid": 2 + } + ], + "errors": [ + { + "mailboxId": "551f0580-82fb-11ea-970e-f9c83d4cf8c2", + "messageId": "ffffffff-7e63-11ea-883c-95b84008f979", + "uid": 3 + } + ] +} +.... + +User actions concurrent to the inconsistency fixing task could result in +concurrency issues. New inconsistencies could be created. + +However the source of truth will not be impacted, hence rerunning the +task will eventually fix all issues. + +This task could be run safely online and can be scheduled on a recurring +basis outside of peak traffic by an admin to ensure Cassandra message +consistency. diff --git a/docs/modules/servers/pages/distributed/operate/webadmin/admin-messages-extend.adoc b/docs/modules/servers/pages/distributed/operate/webadmin/admin-messages-extend.adoc new file mode 100644 index 00000000000..1f77c276581 --- /dev/null +++ b/docs/modules/servers/pages/distributed/operate/webadmin/admin-messages-extend.adoc @@ -0,0 +1,117 @@ +=== Fixing message inconsistencies + +This task is only available on top of Guice Cassandra products. + +.... +curl -XPOST /messages?task=SolveInconsistencies +.... + +Will schedule a task for fixing message inconsistencies created by the +message denormalization process. + +Messages are denormalized and stored in separated data tables in +Cassandra, so they can be accessed by their unique identifier or mailbox +identifier & local mailbox identifier through different protocols. + +Failure in the denormalization process will lead to inconsistencies, for +example: + +.... +BOB receives a message +The denormalization process fails +BOB can read the message via JMAP +BOB cannot read the message via IMAP + +BOB marks a message as SEEN +The denormalization process fails +The message is SEEN via JMAP +The message is UNSEEN via IMAP +.... + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +An admin can specify the concurrency that should be used when running +the task: + +* `messagesPerSecond` rate of messages to be processed per second. +Default is 100. + +This optional parameter must have a strictly positive integer as a value +and be passed as query parameter. + +An admin can also specify the reindexing mode it wants to use when +running the task: + +* `mode` the reindexing mode used. There are 2 modes for the moment: +** `rebuildAll` allows to rebuild all indexes. This is the default mode. +** `fixOutdated` will check for outdated indexed document and reindex +only those. + +This optional parameter must be passed as query parameter. + +It’s good to note as well that there is a limitation with the +`fixOutdated` mode. As we first collect metadata of stored messages to +compare them with the ones in the index, a failed `expunged` operation +might not be well corrected (as the message might not exist anymore but +still be indexed). + +Example: + +.... +curl -XPOST /messages?task=SolveInconsistencies&messagesPerSecond=200&mode=rebuildAll +.... + +Response codes: + +* 201: Success. Corresponding task id is returned. +* 400: Error in the request. Details can be found in the reported error. + +The scheduled task will have the following type +`solve-message-inconsistencies` and the following +`additionalInformation`: + +.... +{ + "type":"solve-message-inconsistencies", + "timestamp":"2007-12-03T10:15:30Z", + "processedImapUidEntries": 2, + "processedMessageIdEntries": 1, + "addedMessageIdEntries": 1, + "updatedMessageIdEntries": 0, + "removedMessageIdEntries": 1, + "runningOptions":{ + "messagesPerSecond": 200, + "mode":"REBUILD_ALL" + }, + "fixedInconsistencies": [ + { + "mailboxId": "551f0580-82fb-11ea-970e-f9c83d4cf8c2", + "messageId": "d2bee791-7e63-11ea-883c-95b84008f979", + "uid": 1 + }, + { + "mailboxId": "551f0580-82fb-11ea-970e-f9c83d4cf8c2", + "messageId": "d2bee792-7e63-11ea-883c-95b84008f979", + "uid": 2 + } + ], + "errors": [ + { + "mailboxId": "551f0580-82fb-11ea-970e-f9c83d4cf8c2", + "messageId": "ffffffff-7e63-11ea-883c-95b84008f979", + "uid": 3 + } + ] +} +.... + +User actions concurrent to the inconsistency fixing task could result in +concurrency issues. New inconsistencies could be created. + +However the source of truth will not be impacted, hence rerunning the +task will eventually fix all issues. + +This task could be run safely online and can be scheduled on a recurring +basis outside of peak traffic by an admin to ensure Cassandra message +consistency. \ No newline at end of file diff --git a/docs/modules/servers/partials/operate/cli.adoc b/docs/modules/servers/partials/operate/cli.adoc new file mode 100644 index 00000000000..32f4731cda9 --- /dev/null +++ b/docs/modules/servers/partials/operate/cli.adoc @@ -0,0 +1,332 @@ +The {server-name} is packed with a command line client. + +To run this command line client simply execute: + +.... +java -jar /root/james-cli.jar -h 127.0.0.1 -p 9999 COMMAND +.... + +The following document will explain you which are the available options +for *COMMAND*. + +Note: the above command line before *COMMAND* will be documented as _\{cli}_. + +== Manage Domains + +Domains represent the domain names handled by your server. + +You can add a domain: + +.... +{cli} AddDomain domain.tld +.... + +You can remove a domain: + +.... +{cli} RemoveDomain domain.tld +.... + +(Note: associated users are not removed automatically) + +Check if a domain is handled: + +.... +{cli} ContainsDomain domain.tld +.... + +And list your domains: + +.... +{cli} ListDomains +.... + +== Managing users + +Note: the following commands are explained with virtual hosting turned +on. + +Users are accounts on the mail server. James can maintain mailboxes for +them. + +You can add a user: + +.... +{cli} AddUser user@domain.tld password +.... + +Note: the domain used should have been previously created. + +You can delete a user: + +.... +{cli} RemoveUser user@domain.tld +.... + +(Note: associated mailboxes are not removed automatically) + +And change a user password: + +.... +{cli} SetPassword user@domain.tld password +.... + +Note: All these write operations can not be performed on LDAP backend, +as the implementation is read-only. + +Finally, you can list users: + +.... +{cli} ListUsers +.... + +=== Virtual hosting + +James supports virtualhosting. + +* If set to true in the configuration, then the username is the full +mail address. + +The domains then become a part of the user. + +_usera@domaina.com and_ _usera@domainb.com_ on a mail server with +_domaina.com_ and _domainb.com_ configured are mail addresses that +belongs to different users. + +* If set to false in the configurations, then the username is the mail +address local part. + +It means that a user is automatically created for all the domains +configured on your server. + +_usera@domaina.com and_ _usera@domainb.com_ on a mail server with +_domaina.com_ and _domainb.com_ configured are mail addresses that +belongs to the same users. + +Here are some sample commands for managing users when virtual hosting is +turned off: + +.... +{cli} AddUser user password +{cli} RemoveUser user +{cli} SetPassword user password +.... + +== Managing mailboxes + +An administrator can perform some basic operation on user mailboxes. + +Note on mailbox formatting: mailboxes are composed of three parts. + +* The namespace, indicating what kind of mailbox it is. (Shared or +not?). The value for users mailboxes is #private . Note that for now no +other values are supported as James do not support shared mailboxes. +* The username as stated above, depending on the virtual hosting value. +* And finally mailbox name. Be aware that `.' serves as mailbox +hierarchy delimiter. + +An administrator can delete all of the mailboxes of a user, which is not +done automatically when removing a user (to avoid data loss): + +.... +{cli} DeleteUserMailboxes user@domain.tld +.... + +He can delete a specific mailbox: + +.... +{cli} DeleteMailbox #private user@domain.tld INBOX.toBeDeleted +.... + +He can list the mailboxes of a specific user: + +.... +{cli} ListUserMailboxes user@domain.tld +.... + +And finally can create a specific mailbox: + +.... +{cli} CreateMailbox #private user@domain.tld INBOX.newFolder +.... + +== Adding a message in a mailbox + +The administrator can use the CLI to add a message in a mailbox. this +can be done using: + +.... +{cli} ImportEml #private user@domain.tld INBOX.newFolder /full/path/to/file.eml +.... + +This command will add a message having the content specified in file.eml +(that needs to be at the EML format). It will get added in the +INBOX.subFolder mailbox belonging to user user@domain.tld. + +== Managing mappings + +A mapping is a recipient rewriting rule. There is several kind of +rewriting rules: + +* address mapping: rewrite a given mail address into an other one. +* regex mapping. + +You can manage address mapping like (redirects email from +fromUser@fromDomain.tld to redirected@domain.new, then deletes the +mapping): + +.... +{cli} AddAddressMapping fromUser fromDomain.tld redirected@domain.new +{cli} RemoveAddressMapping fromUser fromDomain.tld redirected@domain.new +.... + +You can manage regex mapping like this: + +.... +{cli} AddRegexMapping redirected domain.new .*@domain.tld +{cli} RemoveRegexMapping redirected domain.new .*@domain.tld +.... + +You can view mapping for a mail address: + +.... +{cli} ListUserDomainMappings user domain.tld +.... + +And all mappings defined on the server: + +.... +{cli} ListMappings +.... + +== Manage quotas + +Quotas are limitations on a group of mailboxes. They can limit the +*size* or the *messages count* in a group of mailboxes. + +James groups by defaults mailboxes by user (but it can be overridden), +and labels each group with a quotaroot. + +To get the quotaroot a given mailbox belongs to: + +.... +{cli} GetQuotaroot #private user@domain.tld INBOX +.... + +Then you can get the specific quotaroot limitations. + +For the number of messages: + +.... +{cli} GetMessageCountQuota quotaroot +.... + +And for the storage space available: + +.... +{cli} GetStorageQuota quotaroot +.... + +You see the maximum allowed for these values: + +For the number of messages: + +.... +{cli} GetMaxMessageCountQuota quotaroot +.... + +And for the storage space available: + +.... +{cli} GetMaxStorageQuota quotaroot +.... + +You can also specify maximum for these values. + +For the number of messages: + +.... +{cli} SetMaxMessageCountQuota quotaroot value +.... + +And for the storage space available: + +.... +{cli} SetMaxStorageQuota quotaroot value +.... + +With value being an integer. Please note the use of units for storage +(K, M, G). For instance: + +.... +{cli} SetMaxStorageQuota someone@apache.org 4G +.... + +Moreover, James allows to specify global maximum values, at the server +level. Note: syntax is similar to what was exposed previously. + +.... +{cli} SetGlobalMaxMessageCountQuota value +{cli} GetGlobalMaxMessageCountQuota +{cli} SetGlobalMaxStorageQuota value +{cli} GetGlobalMaxStorageQuota +.... + +== Re-indexing + +James allow you to index your emails in a search engine, for making +search faster. + +For some reasons, you might want to re-index your mails (inconsistencies +across datastore, migrations). + +To re-index all mails of all mailboxes of all users, type: + +.... +{cli} ReindexAll +.... + +And for a specific mailbox: + +.... +{cli} Reindex #private user@domain.tld INBOX +.... + +== Sieve scripts quota + +James implements Sieve (RFC-5228). Your users can then write scripts +and upload them to the server. Thus they can define the desired behavior +upon email reception. James defines a Sieve mailet for this, and stores +Sieve scripts. You can update them via the ManageSieve protocol, or via +the ManageSieveMailet. + +You can define quota for the total size of Sieve scripts, per user. + +Syntax is similar to what was exposed for quotas. For defaults values: + +.... +{cli} GetSieveQuota +{cli} SetSieveQuota value +{cli} RemoveSieveQuota +.... + +And for specific user quotas: + +.... +{cli} GetSieveUserQuota user@domain.tld +{cli} SetSieveQuota user@domain.tld value +{cli} RemoveSieveUserQuota user@domain.tld +.... + +== Switching of mailbox implementation + +Migration is experimental for now. You would need to customize *Spring* +configuration to add a new mailbox manager with a different bean name. + +You can then copy data across mailbox managers using: + +.... +{cli} CopyMailbox srcBean dstBean +.... + +You will then need to reconfigure James to use the new mailbox manager. \ No newline at end of file diff --git a/docs/modules/servers/partials/operate/guide.adoc b/docs/modules/servers/partials/operate/guide.adoc new file mode 100644 index 00000000000..cdf2f4a6d4b --- /dev/null +++ b/docs/modules/servers/partials/operate/guide.adoc @@ -0,0 +1,270 @@ +This guide aims to be an entry-point to the James documentation for user +managing a {server-name}. + +It includes: + +* Simple architecture explanations +* Propose some diagnostics for some common issues +* Present procedures that can be set up to address these issues + +In order to not duplicate information, existing documentation will be +linked. + +Please note that this product is under active development, should be +considered experimental and thus targets advanced users. + +== Basic Monitoring + +A toolbox is available to help an administrator diagnose issues: + +* xref:{xref-base}/operate/logging.adoc[Structured logging into Kibana] +* xref:{xref-base}/operate/metrics.adoc[Metrics graphs into Grafana] +* xref:{xref-base}/operate/webadmin.adoc#_healthcheck[WebAdmin HealthChecks] + +== Mail processing + +Currently, an administrator can monitor mail processing failure through `ERROR` log +review. We also recommend watching in Kibana INFO logs using the +`org.apache.james.transport.mailets.ToProcessor` value as their `logger`. Metrics about +mail repository size, and the corresponding Grafana boards are yet to be contributed. + +Furthermore, given the default mailet container configuration, we recommend monitoring +`{mailet-repository-path-prefix}://var/mail/error/` to be empty. + +WebAdmin exposes all utilities for +xref:{xref-base}/operate/webadmin.adoc#_reprocessing_mails_from_a_mail_repository[reprocessing +all mails in a mail repository] or +xref:{xref-base}/operate/webadmin.adoc#_reprocessing_a_specific_mail_from_a_mail_repository[reprocessing +a single mail in a mail repository]. + +In order to prevent unbounded processing that could consume unbounded resources. We can provide a CRON with `limit` parameter. +Ex: 10 reprocessed per minute +Note that it only support the reprocessing all mails. + +Also, one can decide to +xref:{xref-base}/operate/webadmin.adoc#_removing_all_mails_from_a_mail_repository[delete +all the mails of a mail repository] or +xref:{xref-base}/operate/webadmin.adoc#_removing_a_mail_from_a_mail_repository[delete +a single mail of a mail repository]. + +Performance of mail processing can be monitored via the +https://github.com/apache/james-project/blob/d2cf7c8e229d9ed30125871b3de5af3cb1553649/server/grafana-reporting/es-datasource/MAILET-1490071694187-dashboard.json[mailet +grafana board] and +https://github.com/apache/james-project/blob/d2cf7c8e229d9ed30125871b3de5af3cb1553649/server/grafana-reporting/es-datasource/MATCHER-1490071813409-dashboard.json[matcher +grafana board]. + +=== Recipient rewriting + +Given the default configuration, errors (like loops) uopn recipient rewritting will lead +to emails being stored in `{mailet-repository-path-prefix}://var/mail/rrt-error/`. + +We recommend monitoring the content of this mail repository to be empty. + +If it is not empty, we recommend +verifying user mappings via xref:{xref-base}/operate/webadmin.adoc#_listing_user_mappings_[User Mappings webadmin API] then once identified break the loop by removing +some Recipient Rewrite Table entry via the +xref:{xref-base}/operate/webadmin.adoc#_removing_an_alias_of_an_user[Delete Alias], +xref:{xref-base}/operate/webadmin.adoc#_removing_a_group_member[Delete Group member], +xref:{xref-base}/operate/webadmin.adoc#_removing_a_destination_of_a_forward[Delete forward], +xref:{xref-base}/operate/webadmin.adoc#_remove_an_address_mapping[Delete Address mapping], +xref:{xref-base}/operate/webadmin.adoc#_removing_a_domain_mapping[Delete Domain mapping] +or xref:{xref-base}/operate/webadmin.adoc#_removing_a_regex_mapping[Delete Regex mapping] +APIs (as needed). + +The `Mail.error` field can help diagnose the issue as well. Then once +the root cause has been addressed, the mail can be reprocessed. + +== Mailbox Event Bus + +It is possible for the administrator of James to define the mailbox +listeners he wants to use, by adding them in the +{sample-configuration-prefix-url}/listeners.xml[listeners.xml] +configuration file. It’s possible also to add your own custom mailbox +listeners. This enables to enhance capabilities of James as a Mail +Delivery Agent. You can get more information about those + xref:{xref-base}/configure/listeners.adoc[here]. + +Currently, an administrator can monitor listeners failures through +`ERROR` log review. Metrics regarding mailbox listeners can be monitored +via +https://github.com/apache/james-project/blob/d2cf7c8e229d9ed30125871b3de5af3cb1553649/server/grafana-reporting/es-datasource/MailboxListeners-1528958667486-dashboard.json[mailbox_listeners +grafana board] and +https://github.com/apache/james-project/blob/d2cf7c8e229d9ed30125871b3de5af3cb1553649/server/grafana-reporting/es-datasource/MailboxListeners%20rate-1552903378376.json[mailbox_listeners_rate +grafana board]. + +Upon exceptions, a bounded number of retries are performed (with +exponential backoff delays). If after those retries the listener is +still failing to perform its operation, then the event will be stored in +the xref:{xref-base}/operate/webadmin.adoc#_event_dead_letter[Event Dead Letter]. This +API allows diagnosing issues, as well as redelivering the events. + +To check that you have undelivered events in your system, you can first +run the associated with +xref:{xref-base}/operate/webadmin.adoc#_healthcheck[event dead letter health check] . +You can explore Event DeadLetter content through WebAdmin. For +this, xref:{xref-base}/operate/webadmin.adoc#_listing_mailbox_listener_groups[list mailbox listener groups] +you will get a list of groups back, allowing +you to check if those contain registered events in each by +xref:{xref-base}/operate/webadmin.adoc#_listing_failed_events[listing their failed events]. + +If you get failed events IDs back, you can as well +xref:{xref-base}/operate/webadmin.adoc#_getting_event_details[check their details]. + +An easy way to solve this is just to trigger then the +xref:{xref-base}/operate/webadmin.adoc#_redeliver_all_events[redeliver all events] +task. It will start reprocessing all the failed events registered in +event dead letters. + +In order to prevent unbounded processing that could consume unbounded resources. We can provide a CRON with `limit` parameter. +Ex: 10 redelivery per minute + +If for some other reason you don’t need to redeliver all events, you +have more fine-grained operations allowing you to +xref:{xref-base}/operate/webadmin.adoc#_redeliver_group_events[redeliver group events] +or even just +xref:{xref-base}/operate/webadmin.adoc#_redeliver_a_single_event[redeliver a single event]. + +== OpenSearch Indexing + +A projection of messages is maintained in OpenSearch via a listener +plugged into the mailbox event bus in order to enable search features. + +You can find more information about OpenSearch configuration +xref:{xref-base}/configure/opensearch.adoc[here]. + +=== Usual troubleshooting procedures + +As explained in the link:#_mailbox_event_bus[Mailbox Event Bus] section, +processing those events can fail sometimes. + +Currently, an administrator can monitor indexation failures through +`ERROR` log review. You can as well +xref:{xref-base}/operate/webadmin.adoc#_listing_failed_events[list failed events] by +looking with the group called +`org.apache.james.mailbox.opensearch.events.OpenSearchListeningMessageSearchIndex$OpenSearchListeningMessageSearchIndexGroup`. +A first on-the-fly solution could be to just +link:#_mailbox_event_bus[redeliver those group events with event dead letter]. + +If the event storage in dead-letters fails (for instance in the face of +{backend-name} storage exceptions), then you might need to use our WebAdmin +reIndexing tasks. + +From there, you have multiple choices. You can +xref:{xref-base}/operate/webadmin.adoc#_reindexing_all_mails[reIndex all mails], +xref:{xref-base}/operate/webadmin.adoc#_reindexing_a_mailbox_mails[reIndex mails from a mailbox] or even just +xref:{xref-base}/operate/webadmin.adoc#_reindexing_a_single_mail_by_messageid[reIndex a single mail]. + +When checking the result of a reIndexing task, you might have failed +reprocessed mails. You can still use the task ID to +xref:{xref-base}/operate/webadmin.adoc#_fixing_previously_failed_reindexing[reprocess previously failed reIndexing mails]. + +=== On the fly OpenSearch Index setting update + +Sometimes you might need to update index settings. Cases when an +administrator might want to update index settings include: + +* Scaling out: increasing the shard count might be needed. +* Changing string analysers, for instance to target another language +* etc. + +In order to achieve such a procedure, you need to: + +* https://www.elastic.co/guide/en/elasticsearch/reference/7.10/indices-create-index.html[Create +the new index] with the right settings and mapping +* James uses two aliases on the mailbox index: one for reading +(`mailboxReadAlias`) and one for writing (`mailboxWriteAlias`). First +https://www.elastic.co/guide/en/elasticsearch/reference/7.10/indices-aliases.html[add +an alias] `mailboxWriteAlias` to that new index, so that now James +writes on the old and new indexes, while only keeping reading on the +first one +* Now trigger a +https://www.elastic.co/guide/en/elasticsearch/reference/7.10/docs-reindex.html[reindex] +from the old index to the new one (this actively relies on `_source` +field being present) +* When this is done, add the `mailboxReadAlias` alias to the new index +* Now that the migration to the new index is done, you can +https://www.elastic.co/guide/en/elasticsearch/reference/7.10/indices-delete-index.html[drop +the old index] +* You might want as well modify the James configuration file +{sample-configuration-prefix-url}/opensearch.properties[opensearch.properties] +by setting the parameter `opensearch.index.mailbox.name` to the name +of your new index. This is to avoid that James re-creates index upon +restart + +_Note_: keep in mind that reindexing can be a very long operation +depending on the volume of mails you have stored. + +== Mail Queue + +=== Fine tune configuration for RabbitMQ + +In order to adapt mail queue settings to the actual traffic load, an +administrator needs to perform fine configuration tunning as explained +in +https://github.com/apache/james-project/blob/master/src/site/xdoc/server/config-rabbitmq.xml[rabbitmq.properties]. + +Be aware that `MailQueue::getSize` is currently performing a browse and +thus is expensive. Size recurring metric reporting thus introduces +performance issues. As such, we advise setting +`mailqueue.size.metricsEnabled=false`. + +=== Managing email queues + +Managing an email queue is an easy task if you follow this procedure: + +* First, xref:{xref-base}/operate/webadmin.adoc#_listing_mail_queues[List mail queues] +and xref:{xref-base}/operate/webadmin.adoc#_getting_a_mail_queue_details[get a mail queue details]. +* And then +xref:{xref-base}/operate/webadmin.adoc#_listing_the_mails_of_a_mail_queue[List the mails of a mail queue]. + +In case, you need to clear an email queue because there are only spam or +trash emails in the email queue you have this procedure to follow: + +* All mails from the given mail queue will be deleted with +xref:{xref-base}/operate/webadmin.adoc#_clearing_a_mail_queue[Clearing a mail queue]. + +== Deleted Message Vault + +We recommend the administrator to +xref:#_cleaning_expired_deleted_messages[run it] in cron job to save +storage volume. + +=== How to configure deleted messages vault + +To setup James with Deleted Messages Vault, you need to follow those +steps: + +* Enable Deleted Messages Vault by configuring Pre Deletion Hooks. +* Configuring the retention time for the Deleted Messages Vault. + +==== Enable Deleted Messages Vault by configuring Pre Deletion Hooks + +You need to configure this hook in +{sample-configuration-prefix-url}/listeners.xml[listeners.xml] +configuration file. More details about configuration & example can be +found at http://james.apache.org/server/config-listeners.html[Pre +Deletion Hook Configuration] + +==== Configuring the retention time for the Deleted Messages Vault + +In order to configure the retention time for the Deleted Messages Vault, +an administrator needs to perform fine configuration tunning as +explained in +{sample-configuration-prefix-url}/deletedMessageVault.properties[deletedMessageVault.properties]. +Mails are not retained forever as you have to configure a retention +period (by `retentionPeriod`) before using it (with one-year retention +by default if not defined). + +=== Restore deleted messages after deletion + +After users deleted their mails and emptied the trash, the admin can use +xref:{xref-base}/operate/webadmin.adoc#_restore_deleted_messages[Restore Deleted Messages] +to restore all the deleted mails. + +=== Cleaning expired deleted messages + +You can delete all deleted messages older than the configured +`retentionPeriod` by using +xref:{xref-base}/operate/webadmin.adoc#_deleted_messages_vault[Purge Deleted Messages]. +We recommend calling this API in CRON job on 1st day each +month. diff --git a/docs/modules/servers/partials/operate/index.adoc b/docs/modules/servers/partials/operate/index.adoc new file mode 100644 index 00000000000..32127a3910a --- /dev/null +++ b/docs/modules/servers/partials/operate/index.adoc @@ -0,0 +1,22 @@ +The following pages detail how to operate the {server-name}. + +Once you have a {server-name} up and running you then need to ensure it operates correctly and has a decent performance. +You may also need to perform some operation maintenance or recover from incidents. This section covers +these topics. + +Read more about xref:{xref-base}/operate/logging.adoc[Logging]. + +The xref:{xref-base}/operate/webadmin.adoc[WebAdmin Restfull administration API] is the +recommended way to operate the {server-name}. It allows managing and interacting with most +server components. + +The xref:{xref-base}/operate/cli.adoc[Command line interface] allows to interact with some +server components. However it relies on JMX technologies and its use is discouraged. + +The xref:{xref-base}/operate/metrics.adoc[metrics] allows to build latency and throughput +graphs, that can be visualized, for instance in *Grafana*. + +We did put together a xref:{xref-base}/operate/guide.adoc[detailed guide] for +{server-tag} James operators. We also propose a xref:{xref-base}/operate/performanceChecklist.adoc[performance checklist]. + +We also included a guide for xref:{xref-base}/operate/migrating.adoc[migrating existing data] into the {server-tag} server. \ No newline at end of file diff --git a/docs/modules/servers/partials/operate/logging.adoc b/docs/modules/servers/partials/operate/logging.adoc new file mode 100644 index 00000000000..4ee06a26a31 --- /dev/null +++ b/docs/modules/servers/partials/operate/logging.adoc @@ -0,0 +1,257 @@ +We recommend to closely monitoring *ERROR* and *WARNING* logs. Those +logs should be considered not normal. + +If you encounter some suspicious logs: + +* If you have any doubt about the log being caused by a bug in James +source code, please reach us via the bug tracker, the user mailing list or our Gitter channel (see our +http://james.apache.org/#second[community page]) +* They can be due to insufficient performance from tier applications (eg +{backend-name} timeouts). In such case we advise you to conduct a close +review of performances at the tier level. + +Leveraging filters in Kibana discover view can help to filter out +''already known'' frequently occurring logs. + +When reporting ERROR or WARNING logs, consider adding the full logs, and +related data (eg the raw content of a mail triggering an issue) to the +bug report in order to ease resolution. + +== Logging configuration + +{server-name} uses link:http://logback.qos.ch/[logback] as a logging library +and link:https://docs.fluentbit.io/[FluentBit] as centralize logging. + +Information about logback configuration can be found +link:http://logback.qos.ch/manual/configuration.html[here]. + +== Structured logging + +=== Pushing logs to ElasticSearch + +{server-name} leverages the use of MDC in order to achieve structured logging, +and better add context to the logged information. We furthermore ship +link:https://github.com/linagora/logback-elasticsearch-appender[Logback Elasticsearch Appender] +on the classpath to easily allow direct log indexation in +link:https://www.elastic.co/elasticsearch[ElasticSearch]. + +Here is a sample `conf/logback.xml` configuration file for logback with the following +pre-requisites: + +* Logging both in an unstructured fashion on the console and in a structured fashion in ElasticSearch +* Logging ElasticSearch Log appender logs in the console + +Configuration for pushing log direct to ElasticSearch + +* Logging ElasticSearch Log appender logs in the console + +.... + + + + + true + + + + + %d{yyyy.MM.dd HH:mm:ss.SSS} %highlight([%-5level]) %logger{15} - %msg%n%rEx + false + + + + + http://elasticsearch:9200/_bulk + logs-james-%date{yyyy.MM.dd} + tester + true + host + es-error-logger + + + host + ${HOSTNAME} + false + + + severity + %level + + + thread + %thread + + + stacktrace + %ex + + + logger + %logger + + + +
    + Content-Type + application/json +
    +
    +
    + + + + + + + + + + + +
    +.... + +=== Using FluentBit as a log forwarder + +==== Using Docker + +{server-name} leverages the use of MDC in order to achieve structured logging, and better add context to the logged information. We furthermore ship json logs to file with RollingFileAppender on the classpath to easily allow FluentBit to directly tail the log file. +Here is a sample conf/logback.xml configuration file for logback with the following pre-requisites: + +Logging in a structured json fashion and write to file for centralizing logging. +Centralize logging third party like FluentBit can tail from logging’s file then filter/process and put in to ElastichSearch + +.... + + + + + true + + + + + logs/james.%d{yyyy-MM-dd}.%i.log + 1 + 200MB + 100MB + + + + + yyyy-MM-dd'T'HH:mm:ss.SSSX + Etc/UTC + + + true + + + false + + + + + + + + + + +.... + +First you need to create a `logs` folder, then mount it to James container and to FluentBit. + +docker-compose: + +include::{docker-compose-code-block-sample}[] + +FluentBit config as: +the `Host elasticsearch` pointing to `elasticsearch` service in docker-compose file. +.... +[SERVICE] + Parsers_File /fluent-bit/etc/parsers.conf + +[INPUT] + name tail + path /fluent-bit/log/*.log + Parser docker + docker_mode on + buffer_chunk_size 1MB + buffer_max_size 1MB + mem_buf_limit 64MB + Refresh_Interval 30 + +[OUTPUT] + Name stdout + Match * + + +[OUTPUT] + Name es + Match * + Host elasticsearch + Port 9200 + Index fluentbit + Logstash_Format On + Logstash_Prefix fluentbit-james + Type docker +.... + +FluentBit Parser config: +.... +[PARSER] + Name docker + Format json + Time_Key timestamp + Time_Format %Y-%m-%dT%H:%M:%S.%LZ + Time_Keep On + Decode_Field_As escaped_utf8 log do_next + Decode_Field_As escaped log do_next + Decode_Field_As json log +.... + +==== Using Kubernetes + +If using James in a Kubernetes environment, you can just append the logs to the console in a JSON formatted way +using Jackson to easily allow FluentBit to directly tail them. + +Here is a sample conf/logback.xml configuration file for achieving this: + +.... + + + + + true + + + + + + yyyy-MM-dd'T'HH:mm:ss.SSSX + Etc/UTC + + + true + + + false + + + + + + + + + + +.... + +Regarding FluentBit on Kubernetes, you need to install it as a DaemonSet. Some official template exist +with FluentBit outputting logs to ElasticSearch. For more information on how to install it, +with your cluster, you can look at this https://docs.fluentbit.io/manual/installation/kubernetes[documentation]. + +As stated by the https://docs.fluentbit.io/manual/installation/kubernetes#details[detail] of the +official documentation, FluentBit is configured to consume out of the box logs from containers +on the same running node. So it should scrap your James logs without extra configuration. diff --git a/docs/modules/servers/partials/operate/metrics.adoc b/docs/modules/servers/partials/operate/metrics.adoc new file mode 100644 index 00000000000..4c8e105aa2d --- /dev/null +++ b/docs/modules/servers/partials/operate/metrics.adoc @@ -0,0 +1,179 @@ +James relies on the https://metrics.dropwizard.io/4.1.2/manual/core.html[Dropwizard metric library] +for keeping track of some core metrics of James. + +Such metrics are made available via JMX. You can connect for instance using VisualVM and the associated +mbean plugins. + +We also support displaying them via https://grafana.com/[Grafana]. Two methods can be used to back grafana display: + + - Prometheus metric collection - Data are exposed on a HTTP endpoint for Prometheus scrape. + - ElasticSearch metric collection - This method is depreciated and will be removed in next version. + +== Expose metrics for Prometheus collection + +To enable James metrics, add ``extensions.routes`` to xref:{xref-base}/operate/webadmin.adoc[webadmin.properties] file: + +``` +extensions.routes=org.apache.james.webadmin.dropwizard.MetricsRoutes +``` +Connect to james-admin url to test the result: +.... +http://james-admin-url/metrics +.... + +== Configure Prometheus Data source +You need to set up https://prometheus.io/docs/prometheus/latest/getting_started/[Prometheus] first to scrape James metrics. + +Add Apache James WebAdmin Url or IP address to ``prometheus.yaml`` configuration file: +.... +scrape_configs: + # The job name is added as a label `job=` to any timeseries scraped from this config. + - job_name: 'WebAdmin url Example' + scrape_interval: 5s + metrics_path: /metrics + static_configs: + - targets: ['james-webamin-url'] + - job_name: 'WebAdmin IP Example' + scrape_interval: 5s + metrics_path: /metrics + static_configs: + - targets: ['192.168.100.10:8000'] +.... + +== Connect Prometheus to Grafana + +You can do this either from https://prometheus.io/docs/visualization/grafana/[Grafana UI] or from a https://grafana.com/docs/grafana/latest/datasources/prometheus/[configuration file]. + +The following `docker-compose.yaml` will help you install a simple Prometheus/ Grafana stack : + +``` +version: '3' +#Metric monitoring + grafana: + image: grafana/grafana:latest + container_name: grafana + ports: + - "3000:3000" + + prometheus: + image: prom/prometheus:latest + restart: unless-stopped + ports: + - "9090:9090" + volumes: + - ./conf/prometheus.yml:/etc/prometheus/prometheus.yml +``` + +== Getting dashboards +Now that the Promtheus/Grafana servers are up, go to this https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/[link] to get all dashboards JSON file. Import the different JSON files in this directory to Grafana via UI. + + +image::preload-dashboards.png[Pre-loaded dashboards] + +*Note: For communication between multiple docker-compose projects, see https://stackoverflow.com/questions/38088279/communication-between-multiple-docker-compose-projects[here] for example. An easier approach is to merge James and Metric docker-compose files together. + +== Available metrics + +Here are the available metrics : + + - James JVM metrics + - Number of active SMTP connections + - Number of SMTP commands received + - Number of active IMAP connections + - Number of IMAP commands received + - Number of active LMTP connections + - Number of LMTP commands received + - Number of per queue number of enqueued mails + - Number of sent emails + - Number of delivered emails + - Diverse Response time percentiles, counts and rates for JMAP + - Diverse Response time percentiles, counts and rates for IMAP + - Diverse Response time percentiles, counts and rates for SMTP + - Diverse Response time percentiles, counts and rates for WebAdmin + - Diverse Response time percentiles, counts and rates for each Mail Queue + - Per mailet and per matcher Response time percentiles + - Diverse Response time percentiles, counts and rates for DNS + - Tika HTTP client statistics + - SpamAssassin TCP client statistics + - Mailbox listeners statistics time percentiles + - Mailbox listeners statistics requests rate + - Pre-deletion hooks execution statistics time percentiles + - {other-metrics} + +== Available Grafana boards + +Here are the various relevant Grafana boards for the {server-name}: + +- https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/James_BlobStore.json[BlobStore] : +Rates and percentiles for the BlobStore component +- https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/James_DNS_Dashboard.json[DNS] : +Latencies and query counts for DNS resolution. +- https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/James_IMAP_Board.json[IMAP] : +Latencies for the IMAP protocol +- https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/James_IMAP_CountBoard.json[IMAP counts] : +Request counts for the IMAP protocol +- https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/James_JMAP_Board.json[JMAP] : +Latencies for the JMAP protocol +- https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/James_JMAP_CountBoard.json[JMAP counts] : +Request counts for the JMAP protocol +- https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/James_JVM.json[JVM] : +JVM statistics (heap, gcs, etc...) +- https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/James_MAILET.json[Mailets] : +Per-mailet execution timings. +- https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/James_MATCHER.json[Matchers] : +Per-matcher execution timings +- https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/James_MailQueue.json[MailQueue] : +MailQueue statistics +- https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/James_SMTP_Board.json[SMTP] : +SMTP latencies reports +- https://github.com/apache/james-project/tree/master/server/grafana-reporting/prometheus-datasource/James_SMTP_CountBoard.json[SMTP count] : +Request count for the SMTP protocol + +=== Dashboard samples +Latencies for the JMAP protocol + + +image::JMAP_board.png[JMAP] + +Latencies for the IMAP protocol + + +image::IMAP_board.png[IMAP] + +JVM Statistics + + +image::JVM_board.png[JVM] + +BlobStore Statistics + + +image::BlobStore.png[BlobStore] + +webAdmin Statistics + + +image::webAdmin.png[webAdmin] + +== Expose metrics for Elasticsearch collection + +The following command allow you to run a fresh grafana server : + +.... +docker run -i -p 3000:3000 grafana/grafana +.... + +Once running, you need to set up an ElasticSearch data-source : - select +proxy mode - Select version 2.x of ElasticSearch - make the URL point +your ES node - Specify the index name. By default, it should be : + +.... +[james-metrics-]YYYY-MM +.... + +Import the different dashboards you want. + +You then need to enable reporting through ElasticSearch. Modify your +James ElasticSearch configuration file accordingly. To help you doing +this, you can take a look to +link:https://github.com/apache/james-project/blob/3.7.x/server/apps/distributed-app/sample-configuration/elasticsearch.properties[elasticsearch.properties]. + +If some metrics seem abnormally slow despite in depth database +performance tuning, feedback is appreciated as well on the bug tracker, +the user mailing list or our Gitter channel (see our +http://james.apache.org/#second[community page]) . Any additional +details categorizing the slowness are appreciated as well (details of +the slow requests for instance). diff --git a/docs/modules/servers/partials/operate/migrating.adoc b/docs/modules/servers/partials/operate/migrating.adoc new file mode 100644 index 00000000000..643f9f5a9dd --- /dev/null +++ b/docs/modules/servers/partials/operate/migrating.adoc @@ -0,0 +1,31 @@ +This page presents how operators can migrate your user mailbox and mails into the {server-name} in order to adopt it. + +We assume you have a xref:{xref-base}/configure/index.adoc[well configured] running {server-name} +at hand. We also assume existing mails are hosted on a tier mail server which can be accessed via IMAP and supports +impersonation. + +First, you want to create the domains handled by your server, as well as the users you will be hosting. This operation +can be performed via WebAdmin or the CLI. + + * Using webadmin : + ** Read xref:{xref-base}/operate/webadmin.adoc#_create_a_domain[this section] for creating domains + ** Read xref:{xref-base}/operate/webadmin.adoc#_create_a_user[this section] for creating users + * Using the CLI : + ** Read xref:{xref-base}/operate/cli.adoc#_manage_domains[this section] for creating domains + ** Read xref:{xref-base}/operate/cli.adoc#_managing_users[this section] for creating users + +Second, you want to allow an administrator account of your {server-name} to have write access on other user mailboxes. +This can be setted up this the *administratorId* configuration option of the xref:{xref-base}/configure/usersrepository.adoc[usersrepository.xml] configuration file. + +Then, it is time to run https://github.com/imapsync/imapsync[imapsync] script to copy the emails from the previous mail server +into the {server-name}. Here is an example migrating a single user, relying on impersonation: + +.... +imapsync --host1 previous.server.domain.tld \ + --user1 user@domain.tld --authuser1 adminOldServer@domain.tld \ + --proxyauth1 --password1 passwordOfTheOldAdmin \ + --host2 distributed.james.domain.tld \ + --user2 use1@domain.tld \ + --authuser2 adminNewServer@domain.tld --proxyauth2 \ + --password2 passwordOfTheNewAdmin +.... \ No newline at end of file diff --git a/docs/modules/servers/partials/operate/performanceChecklist.adoc b/docs/modules/servers/partials/operate/performanceChecklist.adoc new file mode 100644 index 00000000000..2216d514444 --- /dev/null +++ b/docs/modules/servers/partials/operate/performanceChecklist.adoc @@ -0,0 +1,80 @@ +This guide aims to help James operators refine their James configuration and set up to achieve better performance. + +== Database setup + +{backend-name}, OpenSearch, RabbitMQ is a large topic in itself that we do not intend to cover here. Yet, here are some +very basic recommendation that are always beneficial to keep in mind. + +We recommend: + +* Running {backend-name}, OpenSearch on commodity hardware with attached SSD. SAN disks are known to cause performance +issues for these technologies. HDD disks are to be banned for these performance related applications. +* We recommend getting an Object Storage SaaS offering that suites your needs. Most generalist S3 offers will suite +James needs. +* We do provide a guide on xref:[Database benchmarks] that can help identify and fix issues. + +== James configuration + +=== JMAP protocol + +If you are not using JMAP, disabling it will avoid you the cost of populating related projections and thus is recommended. +Within `jmap.properties`: + +.... +enabled=false +.... + +We recommend turning on EmailQueryView as it enables resolution of mailbox listing against {backend-name}, thus unlocking massive +stability / performance gains. Within `jmap.properties`: + +.... +view.email.query.enabled=true +.... + +=== IMAP / SMTP + +We recommend against resolving client connection DNS names. This behaviour can be disabled via a system property within +`jvm.properties`: + +.... +james.protocols.mdc.hostname=false +.... + +Concurrent IMAP request count is the critical setting. In `imapServer.xml`: + +.... +200 +4096 +.... + +Other recommendation includes avoiding unecessary work upon IMAP IDLE, not starting dedicated BOSS threads: + +.... +false +0 +.... + +=== Other generic recommendations + +* Remove unneeded listeners / mailets +* Reduce duplication of Matchers within mailetcontainer.xml +* Limit usage of "DEBUG" loglevel. INFO should be more than decent in most cases. +* While GC tunning is a science in itself, we had good results with G1GC and a low pause time: + +.... +-Xlog:gc*:file=/root/gc.log -XX:MaxGCPauseMillis=20 -XX:ParallelGCThreads=2 +.... + +* We recommand tunning bach sizes: `batchsizes.properties`. This allows, limiting parallel S3 reads, while loading many +messages concurrently on {backend-name}, and improves IMAP massive operations support. + +.... +fetch.metadata=200 +fetch.headers=30 +fetch.body=30 +fetch.full=30 + +copy=8192 + +move=8192 +.... \ No newline at end of file diff --git a/docs/modules/servers/partials/operate/security.adoc b/docs/modules/servers/partials/operate/security.adoc new file mode 100644 index 00000000000..7f84aeb5ded --- /dev/null +++ b/docs/modules/servers/partials/operate/security.adoc @@ -0,0 +1,246 @@ +This document aims as summarizing threats, security best practices as well as recommendations. + +== Threats + +Operating an email server exposes you to the following threats: + + - Spammers might attempt to use your servers to send their spam messages on their behalf. We speak of +*open relay*. In addition to the resources consumed being an open relay will affect the trust other mail +installations have in you, and thus will cause legitimate traffic to be rejected. + - Emails mostly consist of private data, which shall only be accessed by their legitimate user. Failure +to do so might result in *information disclosure*. + - *Email forgery*. An attacker might craft an email on the behalf of legitimate users. + - Email protocols allow user to authenticate and thus can be used as *oracles* to guess user passwords. + - *Spam*. Non legitimate traffic can be a real burden to your users. + - *Phishing*: Crafted emails that tricks the user into doing unintended actions. + - *Viruses*: An attacker sends an attachment that contains an exploit that could run if a user opens it. + - *Denial of service*: A small request may result in a very large response and require considerable work on the server... + - *Denial of service*: A malicious JMAP client may use the JMAP push subscription to attempt to flood a third party +server with requests, creating a denial-of-service attack and masking the attacker’s true identity. + - *Dictionary Harvest Attacks*: An attacker can rely on SMTP command reply code to know if a user exists or not. This + can be used to obtain the list of local users and later use those address as targets for other attacks. + +== Best practices + +The following sections ranks best practices. + +=== Best practices: Must + + - 1. Configure James in order not to be an xref:{xref-base}/configure/smtp.adoc#_about_open_relays[open relay]. This should be the +case with the default configuration. + +Be sure in xref:{xref-base}/configure/smtp.adoc[smtpserver.xml] to activate the following options: `verifyIdentity`. + +We then recommend to manually test your installation in order to ensure that: + + - Unauthenticated SMTP users cannot send mails to external email addresses (they are not relayed) + - Unauthenticated SMTP users can send mails to internal email addresses + - Unauthenticated SMTP users cannot use local addresses in their mail from, and send emails both locally and to distant targets. + + - 2. Avoid *STARTTLS* usage and favor SSL. Upgrade from a non encrypted channel into an encrypted channel is an opportunity +for additional vulnerabilities. This is easily prevented by requiring SSL connection upfront. link:https://nostarttls.secvuln.info/[Read more...] + +Please note that STARTTLS is still beneficial in the context of email relaying, which happens on SMTP port 25 unencrypted, +and enable opportunistic encryption upgrades that would not overwise be possible. We recommend keeping STARTTLS activated +for SMTP port 25. + + - 3. Use SSL for xref:{xref-base}/configure/mailets.adoc#_remotedelivery[remote delivery] whenever you are using a gateway relaying SMTP server. + + - 4. Rely on an external identity service, dedicated to user credential storage. James supports xref:{xref-base}/configure/usersrepository.adoc#_configuring_a_ldap[LDAP]. If you are +forced to store users in James be sure to choose `PBKDF2` as a hashing algorithm. Also, delays on authentication failures +are supported via the `verifyFailureDelay` property. Note that IMAP / SMTP connections are closed after 3 authentication +failures. + + - 5. Ensure that xref:{xref-base}/configure/webadmin.adoc[WebAdmin] is not exposed unencrypted to the outer world. Doing so trivially +exposes yourself. You can either disable it, activate JWT security, or restrict it to listen only on localhost. + + - 6. Set up `HTTPS` for http based protocols, namely *JMAP* and *WebAdmin*. We recommend the use of a reverse proxy like Nginx. + + - 7. Set up link:https://james.apache.org/howTo/spf.html[SPF] and link:https://james.apache.org/howTo/dkim.html[DKIM] +for your outgoing emails to be trusted. + + - 8. Prevent access to JMX. This can be achieved through a strict firewalling policy +(link:https://nickbloor.co.uk/2017/10/22/analysis-of-cve-2017-12628/[blocking port 9999 is not enough]) +or xref:{xref-base}/configure/jmx.adoc[disabling JMX]. JMX is needed to use the existing CLI application but webadmin do offer similar +features. Set the `jmx.remote.x.mlet.allow.getMBeansFromURL` to `false` to disable JMX remote code execution feature. + + - 9. If JMAP is enabled, be sure that JMAP PUSH cannot be used for server side request forgery. This can be +xref:{xref-base}/configure/jmap.adoc[configured] using the `push.prevent.server.side.request.forgery=true` property, +forbidding push to private addresses. + +=== Best practice: Should + + - 1. Avoid advertising login/authenticate capabilities in clear channels. This might prevent some clients to attempt login +on clear channels, and can be configured for both xref:{xref-base}/configure/smtp.adoc[SMTP] and xref:{xref-base}/configure/imap.adoc[IMAP] +using `auth.plainAuthEnabled=false`. + + - 2. Verify link:https://james.apache.org/howTo/spf.html[SPF] and xref:{xref-base}/configure/mailets.adoc#_dkimverify[DKIM] for your incoming emails. + + - 3. Set up reasonable xref:{xref-base}/operate/webadmin.adoc#_administrating_quotas[storage quota] for your users. + + - 4. We recommend setting up anti-spam and anti-virus solutions. James comes with some xref:{xref-base}/configure/spam.adoc[Rspamd and SpamAssassin] +integration, and some xref:{xref-base}/configure/mailets.adoc#_clamavscan[ClamAV] tooling exists. +Rspamd supports anti-phishing modules. +Filtering with third party systems upstream is also possible. + + - 5. In order to limit your attack surface, disable protocols you or your users do not use. This includes the JMAP protocol, +POP3, ManagedSieve, etc... Be conservative on what you expose. + + - 6. If operating behind a load-balancer, set up the link:https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt[PROXY protocol] for +TCP based protocols (IMAP and SMTP `proxyRequired` option) + +=== Best practice: Could + + - 1. Set up link:https://openid.net/connect/[OIDC] for IMAP, SMTP and JMAP. Disable login/plain/basic authentication. + + - 2. You can configure xref:{xref-base}/configure/ssl.adoc#_client_authentication_via_certificates[Client authentication via certificates]. + + - 3. You can xref:{xref-base}/configure/mailets.adoc#_smimesign[sign], xref:{xref-base}/configure/mailets.adoc#_smimechecksignature[verify] +and xref:{xref-base}/configure/mailets.adoc#_smimedecrypt[decrypt] your email traffic using link:https://datatracker.ietf.org/doc/html/rfc5751[SMIME]. + +== Known vulnerabilities + +Several vulnerabilities have had been reported for previous releases of Apache James server. + +Be sure not to run those! We highly recommend running the latest release, which we put great effort in not to use +outdated dependencies. + +=== Reporting vulnerabilities + +We follow the standard procedures within the ASF regarding link:https://apache.org/security/committers.html#vulnerability-handling[vulnerability handling] + +=== CVE-2024-21742: Mime4J DOM header injection + +Apache JAMES MIME4J prior to version 0.8.10 allow attackers able to specify the value of a header field to craft other header fields. + +*Severity*: Moderate + +*Mitigation*: Release 0.8.10 rejects the use of LF inside a header field thus preventing the issue. + +Upgrading to Apache James MIME4J 0.8.10 is thus advised. + +=== CVE-2023-51747: SMTP smuggling in Apache James + +Apache James distribution prior to release 3.7.5 and release 3.8.1 is subject to SMTP smuggling, when used in combination +of antother vulnerable server and can result in SPF bypass, leading to email forgery. + +*Severity*: High + +*Mitigation*: Release 3.7.5 and 3.8.1 interpret strictly the CRLF delimiter and thus prevent the issue. + +Upgrading to Apache James 3.7.5 or 3.8.1 is thus advised. + +=== CVE-2023-51518: Privilege escalation via JMX pre-authentication deserialisation + +Apache James distribution prior to release 3.7.5 and 3.8.1 allow privilege escalation via JMX pre-authentication deserialisation. +An attacker would need to identify a deserialization glitch before triggering an exploit. + +*Severity*: Moderate + +*Mitigation*:We recommend turning off JMX whenever possible. + +Release 3.7.5 and 3.8.1 disable deserialization on unauthencited channels. + +Upgrading to Apache James 3.7.5 on 3.8.1 is thus advised. + + +=== CVE-2023-26269: Privilege escalation through unauthenticated JMX + +Apache James distribution prior to release 3.7.4 allows privilege escalation through the use of JMX. + +*Severity*: Moderate + +*Mitigation*: We recommend turning on authentication on. If the CLI is unused we recommend turning JMX off. + +Release 3.7.4 set up implicitly JMX authentication for Guice based products and addresses the underlying JMX exploits. + +Upgrading to Apache James 3.7.4 is thus advised. + +=== CVE-2022-45935: Temporary File Information Disclosure in Apache JAMES + +Apache James distribution prior to release 3.7.3 is vulnerable to a temporary File Information Disclosure. + +*Severity*: Moderate + +*Mitigation*: We recommend to upgrade to Apache James 3.7.3 or higher, which fixes this vulnerability. + + +=== CVE-2021-44228: STARTTLS command injection in Apache JAMES + +Apache James distribution prior to release 3.7.1 is vulnerable to a buffering attack relying on the use of the STARTTLS command. + +Fix of CVE-2021-38542, which solved similar problem from Apache James 3.6.1, is subject to a parser differential and do not take into account concurrent requests. + +*Severity*: Moderate + +*Mitigation*: We recommend to upgrade to Apache James 3.7.1 or higher, which fixes this vulnerability. + +=== CVE-2021-38542: Apache James vulnerable to STARTTLS command injection (IMAP and POP3) + +Apache James prior to release 3.6.1 is vulnerable to a buffering attack relying on the use of the STARTTLS +command. This can result in Man-in -the-middle command injection attacks, leading potentially to leakage +of sensible information. + +*Severity*: Moderate + +This issue is being tracked as link:https://issues.apache.org/jira/browse/JAMES-1862[JAMES-1862] + +*Mitigation*: We recommend upgrading to Apache James 3.6.1, which fixes this vulnerability. + +Furthermore, we recommend, if possible to dis-activate STARTTLS and rely solely on explicit TLS for mail protocols, including SMTP, IMAP and POP3. + +Read more link:https://nostarttls.secvuln.info/[about STARTTLS security here]. + +=== CVE-2021-40110: Apache James IMAP vulnerable to a ReDoS + +Using Jazzer fuzzer, we identified that an IMAP user can craft IMAP LIST commands to orchestrate a Denial +Of Service using a vulnerable Regular expression. This affected Apache James prior to 3.6.1 + +*Severity*: Moderate + +This issue is being tracked as link:https://issues.apache.org/jira/browse/JAMES-3635[JAMES-3635] + +*Mitigation*: We recommend upgrading to Apache James 3.6.1, which enforce the use of RE2J regular +expression engine to execute regex in linear time without back-tracking. + +=== CVE-2021-40111: Apache James IMAP parsing Denial Of Service + +While fuzzing with Jazzer the IMAP parsing stack we discover that crafted APPEND and STATUS IMAP command +could be used to trigger infinite loops resulting in expensive CPU computations and OutOfMemory exceptions. +This can be used for a Denial Of Service attack. The IMAP user needs to be authenticated to exploit this +vulnerability. This affected Apache James prior to version 3.6.1. + +*Severity*: Moderate + +This issue is being tracked as link:https://issues.apache.org/jira/browse/JAMES-3634[JAMES-3634] + +*Mitigation*: We recommend upgrading to Apache James 3.6.1, which fixes this vulnerability. + +=== CVE-2021-40525: Apache James: Sieve file storage vulnerable to path traversal attacks + +Apache James ManagedSieve implementation alongside with the file storage for sieve scripts is vulnerable +to path traversal, allowing reading and writing any file. + +*Severity*: Moderate + +This issue is being tracked as link:https://issues.apache.org/jira/browse/JAMES-3646[JAMES-3646] + +*Mitigation*:This vulnerability had been patched in Apache James 3.6.1 and higher. We recommend the upgrade. + +This could also be mitigated by ensuring manageSieve is disabled, which is the case by default. + +Distributed and {backend-name} based products are also not impacted. + +=== CVE-2017-12628 Privilege escalation using JMX + +The Apache James Server prior version 3.0.1 is vulnerable to Java deserialization issues. +One can use this for privilege escalation. +This issue can be mitigated by: + + - Upgrading to James 3.0.1 onward + - Using a recent JRE (Exploit could not be reproduced on OpenJdk 8 u141) + - Exposing JMX socket only to localhost (default behaviour) + - Possibly running James in a container + - Disabling JMX all-together (Guice only) + +Read more link:http://james.apache.org//james/update/2017/10/20/james-3.0.1.html[here]. \ No newline at end of file diff --git a/docs/modules/servers/partials/operate/webadmin.adoc b/docs/modules/servers/partials/operate/webadmin.adoc new file mode 100644 index 00000000000..ddbc85df079 --- /dev/null +++ b/docs/modules/servers/partials/operate/webadmin.adoc @@ -0,0 +1,4517 @@ +The web administration supports for now the CRUD operations on the domains, the users, their mailboxes and their quotas, +managing mail repositories, performing {backend-name} migrations, and much more, as described in the following sections. + +*WARNING*: This API allow authentication only via the use of JWT. If not +configured with JWT, an administrator should ensure an attacker can not +use this API. + +By the way, some endpoints are not filtered by authentication. Those endpoints are not related to data stored in James, +for example: Swagger documentation & James health checks. + +In case of any error, the system will return an error message which is +json format like this: + +.... +{ + statusCode: , + type: , + message: + cause: +} +.... + +Also be aware that, in case things go wrong, all endpoints might return +a 500 internal error (with a JSON body formatted as exposed above). To +avoid information duplication, this is omitted on endpoint specific +documentation. + +Finally, please note that in case of a malformed URL the 400 bad request +response will contain an HTML body. + +== HealthCheck + +=== Check all components + +This endpoint is simple for now and is just returning the http status +code corresponding to the state of checks (see below). The user has to +check in the logs in order to have more information about failing +checks. + +.... +curl -XGET http://ip:port/healthcheck +.... + +Will return a list of healthChecks execution result, with an aggregated +result: + +.... +{ + "status": "healthy", + "checks": [ + { + "componentName": "{backend-name} backend", + "escapedComponentName": "{backend-name}%20backend", + "status": "healthy" + "cause": null + } + ] +} +.... + +*status* field can be: + +* *healthy*: Component works normally +* *degraded*: Component works in degraded mode. Some non-critical +services may not be working, or latencies are high, for example. Cause +contains explanations. +* *unhealthy*: The component is currently not working. Cause contains +explanations. + +Supported health checks include: + +* *{backend-name} backend*: {backend-name} storage. +* *OpenSearch Backend*: OpenSearch storage. +* *EventDeadLettersHealthCheck* +* *Guice application lifecycle* +* *JPA Backend*: JPA storage. +* *MailReceptionCheck* We rely on a configured user, send an email to him and +assert that the email is well received, and can be read within the given configured +period. Unhealthy means that the email could not be received before reacing the timeout. +* *MessageFastViewProjection* Health check of the component storing JMAP properties +which are fast to retrieve. Those properties are computed in advance +from messages and persisted in order to archive a better performance. +There are some latencies between a source update and its projections +updates. Incoherency problems arise when reads are performed in this +time-window. We piggyback the projection update on missed JMAP read in +order to decrease the outdated time window for a given entry. The health +is determined by the ratio of missed projection reads. (lower than 10% +causes `degraded`) +* *RabbitMQ backend*: RabbitMQ messaging. + +Response codes: + +* 200: All checks have answered with a Healthy or Degraded status. James +services can still be used. +* 503: At least one check have answered with a Unhealthy status + +=== Check single component + +Performs a health check for the given component. The component is +referenced by its URL encoded name. + +.... +curl -XGET http://ip:port/healthcheck/checks/{backend-name}%20backend +.... + +Will return the component’s name, the component’s escaped name, the +health status and a cause. + +.... +{ + "componentName": "{backend-name} backend", + "escapedComponentName": "{backend-name}%20backend", + "status": "healthy" + "cause": null +} +.... + +Response codes: + +* 200: The check has answered with a Healthy or Degraded status. +* 404: A component with the given name was not found. +* 503: The check has answered with an Unhealthy status. + +=== List all health checks + +This endpoint lists all the available health checks. + +.... +curl -XGET http://ip:port/healthcheck/checks +.... + +Will return the list of all available health checks. + +.... +[ + { + "componentName": "{backend-name} backend", + "escapedComponentName": "{backend-name}%20backend" + } +] +.... + +Response codes: + +* 200: List of available health checks + +== Task management + +Some webadmin features schedule tasks. The task management API allow to +monitor and manage the execution of the following tasks. + +Note that the `taskId` used in the following APIs is returned by other +WebAdmin APIs scheduling tasks. + +=== Getting a task details + +.... +curl -XGET http://ip:port/tasks/3294a976-ce63-491e-bd52-1b6f465ed7a2 +.... + +An Execution Report will be returned: + +.... +{ + "submitDate": "2017-12-27T15:15:24.805+0700", + "startedDate": "2017-12-27T15:15:24.809+0700", + "completedDate": "2017-12-27T15:15:24.815+0700", + "cancelledDate": null, + "failedDate": null, + "taskId": "3294a976-ce63-491e-bd52-1b6f465ed7a2", + "additionalInformation": {}, + "status": "completed", + "type": "type-of-the-task" +} +.... + +Note that: + +* `status` can have the value: +** `waiting`: The task is scheduled but its execution did not start yet +** `inProgress`: The task is currently executed +** `cancelled`: The task had been cancelled +** `completed`: The task execution is finished, and this execution is a +success +** `failed`: The task execution is finished, and this execution is a +failure +* `additionalInformation` is a task specific object giving additional +information and context about that task. The structure of this +`additionalInformation` field is provided along the specific task +submission endpoint. + +Response codes: + +* 200: The specific task was found and the execution report exposed +above is returned +* 400: Invalid task ID +* 404: Task ID was not found + +=== Awaiting a task + +One can await the end of a task, then receive its final execution +report. + +That feature is especially usefully for testing purpose but still can +serve real-life scenario. + +.... +curl -XGET http://ip:port/tasks/3294a976-ce63-491e-bd52-1b6f465ed7a2/await?timeout=duration +.... + +An Execution Report will be returned. + +`timeout` is optional. By default it is set to 365 days (the maximum +value). The expected value is expressed in the following format: +`Nunit`. `N` should be strictly positive. `unit` could be either in the +short form (`s`, `m`, `h`, etc.), or in the long form (`day`, `week`, +`month`, etc.). + +Examples: + +* `30s` +* `5m` +* `7d` +* `1y` + +Response codes: + +* 200: The specific task was found and the execution report exposed +above is returned +* 400: Invalid task ID or invalid timeout +* 404: Task ID was not found +* 408: The timeout has been reached + +=== Cancelling a task + +You can cancel a task by calling: + +.... +curl -XDELETE http://ip:port/tasks/3294a976-ce63-491e-bd52-1b6f465ed7a2 +.... + +Response codes: + +* 204: Task had been cancelled +* 400: Invalid task ID + +=== Listing tasks + +A list of all tasks can be retrieved: + +.... +curl -XGET http://ip:port/tasks +.... + +Will return a list of Execution reports + +One can filter the above results by status. For example: + +.... +curl -XGET http://ip:port/tasks?status=inProgress +.... + +Will return a list of Execution reports that are currently in progress. This list is sorted by +reverse submitted date (recent tasks goes first). + +Response codes: + +* 200: A list of corresponding tasks is returned +* 400: Invalid status value + +Additional optional task parameters are supported: + +- `status` one of `waiting`, `inProgress`, `canceledRequested`, `completed`, `canceled`, `failed`. Only +tasks with the given status are returned. +- `type`: only tasks with the given type are returned. +- `submittedBefore`: Date. Returns only tasks submitted before this date. +- `submittedAfter`: Date. Returns only tasks submitted after this date. +- `startedBefore`: Date. Returns only tasks started before this date. +- `startedAfter`: Date. Returns only tasks started after this date. +- `completedBefore`: Date. Returns only tasks completed before this date. +- `completedAfter`: Date. Returns only tasks completed after this date. +- `failedBefore`: Date. Returns only tasks failed before this date. +- `failedAfter`: Date. Returns only tasks faield after this date. +- `offset`: Integer, number of tasks to skip in the response. Useful for paging. +- `limit`: Integer, maximum number of tasks to return in one call + +Example of date format: `2023-04-15T07:23:27.541254+07:00` and `2023-04-15T07%3A23%3A27.541254%2B07%3A00` once URL encoded. + +=== Endpoints returning a task + +Many endpoints do generate a task. + +Example: + +.... +curl -XPOST /endpoint?action={action} +.... + +The response to these requests will be the scheduled `taskId` : + +.... +{"taskId":"5641376-02ed-47bd-bcc7-76ff6262d92a"} +.... + +Positionned headers: + +* Location header indicates the location of the resource associated with +the scheduled task. Example: + +.... +Location: /tasks/3294a976-ce63-491e-bd52-1b6f465ed7a2 +.... + +Response codes: + +* 201: Task generation succeeded. Corresponding task id is returned. +* Other response codes might be returned depending on the endpoint + +The additional information returned depends on the scheduled task type +and is documented in the endpoint documentation. + +== Administrating domains + +=== Create a domain + +.... +curl -XPUT http://ip:port/domains/domainToBeCreated +.... + +Resource name domainToBeCreated: + +* can not be null or empty +* can not contain `@' +* can not be more than 255 characters +* can not contain `/' + +Response codes: + +* 204: The domain was successfully added +* 400: The domain name is invalid + +=== Delete a domain + +.... +curl -XDELETE http://ip:port/domains/{domainToBeDeleted} +.... + +Note: Deletion of an auto-detected domain, default domain or of an +auto-detected ip is not supported. We encourage you instead to review +your https://james.apache.org/server/config-domainlist.html[domain list +configuration]. + +Response codes: + +* 204: The domain was successfully removed + +=== Test if a domain exists + +.... +curl -XGET http://ip:port/domains/{domainName} +.... + +Response codes: + +* 204: The domain exists +* 404: The domain does not exist + +=== Get the list of domains + +.... +curl -XGET http://ip:port/domains +.... + +Possible response: + +.... +["domain1", "domain2"] +.... + +Response codes: + +* 200: The domain list was successfully retrieved + +=== Get the list of aliases for a domain + +.... +curl -XGET http://ip:port/domains/destination.domain.tld/aliases +.... + +Possible response: + +.... +[ + {"source": "source1.domain.tld"}, + {"source": "source2.domain.tld"} +] +.... + +When sending an email to an email address having `source1.domain.tld` or +`source2.domain.tld` as a domain part (example: +`user@source1.domain.tld`), then the domain part will be rewritten into +destination.domain.tld (so into `user@destination.domain.tld`). + +Response codes: + +* 200: The domain aliases was successfully retrieved +* 400: destination.domain.tld has an invalid syntax +* 404: destination.domain.tld is not part of handled domains and does +not have local domains as aliases. + +=== Create an alias for a domain + +To create a domain alias execute the following query: + +.... +curl -XPUT http://ip:port/domains/destination.domain.tld/aliases/source.domain.tld +.... + +When sending an email to an email address having `source.domain.tld` as +a domain part (example: `user@source.domain.tld`), then the domain part +will be rewritten into `destination.domain.tld` (so into +`user@destination.domain.tld`). + +Response codes: + +* 204: The redirection now exists +* 400: `source.domain.tld` or `destination.domain.tld` have an invalid +syntax +* 400: `source, domain` and `destination domain` are the same +* 404: `source.domain.tld` are not part of handled domains. + +Be aware that no checks to find possible loops that would result of this creation will be performed. + +=== Delete an alias for a domain + +To delete a domain alias execute the following query: + +.... +curl -XDELETE http://ip:port/domains/destination.domain.tld/aliases/source.domain.tld +.... + +When sending an email to an email address having `source.domain.tld` as +a domain part (example: `user@source.domain.tld`), then the domain part +will be rewritten into `destination.domain.tld` (so into +`user@destination.domain.tld`). + +Response codes: + +* 204: The redirection now no longer exists +* 400: `source.domain.tld` or destination.domain.tld have an invalid +syntax +* 400: source, domain and destination domain are the same +* 404: `source.domain.tld` are not part of handled domains. + +=== Delete all users data of a domain + +.... +curl -XPOST http://ip:port/domains/{domainToBeUsed}?action=deleteData +.... + +Would create a task that deletes data of all users of the domain. + +[More details about endpoints returning a task](#_endpoints_returning_a_task). + +Response codes: + +* 201: Success. Corresponding task id is returned. +* 400: Error in the request. Details can be found in the reported error. + +The scheduled task will have the following type `DeleteUsersDataOfDomainTask` and the following `additionalInformation`: + +.... +{ + "type": "DeleteUsersDataOfDomainTask", + "domain": "domain.tld", + "successfulUsersCount": 2, + "failedUsersCount": 1, + "failedUsers": ["faileduser@domain.tld"], + "timestamp": "2023-05-22T08:52:47.076261Z" +} +.... + +Notes: `failedUsers` only lists maximum 100 failed users. + +== Administrating users + +=== Create a user + +.... +curl -XPUT http://ip:port/users/usernameToBeUsed \ + -d '{"password":"passwordToBeUsed"}' \ + -H "Content-Type: application/json" +.... + +Resource name usernameToBeUsed representing valid users, hence it should +match the criteria at xref:{xref-base}/configure/usersrepository.adoc[User Repositories documentation] + +Response codes: + +* 204: The user was successfully created +* 400: The user name or the payload is invalid +* 409: The user name already exists + +Note: If the user exists already, its password cannot be updated using this. +If you want to update a user's password, please have a look at *Update a user password* below. + +=== Updating a user password + +.... +curl -XPUT http://ip:port/users/usernameToBeUsed?force \ + -d '{"password":"passwordToBeUsed"}' \ + -H "Content-Type: application/json" +.... + +Response codes: + +- 204: The user's password was successfully updated +- 400: The user name or the payload is invalid + +This also can be used to create a new user. + +=== Verifying a user password + +.... +curl -XPOST http://ip:port/users/usernameToBeUsed/verify \ + -d '{"password":"passwordToBeVerified"}' \ + -H "Content-Type: application/json" +.... + +Response codes: + +- 204: The user's password was correct +- 401: Wrong password or user does not exist +- 400: The user name or the payload is invalid + +This intentionally treats non-existing users as unauthenticated, to prevent a username oracle attack. + +=== Testing a user existence + +.... +curl -XHEAD http://ip:port/users/usernameToBeUsed +.... + +Resource name ``usernameToBeUsed'' represents a valid user, hence it +should match the criteria at xref:{xref-base}/configure/usersrepository.adoc[User Repositories documentation] + +Response codes: + +* 200: The user exists +* 400: The user name is invalid +* 404: The user does not exist + +=== Deleting a user + +.... +curl -XDELETE http://ip:port/users/{userToBeDeleted} +.... + +Response codes: + +* 204: The user was successfully deleted + +=== Retrieving the user list + +.... +curl -XGET http://ip:port/users +.... + +The answer looks like: + +.... +[{"username":"username@domain-jmapauthentication.tld"},{"username":"username@domain.tld"}] +.... + +Response codes: + +* 200: The user name list was successfully retrieved + +=== Retrieving the list of allowed `From` headers for a given user + +This endpoint allows to know which From headers a given user is allowed to use when sending mails. + +.... +curl -XGET http://ip:port/users/givenUser/allowedFromHeaders +.... + +The answer looks like: + +.... +["user@domain.tld","alias@domain.tld"] +.... + +Response codes: + +* 200: The list was successfully retrieved +* 400: The user is invalid +* 404: The user is unknown + +=== Add a delegated user of a base user + +.... +curl -XPUT http://ip:port/users/baseUser/authorizedUsers/delegatedUser +.... + +Response codes: + +* 200: Addition of the delegated user succeeded +* 404: The base user does not exist +* 400: The delegated user does not exist + +Note: Delegation is only available on top of {backend-name} products and not implemented yet on top of JPA backends. + +=== Remove a delegated user of a base user + +.... +curl -XDELETE http://ip:port/users/baseUser/authorizedUsers/delegatedUser +.... + +Response codes: + +* 200: Removal of the delegated user succeeded +* 404: The base user does not exist +* 400: The delegated user does not exist + +Note: Delegation is only available on top of {backend-name} products and not implemented yet on top of JPA backends. + +=== Retrieving the list of delegated users of a base user + +.... +curl -XGET http://ip:port/users/baseUser/authorizedUsers +.... + +The answer looks like: + +.... +["alice@domain.tld","bob@domain.tld"] +.... + +Response codes: + +* 200: The list was successfully retrieved +* 404: The base user does not exist + +Note: Delegation is only available on top of {backend-name} products and not implemented yet on top of JPA backends. + +=== Remove all delegated users of a base user + +.... +curl -XDELETE http://ip:port/users/baseUser/authorizedUsers +.... + +Response codes: + +* 200: Removal of the delegated users succeeded +* 404: The base user does not exist + +Note: Delegation is only available on top of {backend-name} products and not implemented yet on top of JPA backends. + +=== Change a username + +.... +curl -XPOST http://ip:port/users/oldUser/rename/newUser?action=rename +.... + +Would migrate account data from `oldUser` to `newUser`. + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +Implemented migration steps are: + +- `ForwardUsernameChangeTaskStep`: creates forward from old user to new user and migrates existing forwards +- `FilterUsernameChangeTaskStep`: migrates users filtering rules +- `DelegationUsernameChangeTaskStep`: migrates delegations where the impacted user is either delegatee or delegator +- `MailboxUsernameChangeTaskStep`: migrates mailboxes belonging to the old user to the account of the new user. It also +migrates user's mailbox subscriptions. +- `ACLUsernameChangeTaskStep`: migrates ACLs on mailboxes the migrated user has access to and updates subscriptions accordingly. +- `QuotaUsernameChangeTaskStep`: migrates quotas user from old user to new user. + +Response codes: + +* 201: Success. Corresponding task id is returned. +* 400: Error in the request. Details can be found in the reported error. If you encounter the error "'oldUser' parameter should be an existing user," please note that this validation can be bypassed by specifying the `force` query parameter. + +The `fromStep` query parameter allows skipping previous steps, allowing to resume the username change from a failed step. + +The scheduled task will have the following type `UsernameChangeTask` and the following `additionalInformation`: + +.... +{ + "type": "UsernameChangeTask", + "oldUser": "jessy.jones@domain.tld", + "newUser": "jessy.smith@domain.tld", + "status": { + "A": "DONE", + "B": "FAILED", + "C": "ABORTED" + }, + "fromStep": null, + "timestamp": "2023-02-17T02:54:01.246477Z" +} +.... + +Valid status includes: + +- `SKIPPED`: bypassed via `fromStep` setting +- `WAITING`: Awaits execution +- `IN_PROGRESS`: Currently executed +- `FAILED`: Error encountered while executing this step. Check the logs. +- `ABORTED`: Won't be executed because of previous step failures. + +=== Delete data of a user + +.... +curl -XPOST http://ip:port/users/usernameToBeUsed?action=deleteData +.... + +Would create a task that deletes data of the user. + +link:#_endpoints_returning_a_task[More details about endpoints returning a task]. + +Implemented deletion steps are: + +- `RecipientRewriteTableUserDeletionTaskStep`: deletes all rewriting rules related to this user. +- `FilterUserDeletionTaskStep`: deletes all filters belonging to the user. +- `DelegationUserDeletionTaskStep`: deletes all delegations from / to the user. +- `MailboxUserDeletionTaskStep`: deletes mailboxes of this user, all ACLs of this user, as well as his subscriptions. +- `WebPushUserDeletionTaskStep`: deletes push data registered for this user. +- `IdentityUserDeletionTaskStep`: deletes identities registered for this user. +- `VacationUserDeletionTaskStep`: deletes vacations registered for this user. + +Response codes: + +* 201: Success. Corresponding task id is returned. +* 400: Error in the request. Details can be found in the reported error. + +The `fromStep` query parameter allows skipping previous steps, allowing to resume the user data deletion from a failed step. + +The scheduled task will have the following type `DeleteUserDataTask` and the following `additionalInformation`: + +.... +{ + "type": "DeleteUserDataTask", + "username": "jessy.jones@domain.tld", + "status": { + "A": "DONE", + "B": "FAILED", + "C": "ABORTED" + }, + "fromStep": null, + "timestamp": "2023-02-17T02:54:01.246477Z" +} +.... + +Valid status includes: + +- `SKIPPED`: bypassed via `fromStep` setting +- `WAITING`: Awaits execution +- `IN_PROGRESS`: Currently executed +- `FAILED`: Error encountered while executing this step. Check the logs. +- `ABORTED`: Won't be executed because of previous step failures. + +=== Retrieving the user identities + +.... +curl -XGET http://ip:port/users/{baseUser}/identities?default=true +.... + +API to get the list of identities of a user + +The response will look like: + +``` +[ + { + "name":"identity name 1", + "email":"bob@domain.tld", + "id":"4c039533-75b9-45db-becc-01fb0e747aa8", + "mayDelete":true, + "textSignature":"textSignature 1", + "htmlSignature":"htmlSignature 1", + "sortOrder":1, + "bcc":[ + { + "emailerName":"bcc name 1", + "mailAddress":"bcc1@domain.org" + } + ], + "replyTo":[ + { + "emailerName":"reply name 1", + "mailAddress":"reply1@domain.org" + } + ] + } +] +``` + +Query parameters: + +* default: (Optional) allows getting the default identity of a user. In order to do that: `default=true` + +Response codes: + +* 200: The list was successfully retrieved +* 400: The user is invalid +* 404: The user is unknown or the default identity can not be found. + +The optional `default` query parameter allows getting the default identity of a user. +In order to do that: `default=true` + +The web-admin server will return `404` response code when the default identity can not be found. + +=== Creating a JMAP user identity + +API to create a new JMAP user identity +.... +curl -XPOST http://ip:port/users/{username}/identities \ +-d '{ + "name": "Bob", + "email": "bob@domain.tld", + "mayDelete": true, + "htmlSignature": "a html signature", + "textSignature": "a text signature", + "bcc": [{ + "email": "boss2@domain.tld", + "name": "My Boss 2" + }], + "replyTo": [{ + "email": "boss@domain.tld", + "name": "My Boss" + }], + "sortOrder": 0 + }' \ +-H "Content-Type: application/json" +.... + +Response codes: + +* 201: The new identity was successfully created +* 404: The username is unknown +* 400: The payload is invalid + +Resource name ``username'' represents a valid user + +=== Updating a JMAP user identity + +API to update an exist JMAP user identity +.... +curl -XPUT http://ip:port/users/{username}/identities/{identityId} \ +-d '{ + "name": "Bob", + "htmlSignature": "a html signature", + "textSignature": "a text signature", + "bcc": [{ + "email": "boss2@domain.tld", + "name": "My Boss 2" + }], + "replyTo": [{ + "email": "boss@domain.tld", + "name": "My Boss" + }], + "sortOrder": 1 + }' \ +-H "Content-Type: application/json" +.... + +Response codes: + +* 204: The identity were successfully updated +* 404: The username is unknown +* 400: The payload is invalid + +Resource name ``username'' represents a valid user +Resource name ``identityId'' represents a exist user identity + +== Administrating vacation settings + +=== Get vacation settings + +.... +curl -XGET http://ip:port/vacation/usernameToBeUsed +.... + +Resource name usernameToBeUsed representing valid users, hence it should +match the criteria at xref:{xref-base}/configure/usersrepository.adoc[User Repositories documentation] + +The response will look like this: + +.... +{ + "enabled": true, + "fromDate": "2021-09-20T10:00:00Z", + "toDate": "2021-09-27T18:00:00Z", + "subject": "Out of office", + "textBody": "I am on vacation, will be back soon.", + "htmlBody": "

    I am on vacation, will be back soon.

    " +} +.... + +Response codes: + +* 200: The vacation settings were successfully retrieved +* 404: The user name is unknown + +=== Update vacation settings + +.... +curl -XPOST http://ip:port/vacation/usernameToBeUsed +.... + +Request body must be a JSON structure as described above. + +If any field is not set in the request, the corresponding field in the existing vacation message is left unchanged. + +Response codes: + +* 204: The vacation settings were successfully updated +* 404: The user name is unknown +* 400: The payload is invalid + +=== Delete vacation settings + +.... +curl -XDELETE http://ip:port/vacation/usernameToBeUsed +.... + +For convenience, this disables and clears the existing vacation settings of the user. + +Response codes: + +* 204: The vacation settings were successfully disabled +* 404: The user name is unknown + +== Administrating mailboxes + +=== All mailboxes + +Several actions can be performed on the server mailboxes. + +Request pattern is: + +.... +curl -XPOST /mailboxes?action={action1},... +.... + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +Response codes: + +* 201: Success. Corresponding task id is returned. +* 400: Error in the request. Details can be found in the reported error. + +The kind of task scheduled depends on the action parameter. See below +for details. + + +==== Recomputing Global JMAP fast message view projection + +Message fast view projection stores message properties expected to be +fast to fetch but are actually expensive to compute, in order for +GetMessages operation to be fast to execute for these properties. + +These projection items are asynchronously computed on mailbox events. + +You can force the full projection recomputation by calling the following +endpoint: + +.... +curl -XPOST /mailboxes?task=recomputeFastViewProjectionItems +.... + +Will schedule a task for recomputing the fast message view projection +for all mailboxes. + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +An admin can specify the concurrency that should be used when running +the task: + +* `messagesPerSecond` rate at which messages should be processed, per +second. Defaults to 10. + +This optional parameter must have a strictly positive integer as a value +and be passed as query parameters. + +Example: + +.... +curl -XPOST /mailboxes?task=recomputeFastViewProjectionItems&messagesPerSecond=20 +.... + +The scheduled task will have the following type +`RecomputeAllFastViewProjectionItemsTask` and the following +`additionalInformation`: + +.... +{ + "type":"RecomputeAllPreviewsTask", + "processedUserCount": 3, + "processedMessageCount": 3, + "failedUserCount": 2, + "failedMessageCount": 1, + "runningOptions": { + "messagesPerSecond":20 + } +} +.... + +Response codes: + +* 201: Success. Corresponding task id is returned. +* 400: Error in the request. Details can be found in the reported error. + +==== Populate email query view + +Email query view is an optional projection to offload common JMAP `Email/query` requests used for listing mails on {backend-name} +and not on the search index thus improving the overall reliability / performance on this operation. + +These projection items are asynchronously computed on mailbox events. + +You can populate this projection with the following request: + +.... +curl -XPOST /mailboxes?task=populateEmailQueryView +.... + +Will schedule a task for recomputing the fast message view projection +for all mailboxes. + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +An admin can specify the concurrency that should be used when running +the task: + +* `messagesPerSecond` rate at which messages should be processed, per +second. Defaults to 10. + +This optional parameter must have a strictly positive integer as a value +and be passed as query parameters. + +Example: + +.... +curl -XPOST /mailboxes?task=populateEmailQueryView&messagesPerSecond=20 +.... + +The scheduled task will have the following type +`PopulateEmailQueryViewTask` and the following +`additionalInformation`: + +.... +{ + "type":"PopulateEmailQueryViewTask", + "processedUserCount": 3, + "processedMessageCount": 3, + "failedUserCount": 2, + "failedMessageCount": 1, + "runningOptions": { + "messagesPerSecond":20 + } +} +.... + +Response codes: + +* 201: Success. Corresponding task id is returned. +* 400: Error in the request. Details can be found in the reported error. + +==== Recomputing {backend-name} filtering projection + +You can force the reset of the {backend-name} filtering projection by calling the following +endpoint: + +.... +curl -XPOST /mailboxes?task=populateFilteringProjection +.... + +Will schedule a task. + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +The scheduled task will have the following type +`PopulateFilteringProjectionTask` and the following +`additionalInformation`: + +.... +{ + "type":"RecomputeAllPreviewsTask", + "processedUserCount": 3, + "failedUserCount": 2 +} +.... + +Response codes: + +* 201: Success. Corresponding task id is returned. +* 400: Error in the request. Details can be found in the reported error. + +==== ReIndexing action + +Be also aware of the limits of this API: + +Warning: During the re-indexing, the result of search operations might +be altered. + +Warning: Canceling this task should be considered unsafe as it will +leave the currently reIndexed mailbox as partially indexed. + +Warning: While we have been trying to reduce the inconsistency window to +a maximum (by keeping track of ongoing events), concurrent changes done +during the reIndexing might be ignored. + +===== ReIndexing all mails + +.... +curl -XPOST http://ip:port/mailboxes?task=reIndex +.... + +Will schedule a task for reIndexing all the mails stored on this James +server. + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +An admin can specify the concurrency that should be used when running +the task: + +* `messagesPerSecond` rate at which messages should be processed per +second. Default is 50. + +This optional parameter must have a strictly positive integer as a value +and be passed as query parameter. + +An admin can also specify the reindexing mode it wants to use when +running the task: + +* `mode` the reindexing mode used. There are 2 modes for the moment: +** `rebuildAll` allows to rebuild all indexes. This is the default mode. +** `fixOutdated` will check for outdated indexed document and reindex +only those. + +This optional parameter must be passed as query parameter. + +It’s good to note as well that there is a limitation with the +`fixOutdated` mode. As we first collect metadata of stored messages to +compare them with the ones in the index, a failed `expunged` operation +might not be well corrected (as the message might not exist anymore but +still be indexed). + +Example: + + curl -XPOST http://ip:port/mailboxes?task=reIndex&messagesPerSecond=200&mode=rebuildAll + +The scheduled task will have the following type `full-reindexing` and +the following `additionalInformation`: + +.... +{ + "type":"full-reindexing", + "runningOptions":{ + "messagesPerSecond":200, + "mode":"REBUILD_ALL" + }, + "successfullyReprocessedMailCount":18, + "failedReprocessedMailCount": 3, + "mailboxFailures": ["12", "23" ], + "messageFailures": [ + { + "mailboxId": "1", + "uids": [1, 36] + }] +} +.... + +===== Fixing previously failed ReIndexing + +Will schedule a task for reIndexing all the mails which had failed to be +indexed from the ReIndexingAllMails task. + +Given `bbdb69c9-082a-44b0-a85a-6e33e74287a5` being a `taskId` generated +for a reIndexing tasks + +.... +curl -XPOST 'http://ip:port/mailboxes?task=reIndex&reIndexFailedMessagesOf=bbdb69c9-082a-44b0-a85a-6e33e74287a5' +.... + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +An admin can specify the concurrency that should be used when running +the task: + +* `messagesPerSecond` rate at which messages should be processed per +second. Default is 50. + +This optional parameter must have a strictly positive integer as a value +and be passed as query parameter. + +An admin can also specify the reindexing mode it wants to use when +running the task: + +* `mode` the reindexing mode used. There are 2 modes for the moment: +** `rebuildAll` allows to rebuild all indexes. This is the default mode. +** `fixOutdated` will check for outdated indexed document and reindex +only those. + +This optional parameter must be passed as query parameter. + +It’s good to note as well that there is a limitation with the +`fixOutdated` mode. As we first collect metadata of stored messages to +compare them with the ones in the index, a failed `expunged` operation +might not be well corrected (as the message might not exist anymore but +still be indexed). + +Example: + +.... +curl -XPOST http://ip:port/mailboxes?task=reIndex&reIndexFailedMessagesOf=bbdb69c9-082a-44b0-a85a-6e33e74287a5&messagesPerSecond=200&mode=rebuildAll +.... + +The scheduled task will have the following type +`error-recovery-indexation` and the following `additionalInformation`: + +.... +{ + "type":"error-recovery-indexation" + "runningOptions":{ + "messagesPerSecond":200, + "mode":"REBUILD_ALL" + }, + "successfullyReprocessedMailCount":18, + "failedReprocessedMailCount": 3, + "mailboxFailures": ["12", "23" ], + "messageFailures": [{ + "mailboxId": "1", + "uids": [1, 36] + }] +} +.... + +===== Create missing parent mailboxes + +Will schedule a task for creating all the missing parent mailboxes in a hierarchical mailbox tree, which is the result +of a partially failed rename operation of a child mailbox. + +.... +curl -XPOST http://ip:port/mailboxes?task=createMissingParents +.... + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +Response codes: + +* 201: Success. Corresponding task id is returned. +* 400: Error in the request. Details can be found in the reported error. + +The scheduled task will have the following type `createMissingParents` and the following `additionalInformation`: + +.... +{ + "type":"createMissingParents" + "created": ["1", "2" ], + "totalCreated": 2, + "failures": [], + "totalFailure": 0 +} +.... + +=== Single mailbox + +==== ReIndexing a mailbox mails + +.... +curl -XPOST http://ip:port/mailboxes/{mailboxId}?task=reIndex +.... + +Will schedule a task for reIndexing all the mails in one mailbox. + +Note that `mailboxId' path parameter needs to be a (implementation +dependent) valid mailboxId. + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +An admin can specify the concurrency that should be used when running +the task: + +* `messagesPerSecond` rate at which messages should be processed per +second. Default is 50. + +This optional parameter must have a strictly positive integer as a value +and be passed as query parameter. + +An admin can also specify the reindexing mode it wants to use when +running the task: + +* `mode` the reindexing mode used. There are 2 modes for the moment: +** `rebuildAll` allows to rebuild all indexes. This is the default mode. +** `fixOutdated` will check for outdated indexed document and reindex +only those. + +This optional parameter must be passed as query parameter. + +It’s good to note as well that there is a limitation with the +`fixOutdated` mode. As we first collect metadata of stored messages to +compare them with the ones in the index, a failed `expunged` operation +might not be well corrected (as the message might not exist anymore but +still be indexed). + +Example: + +.... +curl -XPOST http://ip:port/mailboxes/{mailboxId}?task=reIndex&messagesPerSecond=200&mode=fixOutdated +.... + +Response codes: + +* 201: Success. Corresponding task id is returned. +* 400: Error in the request. Details can be found in the reported error. + +The scheduled task will have the following type `mailbox-reindexing` and +the following `additionalInformation`: + +.... +{ + "type":"mailbox-reindexing", + "runningOptions":{ + "messagesPerSecond":200, + "mode":"FIX_OUTDATED" + }, + "mailboxId":"{mailboxId}", + "successfullyReprocessedMailCount":18, + "failedReprocessedMailCount": 3, + "mailboxFailures": ["12"], + "messageFailures": [ + { + "mailboxId": "1", + "uids": [1, 36] + }] +} +.... + +Warning: During the re-indexing, the result of search operations might +be altered. + +Warning: Canceling this task should be considered unsafe as it will +leave the currently reIndexed mailbox as partially indexed. + +Warning: While we have been trying to reduce the inconsistency window to +a maximum (by keeping track of ongoing events), concurrent changes done +during the reIndexing might be ignored. + +include::{admin-mailboxes-extend}[] + +== Administrating Messages + +=== ReIndexing a single mail by messageId + +.... +curl -XPOST http://ip:port/messages/{messageId}?task=reIndex +.... + +Will schedule a task for reIndexing a single email in all the mailboxes +containing it. + +Note that `messageId' path parameter needs to be a (implementation +dependent) valid messageId. + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +Response codes: + +* 201: Success. Corresponding task id is returned. +* 400: Error in the request. Details can be found in the reported error. + +The scheduled task will have the following type `messageId-reindexing` +and the following `additionalInformation`: + +.... +{ + "messageId":"18" +} +.... + +Warning: During the re-indexing, the result of search operations might +be altered. + +=== Deleting old messages of all users + +*Note:* +Consider enabling the xref:{xref-base}/configure/vault.adoc[Deleted Messages Vault] +if you use this feature. + +Old messages tend to pile up in user INBOXes. An admin might want to delete +these on behalf of the users, e.g. all messages older than 30 days: +.... +curl -XDELETE http://ip:port/messages?olderThan=30d +.... + +link:#_endpoints_returning_a_task[More details about endpoints returning a task]. + +The `olderThan` parameter should be expressed in the following format: `Nunit`. +`N` should be strictly positive. `unit` could be either in the short form +(`d`, `w`, `y` etc.), or in the long form (`days`, `weeks`, `months`, `years`). +The default unit is `days`. + +Response codes: + +* 201: Success. Corresponding task id is returned. +* 400: Error in the request. Details can be found in the reported error. + +The scheduled task will have the type `ExpireMailboxTask` and the following `additionalInformation`: + +.... +{ + "type": "ExpireMailboxTask" + "mailboxesExpired": 5, + "mailboxesFailed": 2, + "mailboxesProcessed": 10, + "messagesDeleted": 23, +} +.... + +To delete old mails from a different mailbox than INBOX, e.g. a mailbox +named "Archived" : +.... +curl -XDELETE http://ip:port/messages?mailbox=Archived&olderThan=30d +.... + +Since this is a somewhat expensive operation, the task is throttled to one user +per second. You may speed it up via `usersPerSecond=10` for example. But keep +in mind that a high rate might overwhelm your database or blob store. + +*Scanning search only:* (unsupported for Lucene and OpenSearch search implementations) + +Some mail clients can add an `Expires` header (RFC 4021) to their messages. +Instead of specifying an absolute age, you may choose to delete only such +messages where the expiration date from this header lies in the past: +.... +curl -XDELETE http://ip:port/messages?byExpiresHeader +.... +In this case you should also add the xref:{xref-base}/configure/mailets.adoc[mailet] +`Expires` to your mailet container, which can sanitize expiration date headers. + +include::{admin-messages-extend}[] + +== Administrating user mailboxes + +=== Creating a mailbox + +.... +curl -XPUT http://ip:port/users/{usernameToBeUsed}/mailboxes/{mailboxNameToBeCreated} +.... + +Resource name `usernameToBeUsed` should be an existing user Resource +name `mailboxNameToBeCreated` should not be empty, nor contain % * characters, nor starting with #. + +Response codes: + +* 204: The mailbox now exists on the server +* 400: Invalid mailbox name +* 404: The user name does not exist. Note that this check can be bypassed by specifying the `force` query parameter. + +To create nested mailboxes, for instance a work mailbox inside the INBOX +mailbox, people should use the . separator. The sample query is: + +.... +curl -XDELETE http://ip:port/users/{usernameToBeUsed}/mailboxes/INBOX.work +.... + +=== Deleting a mailbox and its children + +.... +curl -XDELETE http://ip:port/users/{usernameToBeUsed}/mailboxes/{mailboxNameToBeDeleted} +.... + +Resource name `usernameToBeUsed` should be an existing user Resource +name `mailboxNameToBeDeleted` should not be empty + +Response codes: + +* 204: The mailbox now does not exist on the server +* 400: Invalid mailbox name +* 404: The user name does not exist. Note that this check can be bypassed by specifying the `force` query parameter. + +=== Testing existence of a mailbox + +.... +curl -XGET http://ip:port/users/{usernameToBeUsed}/mailboxes/{mailboxNameToBeTested} +.... + +Resource name `usernameToBeUsed` should be an existing user Resource +name `mailboxNameToBeTested` should not be empty + +Response codes: + +* 204: The mailbox exists +* 400: Invalid mailbox name +* 404: The user name does not exist, the mailbox does not exist + +=== Listing user mailboxes + +.... +curl -XGET http://ip:port/users/{usernameToBeUsed}/mailboxes +.... + +The answer looks like: + +.... +[{"mailboxName":"INBOX"},{"mailboxName":"outbox"}] +.... + +Resource name `usernameToBeUsed` should be an existing user + +Response codes: + +* 200: The mailboxes list was successfully retrieved +* 404: The user name does not exist, the mailbox does not exist. Note that this check can be bypassed by specifying the `force` query parameter. + + +=== Deleting user mailboxes + +.... +curl -XDELETE http://ip:port/users/{usernameToBeUsed}/mailboxes +.... + +Resource name `usernameToBeUsed` should be an existing user + +Response codes: + +* 204: The user do not have mailboxes anymore +* 404: The user name does not exist. Note that this check can be bypassed by specifying the `force` query parameter. + +=== Exporting user mailboxes + +.... +curl -XPOST http://ip:port/users/{usernameToBeUsed}/mailboxes?action=export +.... + +Resource name `usernameToBeUsed` should be an existing user + +Response codes: + +* 201: Success. Corresponding task id is returned +* 404: The user name does not exist + +The scheduled task will have the following type `MailboxesExportTask` +and the following `additionalInformation`: + +.... +{ + "type":"MailboxesExportTask", + "timestamp":"2007-12-03T10:15:30Z", + "username": "user", + "stage": "STARTING" +} +.... + +=== ReIndexing a user mails + +.... +curl -XPOST http://ip:port/users/{usernameToBeUsed}/mailboxes?task=reIndex +.... + +Will schedule a task for reIndexing all the mails in ``user@domain.com'' +mailboxes (encoded above). + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +An admin can specify the concurrency that should be used when running +the task: + +* `messagesPerSecond` rate at which messages should be processed per +second. Default is 50. + +This optional parameter must have a strictly positive integer as a value +and be passed as query parameter. + +An admin can also specify the reindexing mode it wants to use when +running the task: + +* `mode` the reindexing mode used. There are 2 modes for the moment: +** `rebuildAll` allows to rebuild all indexes. This is the default mode. +** `fixOutdated` will check for outdated indexed document and reindex +only those. + +This optional parameter must be passed as query parameter. + +It’s good to note as well that there is a limitation with the +`fixOutdated` mode. As we first collect metadata of stored messages to +compare them with the ones in the index, a failed `expunged` operation +might not be well corrected (as the message might not exist anymore but +still be indexed). + +Example: + +.... +curl -XPOST http://ip:port/users/{usernameToBeUsed}/mailboxes?task=reIndex&messagesPerSecond=200&mode=fixOutdated +.... + +Response codes: + +* 201: Success. Corresponding task id is returned. +* 400: Error in the request. Details can be found in the reported error. + +The scheduled task will have the following type `user-reindexing` and +the following `additionalInformation`: + +.... +{ + "type":"user-reindexing", + "runningOptions":{ + "messagesPerSecond":200, + "mode":"FIX_OUTDATED" + }, + "user":"user@domain.com", + "successfullyReprocessedMailCount":18, + "failedReprocessedMailCount": 3, + "mailboxFailures": ["12", "23" ], + "messageFailures": [ + { + "mailboxId": "1", + "uids": [1, 36] + }] +} +.... + +Warning: During the re-indexing, the result of search operations might +be altered. + +Warning: Canceling this task should be considered unsafe as it will +leave the currently reIndexed mailbox as partially indexed. + +Warning: While we have been trying to reduce the inconsistency window to +a maximum (by keeping track of ongoing events), concurrent changes done +during the reIndexing might be ignored. + +=== Counting emails + +.... +curl -XGET http://ip:port/users/{usernameToBeUsed}/mailboxes/{mailboxName}/messageCount +.... + +Will return the total count of messages within the mailbox of that user. + +Resource name `usernameToBeUsed` should be an existing user. + +Resource name `mailboxName` should not be empty, nor contain `% *` characters, nor starting with `#`. + +Response codes: + +* 200: The number of emails in a given mailbox +* 400: Invalid mailbox name +* 404: Invalid get on user mailboxes. The `usernameToBeUsed` or `mailboxName` does not exit' + +=== Counting unseen emails + +.... +curl -XGET http://ip:port/users/{usernameToBeUsed}/mailboxes/{mailboxName}/unseenMessageCount +.... + +Will return the total count of unseen messages within the mailbox of that user. + +Resource name `usernameToBeUsed` should be an existing user. + +Resource name `mailboxName` should not be empty, nor contain `% *` characters, nor starting with `#`. + +Response codes: + +* 200: The number of unseen emails in a given mailbox +* 400: Invalid mailbox name +* 404: Invalid get on user mailboxes. The `usernameToBeUsed` or `mailboxName` does not exit' + +=== Clearing mailbox content + +.... +curl -XDELETE http://ip:port/users/{usernameToBeUsed}/mailboxes/{mailboxName}/messages +.... + +Will schedule a task for clearing all the mails in ``mailboxName`` mailbox of ``usernameToBeUsed``. + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +Resource name `usernameToBeUsed` should be an existing user. + +Resource name `mailboxName` should not be empty, nor contain `% *` characters, nor starting with `#`. + +Response codes: + +* 201: Success. Corresponding task id is returned. +* 400: Invalid mailbox name +* 404: Invalid get on user mailboxes. The `username` or `mailboxName` does not exit + +The scheduled task will have the following type `ClearMailboxContentTask` and +the following `additionalInformation`: + +.... +{ + "mailboxName": "mbx1", + "messagesFailCount": 9, + "messagesSuccessCount": 10, + "timestamp": "2007-12-03T10:15:30Z", + "type": "ClearMailboxContentTask", + "username": "bob@domain.tld" +} +.... + +=== Subscribing a user to all of its mailboxes + +.... +curl -XPOST http://ip:port/users/{usernameToBeUsed}/mailboxes?task=subscribeAll +.... + +Will schedule a task for subscribing a user to all of its mailboxes. + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +Most users are unaware of what an IMAP subscription is, nor how they can manage it. If the subscription list gets out +of sync with the mailbox list, it could result in downgraded user experience (see MAILBOX-405). This task allow +to reset the subscription list to the mailbox list on a per user basis thus working around the aforementioned issues. + +Response codes: + +- 201: Success. Corresponding task id is returned. +- 404: No such user + +The scheduled task will have the following type `SubscribeAllTask` and the following `additionalInformation`: + +.... +{ + "type":"SubscribeAllTask", + "username":"user@domain.com", + "subscribedCount":18, + "unsubscribedCount": 3 +} +.... + +=== Recomputing User JMAP fast message view projection + +This action is only available for backends supporting JMAP protocol. + +Message fast view projection stores message properties expected to be +fast to fetch but are actually expensive to compute, in order for +GetMessages operation to be fast to execute for these properties. + +These projection items are asynchronously computed on mailbox events. + +You can force the full projection recomputation by calling the following +endpoint: + +.... +curl -XPOST /users/{usernameToBeUsed}/mailboxes?task=recomputeFastViewProjectionItems +.... + +Will schedule a task for recomputing the fast message view projection +for all mailboxes of `usernameToBeUsed`. + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +An admin can specify the concurrency that should be used when running +the task: + +* `messagesPerSecond` rate at which messages should be processed, per +second. Defaults to 10. + +This optional parameter must have a strictly positive integer as a value +and be passed as query parameters. + +Example: + +.... +curl -XPOST /mailboxes?task=recomputeFastViewProjectionItems&messagesPerSecond=20 +.... + +The scheduled task will have the following type +`RecomputeUserFastViewProjectionItemsTask` and the following +`additionalInformation`: + +.... +{ + "type":"RecomputeUserFastViewProjectionItemsTask", + "username": "{usernameToBeUsed}", + "processedMessageCount": 3, + "failedMessageCount": 1, + "runningOptions": { + "messagesPerSecond":20 + } +} +.... + +Response codes: + +* 201: Success. Corresponding task id is returned. +* 400: Error in the request. Details can be found in the reported error. +* 404: User not found. + +== Administrating quotas + +=== Administrating quotas by users + +==== Getting the quota for a user + +.... +curl -XGET http://ip:port/quota/users/{usernameToBeUsed} +.... + +Resource name `usernameToBeUsed` should be an existing user + +The answer is the details of the quota of that user. + +.... +{ + "global": { + "count":252, + "size":242 + }, + "domain": { + "count":152, + "size":142 + }, + "user": { + "count":52, + "size":42 + }, + "computed": { + "count":52, + "size":42 + }, + "occupation": { + "size":13, + "count":21, + "ratio": { + "size":0.25, + "count":0.5, + "max":0.5 + } + } +} +.... + +* The `global` entry represent the quota limit allowed on this James +server. +* The `domain` entry represent the quota limit allowed for the user of +that domain. +* The `user` entry represent the quota limit allowed for this specific +user. +* The `computed` entry represent the quota limit applied for this user, +resolved from the upper values. +* The `occupation` entry represent the occupation of the quota for this +user. This includes used count and size as well as occupation ratio +(used / limit). + +Note that `quota` object can contain a fixed value, an empty value +(null) or an unlimited value (-1): + +.... +{"count":52,"size":42} + +{"count":null,"size":null} + +{"count":52,"size":-1} +.... + +Response codes: + +* 200: The user’s quota was successfully retrieved +* 404: The user does not exist + +==== Updating the quota for a user + +.... +curl -XPUT http://ip:port/quota/users/{usernameToBeUsed} +.... + +Resource name `usernameToBeUsed` should be an existing user + +The body can contain a fixed value, an empty value (null) or an +unlimited value (-1): + +.... +{"count":52,"size":42} + +{"count":null,"size":null} + +{"count":52,"size":-1} +.... + +Response codes: + +* 204: The quota has been updated +* 400: The body is not a positive integer neither an unlimited value +(-1). +* 404: The user does not exist + +==== Getting the quota count for a user + +.... +curl -XGET http://ip:port/quota/users/{usernameToBeUsed}/count +.... + +Resource name `usernameToBeUsed` should be an existing user + +The answer looks like: + +.... +52 +.... + +Response codes: + +* 200: The user’s quota was successfully retrieved +* 204: No quota count limit is defined at the user level for this user +* 404: The user does not exist + +==== Updating the quota count for a user + +.... +curl -XPUT http://ip:port/quota/users/{usernameToBeUsed}/count +.... + +Resource name `usernameToBeUsed` should be an existing user + +The body can contain a fixed value or an unlimited value (-1): + +.... +52 +.... + +Response codes: + +* 204: The quota has been updated +* 400: The body is not a positive integer neither an unlimited value +(-1). +* 404: The user does not exist + +==== Deleting the quota count for a user + +.... +curl -XDELETE http://ip:port/quota/users/{usernameToBeUsed}/count +.... + +Resource name `usernameToBeUsed` should be an existing user + +Response codes: + +* 204: The quota has been updated to unlimited value. +* 404: The user does not exist + +==== Getting the quota size for a user + +.... +curl -XGET http://ip:port/quota/users/{usernameToBeUsed}/size +.... + +Resource name `usernameToBeUsed` should be an existing user + +The answer looks like: + +.... +52 +.... + +Response codes: + +* 200: The user’s quota was successfully retrieved +* 204: No quota size limit is defined at the user level for this user +* 404: The user does not exist + +==== Updating the quota size for a user + +.... +curl -XPUT http://ip:port/quota/users/{usernameToBeUsed}/size +.... + +Resource name `usernameToBeUsed` should be an existing user + +The body can contain a fixed value or an unlimited value (-1): + +.... +52 +.... + +Response codes: + +* 204: The quota has been updated +* 400: The body is not a positive integer neither an unlimited value +(-1). +* 404: The user does not exist + +==== Deleting the quota size for a user + +.... +curl -XDELETE http://ip:port/quota/users/{usernameToBeUsed}/size +.... + +Resource name `usernameToBeUsed` should be an existing user + +Response codes: + +* 204: The quota has been updated to unlimited value. +* 404: The user does not exist + +==== Searching user by quota ratio + +.... +curl -XGET 'http://ip:port/quota/users?minOccupationRatio=0.8&maxOccupationRatio=0.99&limit=100&offset=200&domain=domain.com' +.... + +Will return: + +.... +[ + { + "username":"user@domain.com", + "detail": { + "global": { + "count":252, + "size":242 + }, + "domain": { + "count":152, + "size":142 + }, + "user": { + "count":52, + "size":42 + }, + "computed": { + "count":52, + "size":42 + }, + "occupation": { + "size":48, + "count":21, + "ratio": { + "size":0.9230, + "count":0.5, + "max":0.9230 + } + } + } + }, + ... +] +.... + +Where: + +* *minOccupationRatio* is a query parameter determining the minimum +occupation ratio of users to be returned. +* *maxOccupationRatio* is a query parameter determining the maximum +occupation ratio of users to be returned. +* *domain* is a query parameter determining the domain of users to be +returned. +* *limit* is a query parameter determining the maximum number of users +to be returned. +* *offset* is a query parameter determining the number of users to skip. + +Please note that users are alphabetically ordered on username. + +The response is a list of usernames, with attached quota details as +defined link:#_getting_the_quota_for_a_user[here]. + +Response codes: + +* 200: List of users had successfully been returned. +* 400: Validation issues with parameters + +==== Recomputing current quotas for users + +.... +curl -XPOST /quota/users?task=RecomputeCurrentQuotas +.... + +Will recompute current quotas (count and size) for all users stored in +James. + +James maintains per quota a projection for current quota count and size. +As with any projection, it can go out of sync, leading to inconsistent +results being returned to the client. + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +An admin can specify the concurrency that should be used when running +the task: + +* `usersPerSecond` rate at which users quotas should be reprocessed, per +second. Defaults to 1. + +This optional parameter must have a strictly positive integer as a value +and be passed as query parameters. + +An admin can select which quota component he wants to recompute: + +* `quotaComponent` component whose quota need to be reprocessed. It could be one of values: MAILBOX, SIEVE, JMAP_UPLOADS. + +The admin could select several quota components. If he does not select, quotas of all components would be recomputed. + +Example: + +.... +curl -XPOST /quota/users?task=RecomputeCurrentQuotas&usersPerSecond=20"aComponent=MAILBOX"aComponent=JMAP_UPLOADS +.... + +The scheduled task will have the following type +`recompute-current-quotas` and the following `additionalInformation`: + +.... +{ + "type":"recompute-current-quotas", + "recomputeSingleQuotaComponentResults": [ + { + "quotaComponent": "MAILBOX", + "processedIdentifierCount": 3, + "failedIdentifiers": ["#private&bob@localhost"] + }, + { + "quotaComponent": "JMAP_UPLOADS", + "processedIdentifierCount": 3, + "failedIdentifiers": ["bob@localhost"] + } + ], + "runningOptions": { + "usersPerSecond":20 + } +} +.... + +*WARNING*: this task do not take into account concurrent modifications +upon a single current quota re-computation. Rerunning the task will +_eventually_ provide the consistent result. + +=== Administrating quotas by domains + +==== Getting the quota for a domain + +.... +curl -XGET http://ip:port/quota/domains/{domainToBeUsed} +.... + +Resource name `domainToBeUsed` should be an existing domain. For +example: + +.... +curl -XGET http://ip:port/quota/domains/james.org +.... + +The answer will detail the default quota applied to users belonging to +that domain: + +.... +{ + "global": { + "count":252, + "size":null + }, + "domain": { + "count":null, + "size":142 + }, + "computed": { + "count":252, + "size":142 + } +} +.... + +* The `global` entry represents the quota limit defined on this James +server by default. +* The `domain` entry represents the quota limit allowed for the user of +that domain by default. +* The `computed` entry represents the quota limit applied for the users +of that domain, by default, resolved from the upper values. + +Note that `quota` object can contain a fixed value, an empty value +(null) or an unlimited value (-1): + +.... +{"count":52,"size":42} + +{"count":null,"size":null} + +{"count":52,"size":-1} +.... + +Response codes: + +* 200: The domain’s quota was successfully retrieved +* 404: The domain does not exist +* 405: Domain Quota configuration not supported when virtual hosting is +deactivated. + +==== Updating the quota for a domain + +.... +curl -XPUT http://ip:port/quota/domains/{domainToBeUsed} +.... + +Resource name `domainToBeUsed` should be an existing domain. + +The body can contain a fixed value, an empty value (null) or an +unlimited value (-1): + +.... +{"count":52,"size":42} + +{"count":null,"size":null} + +{"count":52,"size":-1} +.... + +Response codes: + +* 204: The quota has been updated +* 400: The body is not a positive integer neither an unlimited value +(-1). +* 404: The domain does not exist +* 405: Domain Quota configuration not supported when virtual hosting is +deactivated. + +==== Getting the quota count for a domain + +.... +curl -XGET http://ip:port/quota/domains/{domainToBeUsed}/count +.... + +Resource name `domainToBeUsed` should be an existing domain. + +The answer looks like: + +.... +52 +.... + +Response codes: + +* 200: The domain’s quota was successfully retrieved +* 204: No quota count limit is defined at the domain level for this +domain +* 404: The domain does not exist +* 405: Domain Quota configuration not supported when virtual hosting is +desactivated. + +==== Updating the quota count for a domain + +.... +curl -XPUT http://ip:port/quota/domains/{domainToBeUsed}/count +.... + +Resource name `domainToBeUsed` should be an existing domain. + +The body can contain a fixed value or an unlimited value (-1): + +.... +52 +.... + +Response codes: + +* 204: The quota has been updated +* 400: The body is not a positive integer neither an unlimited value +(-1). +* 404: The domain does not exist +* 405: Domain Quota configuration not supported when virtual hosting is +desactivated. + +==== Deleting the quota count for a domain + +.... +curl -XDELETE http://ip:port/quota/domains/{domainToBeUsed}/count +.... + +Resource name `domainToBeUsed` should be an existing domain. + +Response codes: + +* 204: The quota has been updated to unlimited value. +* 404: The domain does not exist +* 405: Domain Quota configuration not supported when virtual hosting is +deactivated. + +==== Getting the quota size for a domain + +.... +curl -XGET http://ip:port/quota/domains/{domainToBeUsed}/size +.... + +Resource name `domainToBeUsed` should be an existing domain. + +The answer looks like: + +.... +52 +.... + +Response codes: + +* 200: The domain’s quota was successfully retrieved +* 204: No quota size limit is defined at the domain level for this +domain +* 404: The domain does not exist +* 405: Domain Quota configuration not supported when virtual hosting is +deactivated. + +==== Updating the quota size for a domain + +.... +curl -XPUT http://ip:port/quota/domains/{domainToBeUsed}/size +.... + +Resource name `domainToBeUsed` should be an existing domain. + +The body can contain a fixed value or an unlimited value (-1): + +.... +52 +.... + +Response codes: + +* 204: The quota has been updated +* 400: The body is not a positive integer neither an unlimited value +(-1). +* 404: The domain does not exist +* 405: Domain Quota configuration not supported when virtual hosting is +deactivated. + +==== Deleting the quota size for a domain + +.... +curl -XDELETE http://ip:port/quota/domains/{domainToBeUsed}/size +.... + +Resource name `domainToBeUsed` should be an existing domain. + +Response codes: + +* 204: The quota has been updated to unlimited value. +* 404: The domain does not exist + +=== Administrating global quotas + +==== Getting the global quota + +.... +curl -XGET http://ip:port/quota +.... + +The answer is the details of the global quota. + +.... +{ + "count":252, + "size":242 +} +.... + +Note that `quota` object can contain a fixed value, an empty value +(null) or an unlimited value (-1): + +.... +{"count":52,"size":42} + +{"count":null,"size":null} + +{"count":52,"size":-1} +.... + +Response codes: + +* 200: The quota was successfully retrieved + +==== Updating global quota + +.... +curl -XPUT http://ip:port/quota +.... + +The body can contain a fixed value, an empty value (null) or an +unlimited value (-1): + +.... +{"count":52,"size":42} + +{"count":null,"size":null} + +{"count":52,"size":-1} +.... + +Response codes: + +* 204: The quota has been updated +* 400: The body is not a positive integer neither an unlimited value +(-1). + +==== Getting the global quota count + +.... +curl -XGET http://ip:port/quota/count +.... + +Resource name usernameToBeUsed should be an existing user + +The answer looks like: + +.... +52 +.... + +Response codes: + +* 200: The quota was successfully retrieved +* 204: No quota count limit is defined at the global level + +==== Updating the global quota count + +.... +curl -XPUT http://ip:port/quota/count +.... + +The body can contain a fixed value or an unlimited value (-1): + +.... +52 +.... + +Response codes: + +* 204: The quota has been updated +* 400: The body is not a positive integer neither an unlimited value +(-1). + +==== Deleting the global quota count + +.... +curl -XDELETE http://ip:port/quota/count +.... + +Response codes: + +* 204: The quota has been updated to unlimited value. + +==== Getting the global quota size + +.... +curl -XGET http://ip:port/quota/size +.... + +The answer looks like: + +.... +52 +.... + +Response codes: + +* 200: The quota was successfully retrieved +* 204: No quota size limit is defined at the global level + +==== Updating the global quota size + +.... +curl -XPUT http://ip:port/quota/size +.... + +The body can contain a fixed value or an unlimited value (-1): + +.... +52 +.... + +Response codes: + +* 204: The quota has been updated +* 400: The body is not a positive integer neither an unlimited value +(-1). + +==== Deleting the global quota size + +.... +curl -XDELETE http://ip:port/quota/size +.... + +Response codes: + +* 204: The quota has been updated to unlimited value. + +=== Administrating Sieve quotas + +Some limitations on space Users Sieve script can occupy can be +configured by default, and overridden by user. + +==== Retrieving global sieve quota + +This endpoints allows to retrieve the global Sieve quota, which will be +users default: + +.... +curl -XGET http://ip:port/sieve/quota/default +.... + +Will return the bytes count allowed by user per default on this server. + +.... +102400 +.... + +Response codes: + +* 200: Request is a success and the value is returned +* 204: No default quota is being configured + +==== Updating global sieve quota + +This endpoints allows to update the global Sieve quota, which will be +users default: + +.... +curl -XPUT http://ip:port/sieve/quota/default +.... + +With the body being the bytes count allowed by user per default on this +server. + +.... +102400 +.... + +Response codes: + +* 204: Operation succeeded +* 400: Invalid payload + +==== Removing global sieve quota + +This endpoints allows to remove the global Sieve quota. There will no +more be users default: + +.... +curl -XDELETE http://ip:port/sieve/quota/default +.... + +Response codes: + +* 204: Operation succeeded + +==== Retrieving user sieve quota + +This endpoints allows to retrieve the Sieve quota of a user, which will +be this users quota: + +.... +curl -XGET http://ip:port/sieve/quota/users/user@domain.com +.... + +Will return the bytes count allowed for this user. + +.... +102400 +.... + +Response codes: + +* 200: Request is a success and the value is returned +* 204: No quota is being configured for this user + +==== Updating user sieve quota + +This endpoints allows to update the Sieve quota of a user, which will be +users default: + +.... +curl -XPUT http://ip:port/sieve/quota/users/user@domain.com +.... + +With the body being the bytes count allowed for this user on this +server. + +.... +102400 +.... + +Response codes: + +* 204: Operation succeeded +* 400: Invalid payload + +==== Removing user sieve quota + +This endpoints allows to remove the Sieve quota of a user. There will no +more quota for this user: + +.... +curl -XDELETE http://ip:port/sieve/quota/users/user@domain.com +.... + +Response codes: + +* 204: Operation succeeded + +== Administrating Jmap Uploads + +=== Cleaning upload repository + +.... +curl -XDELETE http://ip:port/jmap/uploads?scope=expired +.... + +Will schedule a task for clearing expired upload entries. + + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + + +Query parameter `scope` is required and have the value `expired`. + +Response codes: + +* 201: Success. Corresponding task id is returned. +* 400: Scope invalid + +The scheduled task will have the following type `UploadRepositoryCleanupTask` and +the following `additionalInformation`: + +.... +{ + "scope": "expired", + "timestamp": "2007-12-03T10:15:30Z", + "type": "UploadRepositoryCleanupTask" +} +.... + +== Running blob garbage collection + +When deduplication is enabled one needs to explicitly run a garbage collection in order to delete no longer referenced +blobs. + +To do so: + +.... +curl -XDELETE http://ip:port/blobs?scope=unreferenced +.... + +link:#_endpoints_returning_a_task[More details about endpoints returning a task]. + +Additional parameters include Bloom filter tuning parameters: + +- *associatedProbability*: Allow to define the targeted false positive rate. Note that subsequent runs do not have the +same false-positives. Defaults to `0.01`. +- *expectedBlobCount*: Expected count of blobs used to size the bloom filters. Defaults to `1.000.000`. + +These settings directly impacts the memory footprint of the bloom filter. link:https://hur.st/bloomfilter/[Simulators] can +help understand those parameters. + +The created task has the following additional information: + +.... +{ + "referenceSourceCount": 3456, + "blobCount": 5678, + "gcedBlobCount": 1234, + "bloomFilterExpectedBlobCount": 10000, + "bloomFilterAssociatedProbability": 0.01 +} +.... + +Where: + +- *bloomFilterExpectedBlobCount* correspond to the supplied *expectedBlobCount* query parameter. +- *bloomFilterAssociatedProbability* correspond to the supplied *associatedProbability* query parameter. +- *referenceSourceCount* is the count of distinct blob references encountered while populating the bloom filter. +- *blobCount* is the count of blobs tried against the bloom filter. This value can be used to better size the bloom +filter in later runs. +- *gcedBlobCount* is the count of blobs that were garbage collected. + +== Administrating Recipient rewriting + +=== Address group + +You can use *webadmin* to define address groups. + +When a specific email is sent to the group mail address, every group +member will receive it. + +Note that the group mail address is virtual: it does not correspond to +an existing user. + +This feature uses xref:{xref-base}/architecture/index.adoc#_recipient_rewrite_tables[Recipients rewrite table] +and requires the +https://github.com/apache/james-project/blob/master/server/mailet/mailets/src/main/java/org/apache/james/transport/mailets/RecipientRewriteTable.java[RecipientRewriteTable +mailet] to be configured. + +Note that email addresses are restricted to ASCII character set. Mail +addresses not matching this criteria will be rejected. + +==== Listing groups + +.... +curl -XGET http://ip:port/address/groups +.... + +Will return the groups as a list of JSON Strings representing mail +addresses. For instance: + +.... +["group1@domain.com", "group2@domain.com"] +.... + +Response codes: + +* 200: Success + +==== Listing members of a group + +.... +curl -XGET http://ip:port/address/groups/group@domain.com +.... + +Will return the group members as a list of JSON Strings representing +mail addresses. For instance: + +.... +["member1@domain.com", "member2@domain.com"] +.... + +Response codes: + +* 200: Success +* 400: Group structure is not valid +* 404: The group does not exist + +==== Adding a group member + +.... +curl -XPUT http://ip:port/address/groups/group@domain.com/member@domain.com +.... + +Will add member@domain.com to group@domain.com, creating the group if +needed + +Response codes: + +* 204: Success +* 400: Group structure or member is not valid +* 400: Domain in the source is not managed by the DomainList +* 409: Requested group address is already used for another purpose +* 409: The addition of the group member would lead to a loop and thus cannot be performed + +==== Removing a group member + +.... +curl -XDELETE http://ip:port/address/groups/group@domain.com/member@domain.com +.... + +Will remove member@domain.com from group@domain.com, removing the group +if group is empty after deletion + +Response codes: + +* 204: Success +* 400: Group structure or member is not valid + +=== Address forwards + +You can use *webadmin* to define address forwards. + +When a specific email is sent to the base mail address, every forward +destination addresses will receive it. + +Please note that the base address can be optionaly part of the forward +destination. In that case, the base recipient also receive a copy of the +mail. Otherwise he is omitted. + +Forwards can be defined for existing users. It then defers from +``groups''. + +This feature uses xref:{xref-base}/architecture/index.adoc#_recipient_rewrite_tables[Recipients rewrite table] +and requires the +https://github.com/apache/james-project/blob/master/server/mailet/mailets/src/main/java/org/apache/james/transport/mailets/RecipientRewriteTable.java[RecipientRewriteTable +mailet] to be configured. + +Note that email addresses are restricted to ASCII character set. Mail +addresses not matching this criteria will be rejected. + +==== Listing Forwards + +.... +curl -XGET http://ip:port/address/forwards +.... + +Will return the users having forwards configured as a list of JSON +Strings representing mail addresses. For instance: + +.... +["user1@domain.com", "user2@domain.com"] +.... + +Response codes: + +* 200: Success + +==== Listing destinations in a forward + +.... +curl -XGET http://ip:port/address/forwards/user@domain.com +.... + +Will return the destination addresses of this forward as a list of JSON +Strings representing mail addresses. For instance: + +.... +[ + {"mailAddress":"destination1@domain.com"}, + {"mailAddress":"destination2@domain.com"} +] +.... + +Response codes: + +* 200: Success +* 400: Forward structure is not valid +* 404: The given user don’t have forwards or does not exist + +==== Adding a new destination to a forward + +.... +curl -XPUT http://ip:port/address/forwards/user@domain.com/targets/destination@domain.com +.... + +Will add destination@domain.com to user@domain.com, creating the forward +if needed + +Response codes: + +* 204: Success +* 400: Forward structure or member is not valid +* 400: Domain in the source is not managed by the DomainList +* 404: Requested forward address does not match an existing user +* 409: The creation of the forward would lead to a loop and thus cannot be performed + +==== Removing a destination of a forward + +.... +curl -XDELETE http://ip:port/address/forwards/user@domain.com/targets/destination@domain.com +.... + +Will remove destination@domain.com from user@domain.com, removing the +forward if forward is empty after deletion + +Response codes: + +* 204: Success +* 400: Forward structure or member is not valid + +=== Address aliases + +You can use *webadmin* to define aliases for an user. + +When a specific email is sent to the alias address, the destination +address of the alias will receive it. + +Aliases can be defined for existing users. + +This feature uses xref:{xref-base}/architecture/index.adoc#_recipient_rewrite_tables[Recipients rewrite table] +and requires the +https://github.com/apache/james-project/blob/master/server/mailet/mailets/src/main/java/org/apache/james/transport/mailets/RecipientRewriteTable.java[RecipientRewriteTable +mailet] to be configured. + +Note that email addresses are restricted to ASCII character set. Mail +addresses not matching this criteria will be rejected. + +==== Listing users with aliases + +.... +curl -XGET http://ip:port/address/aliases +.... + +Will return the users having aliases configured as a list of JSON +Strings representing mail addresses. For instance: + +.... +["user1@domain.com", "user2@domain.com"] +.... + +Response codes: + +* 200: Success + +==== Listing alias sources of an user + +.... +curl -XGET http://ip:port/address/aliases/user@domain.com +.... + +Will return the aliases of this user as a list of JSON Strings +representing mail addresses. For instance: + +.... +[ + {"source":"alias1@domain.com"}, + {"source":"alias2@domain.com"} +] +.... + +Response codes: + +* 200: Success +* 400: Alias structure is not valid + +==== Adding a new alias to an user + +.... +curl -XPUT http://ip:port/address/aliases/user@domain.com/sources/alias@domain.com +.... + +Will add alias@domain.com to user@domain.com, creating the alias if +needed + +Response codes: + +* 204: OK +* 400: Alias structure or member is not valid +* 400: Source and destination can’t be the same! +* 400: Domain in the destination or source is not managed by the +DomainList +* 409: The alias source exists as an user already +* 409: The addition of the alias would lead to a loop and thus cannot be performed + +==== Removing an alias of an user + +.... +curl -XDELETE http://ip:port/address/aliases/user@domain.com/sources/alias@domain.com +.... + +Will remove alias@domain.com from user@domain.com, removing the alias if +needed + +Response codes: + +* 204: OK +* 400: Alias structure or member is not valid + +=== Domain mappings + +You can use *webadmin* to define domain mappings. + +Given a configured source (from) domain and a destination (to) domain, +when an email is sent to an address belonging to the source domain, then +the domain part of this address is overwritten, the destination domain +is then used. A source (from) domain can have many destination (to) +domains. + +For example: with a source domain `james.apache.org` maps to two +destination domains `james.org` and `apache-james.org`, when a mail is +sent to `admin@james.apache.org`, then it will be routed to +`admin@james.org` and `admin@apache-james.org` + +This feature uses xref:{xref-base}/architecture/index.adoc#_recipient_rewrite_tables[Recipients rewrite table] +and requires the +https://github.com/apache/james-project/blob/master/server/mailet/mailets/src/main/java/org/apache/james/transport/mailets/RecipientRewriteTable.java[RecipientRewriteTable +mailet] to be configured. + +Note that email addresses are restricted to ASCII character set. Mail +addresses not matching this criteria will be rejected. + +==== Listing all domain mappings + +.... +curl -XGET http://ip:port/domainMappings +.... + +Will return all configured domain mappings + +.... +{ + "firstSource.org" : ["firstDestination.com", "secondDestination.net"], + "secondSource.com" : ["thirdDestination.com", "fourthDestination.net"], +} +.... + +Response codes: + +* 200: OK + +==== Listing all destination domains for a source domain + +.... +curl -XGET http://ip:port/domainMappings/sourceDomain.tld +.... + +With `sourceDomain.tld` as the value passed to `fromDomain` resource +name, the API will return all destination domains configured to that +domain + +.... +["firstDestination.com", "secondDestination.com"] +.... + +Response codes: + +* 200: OK +* 400: The `fromDomain` resource name is invalid +* 404: The `fromDomain` resource name is not found + +==== Adding a domain mapping + +.... +curl -XPUT http://ip:port/domainMappings/sourceDomain.tld +.... + +Body: + +.... +destination.tld +.... + +With `sourceDomain.tld` as the value passed to `fromDomain` resource +name, the API will add a destination domain specified in the body to +that domain + +Response codes: + +* 204: OK +* 400: The `fromDomain` resource name is invalid +* 400: The destination domain specified in the body is invalid + +Be aware that no checks to find possible loops that would result of this creation will be performed. + +==== Removing a domain mapping + +.... +curl -XDELETE http://ip:port/domainMappings/sourceDomain.tld +.... + +Body: + +.... +destination.tld +.... + +With `sourceDomain.tld` as the value passed to `fromDomain` resource +name, the API will remove a destination domain specified in the body +mapped to that domain + +Response codes: + +* 204: OK +* 400: The `fromDomain` resource name is invalid +* 400: The destination domain specified in the body is invalid + +=== Regex mapping + +You can use *webadmin* to create regex mappings. + +A regex mapping contains a mapping source and a Java Regular Expression +(regex) in String as the mapping value. Everytime, if a mail containing +a recipient matched with the mapping source, then that mail will be +re-routed to a new recipient address which is re written by the regex. + +This feature uses xref:{xref-base}/architecture/index.adoc#_recipient_rewrite_tables[Recipients rewrite table] +and requires the +https://github.com/apache/james-project/blob/master/server/mailet/mailets/src/main/java/org/apache/james/transport/mailets/RecipientRewriteTable.java[RecipientRewriteTable +API] to be configured. + +==== Adding a regex mapping + +.... +POST /mappings/regex/mappingSource/targets/regex +.... + +Where: + +* the `mappingSource` is the path parameter represents for the Regex +Mapping mapping source +* the `regex` is the path parameter represents for the Regex Mapping +regex + +The route will add a regex mapping made from `mappingSource` and `regex` +to RecipientRewriteTable. + +Example: + +.... +curl -XPOST http://ip:port/mappings/regex/james@domain.tld/targets/james@.*:james-intern@james.org +.... + +Response codes: + +* 204: Mapping added successfully. +* 400: Invalid `mappingSource` path parameter. +* 400: Invalid `regex` path parameter. + +Be aware that no checks to find possible loops that would result of this creation will be performed. + +==== Removing a regex mapping + +.... +DELETE /mappings/regex/{mappingSource}/targets/{regex} +.... + +Where: + +* the `mappingSource` is the path parameter representing the Regex +Mapping mapping source +* the `regex` is the path parameter representing the Regex Mapping regex + +The route will remove the regex mapping made from `regex` from the +mapping source `mappingSource` to RecipientRewriteTable. + +Example: + +.... +curl -XDELETE http://ip:port/mappings/regex/james@domain.tld/targets/[O_O]:james-intern@james.org +.... + +Response codes: + +* 204: Mapping deleted successfully. +* 400: Invalid `mappingSource` path parameter. +* 400: Invalid `regex` path parameter. + +=== Address Mappings + +You can use *webadmin* to define address mappings. + +When a specific email is sent to the base mail address, every +destination addresses will receive it. + +This feature uses xref:{xref-base}/architecture/index.adoc#_recipient_rewrite_tables[Recipients rewrite table] +and requires the +https://github.com/apache/james-project/blob/master/server/mailet/mailets/src/main/java/org/apache/james/transport/mailets/RecipientRewriteTable.java[RecipientRewriteTable +mailet] to be configured. + +Note that email addresses are restricted to ASCII character set. Mail +addresses not matching this criteria will be rejected. + +Please use address mappings with caution, as it’s not a typed address. +If you know the type of your address (forward, alias, domain, group, +etc), prefer using the corresponding routes to those types. + +Here are the following actions available on address mappings: + +==== Add an address mapping + +.... +curl -XPOST http://ip:port/mappings/address/{mappingSource}/targets/{destinationAddress} +.... + +Add an address mapping to the Recipients rewrite table +Mapping source is the value of \{mappingSource} Mapping destination is +the value of \{destinationAddress} Type of mapping destination is +Address + +Response codes: + +* 204: Action successfully performed +* 400: Invalid parameters +* 409: The addition of the address mapping would lead to a loop and thus cannot be performed + +==== Remove an address mapping + +.... +curl -XDELETE http://ip:port/mappings/address/{mappingSource}/targets/{destinationAddress} +.... + +* Remove an address mapping from the Recipients rewrite table +* Mapping source is the value of `mappingSource` +* Mapping destination is the value of `destinationAddress` +* Type of mapping destination is Address + +Response codes: + +* 204: Action successfully performed +* 400: Invalid parameters + +=== List all mappings + +.... +curl -XGET http://ip:port/mappings +.... + +Get all mappings from the +xref:{xref-base}/architecture/index.adoc#_recipient_rewrite_tables[Recipients rewrite table]. + +Response body: + +.... +{ + "alias@domain.tld": [ + { + "type": "Alias", + "mapping": "user@domain.tld" + }, + { + "type": "Group", + "mapping": "group-user@domain.tld" + } + ], + "aliasdomain.tld": [ + { + "type": "Domain", + "mapping": "realdomain.tld" + } + ], + "group@domain.tld": [ + { + "type": "Address", + "mapping": "user@domain.tld" + } + ] +} +.... + +Response code: + +* 200: OK + +=== Listing User Mappings + +This endpoint allows receiving all mappings of a corresponding user. + +.... +curl -XGET http://ip:port/mappings/user/{userAddress} +.... + +Return all mappings of a user where: + +* `userAddress`: is the selected user + +Response body: + +.... +[ + { + "type": "Address", + "mapping": "user123@domain.tld" + }, + { + "type": "Alias", + "mapping": "aliasuser123@domain.tld" + }, + { + "type": "Group", + "mapping": "group123@domain.tld" + } +] +.... + +Response codes: + +* 200: OK +* 400: Invalid parameter value + +== Administrating mail repositories + +=== Create a mail repository + +.... +curl -XPUT http://ip:port/mailRepositories/{encodedPathOfTheRepository}?protocol={someProtocol} +.... + +Resource name `encodedPathOfTheRepository` should be the resource path +of the created mail repository. Example: + +.... +curl -XPUT http://ip:port/mailRepositories/mailRepo?protocol=file +.... + +Response codes: + +* 204: The repository is created + +=== Listing mail repositories + +.... +curl -XGET http://ip:port/mailRepositories +.... + +The answer looks like: + +.... +[ + { + "repository": "var/mail/error/", + "path": "var%2Fmail%2Ferror%2F" + }, + { + "repository": "var/mail/relay-denied/", + "path": "var%2Fmail%2Frelay-denied%2F" + }, + { + "repository": "var/mail/spam/", + "path": "var%2Fmail%2Fspam%2F" + }, + { + "repository": "var/mail/address-error/", + "path": "var%2Fmail%2Faddress-error%2F" + } +] +.... + +You can use `id`, the encoded URL of the repository, to access it in +later requests. + +Response codes: + +* 200: The list of mail repositories + +=== Getting additional information for a mail repository + +.... +curl -XGET http://ip:port/mailRepositories/{encodedPathOfTheRepository} +.... + +Resource name `encodedPathOfTheRepository` should be the resource path +of an existing mail repository. Example: + +.... +curl -XGET http://ip:port/mailRepositories/var%2Fmail%2Ferror%2F +.... + +The answer looks like: + +.... +{ + "repository": "var/mail/error/", + "path": "mail%2Ferror%2F", + "size": 243 +} +.... + +Response codes: + +* 200: Additonnal information for that repository +* 404: This repository can not be found + +=== Listing mails contained in a mail repository + +.... +curl -XGET http://ip:port/mailRepositories/{encodedPathOfTheRepository}/mails +.... + +Resource name `encodedPathOfTheRepository` should be the resource path +of an existing mail repository. Example: + +.... +curl -XGET http://ip:port/mailRepositories/var%2Fmail%2Ferror%2F/mails +.... + +The answer will contains all mailKey contained in that repository. + +.... +[ + "mail-key-1", + "mail-key-2", + "mail-key-3" +] +.... + +Note that this can be used to read mail details. + +You can pass additional URL parameters to this call in order to limit +the output: - A limit: no more elements than the specified limit will be +returned. This needs to be strictly positive. If no value is specified, +no limit will be applied. - An offset: allow to skip elements. This +needs to be positive. Default value is zero. + +Example: + +.... +curl -XGET 'http://ip:port/mailRepositories/var%2Fmail%2Ferror%2F/mails?limit=100&offset=500' +.... + +Response codes: + +* 200: The list of mail keys contained in that mail repository +* 400: Invalid parameters +* 404: This repository can not be found + +=== Reading/downloading a mail details + +.... +curl -XGET http://ip:port/mailRepositories/{encodedPathOfTheRepository}/mails/mailKey +.... + +Resource name `encodedPathOfTheRepository` should be the resource path +of an existing mail repository. Resource name `mailKey` should be the +key of a mail stored in that repository. Example: + +.... +curl -XGET http://ip:port/mailRepositories/var%2Fmail%2Ferror%2F/mails/mail-key-1 +.... + +If the Accept header in the request is ``application/json'', then the +response looks like: + +.... +{ + "name": "mail-key-1", + "sender": "sender@domain.com", + "recipients": ["recipient1@domain.com", "recipient2@domain.com"], + "state": "address-error", + "error": "A small message explaining what happened to that mail...", + "remoteHost": "111.222.333.444", + "remoteAddr": "127.0.0.1", + "lastUpdated": null +} +.... + +If the Accept header in the request is ``message/rfc822'', then the +response will be the _eml_ file itself. + +Additional query parameter `additionalFields` add the existing +information to the response for the supported values (only work with +``application/json'' Accept header): + +* attributes +* headers +* textBody +* htmlBody +* messageSize +* perRecipientsHeaders + +.... +curl -XGET http://ip:port/mailRepositories/file%3A%2F%2Fvar%2Fmail%2Ferror%2F/mails/mail-key-1?additionalFields=attributes,headers,textBody,htmlBody,messageSize,perRecipientsHeaders +.... + +Give the following kind of response: + +.... +{ + "name": "mail-key-1", + "sender": "sender@domain.com", + "recipients": ["recipient1@domain.com", "recipient2@domain.com"], + "state": "address-error", + "error": "A small message explaining what happened to that mail...", + "remoteHost": "111.222.333.444", + "remoteAddr": "127.0.0.1", + "lastUpdated": null, + "attributes": { + "name2": "value2", + "name1": "value1" + }, + "perRecipientsHeaders": { + "third@party": { + "headerName1": [ + "value1", + "value2" + ], + "headerName2": [ + "value3", + "value4" + ] + } + }, + "headers": { + "headerName4": [ + "value6", + "value7" + ], + "headerName3": [ + "value5", + "value8" + ] + }, + "textBody": "My body!!", + "htmlBody": "My body!!", + "messageSize": 42424242 +} +.... + +Response codes: + +* 200: Details of the mail +* 404: This repository or mail can not be found + +=== Removing a mail from a mail repository + +.... +curl -XDELETE http://ip:port/mailRepositories/{encodedPathOfTheRepository}/mails/mailKey +.... + +Resource name `encodedPathOfTheRepository` should be the resource path +of an existing mail repository. Resource name `mailKey` should be the +key of a mail stored in that repository. Example: + +.... +curl -XDELETE http://ip:port/mailRepositories/var%2Fmail%2Ferror%2F/mails/mail-key-1 +.... + +Response codes: + +* 204: This mail no longer exists in this repository +* 404: This repository can not be found + +=== Removing all mails from a mail repository + +.... +curl -XDELETE http://ip:port/mailRepositories/{encodedPathOfTheRepository}/mails +.... + +Resource name `encodedPathOfTheRepository` should be the resource path +of an existing mail repository. Example: + +.... +curl -XDELETE http://ip:port/mailRepositories/var%2Fmail%2Ferror%2F/mails +.... + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +Response codes: + +* 201: Task generation succeeded. Corresponding task id is returned. +* 404: Could not find that mail repository + +The scheduled task will have the following type `clear-mail-repository` +and the following `additionalInformation`: + +.... +{ + "mailRepositoryPath":"var/mail/error/", + "initialCount": 243, + "remainingCount": 17 +} +.... + +=== Reprocessing mails from a mail repository + +Sometime, you want to re-process emails stored in a mail repository. For +instance, you can make a configuration error, or there can be a James +bug that makes processing of some mails fail. Those mail will be stored +in a mail repository. Once you solved the problem, you can reprocess +them. + +To reprocess mails from a repository: + +.... +curl -XPATCH http://ip:port/mailRepositories/{encodedPathOfTheRepository}/mails?action=reprocess +.... + +Resource name `encodedPathOfTheRepository` should be the resource path +of an existing mail repository. Example: + +For instance: + +.... +curl -XPATCH http://ip:port/mailRepositories/var%2Fmail%2Ferror%2F/mails?action=reprocess +.... + +Additional query parameters are supported: + +- `queue` allows you to +target the mail queue you want to enqueue the mails in. Defaults to +`spool`. +- `processor` allows you to overwrite the state of the +reprocessing mails, and thus select the processors they will start their +processing in. Defaults to the `state` field of each processed email. +- `consume` (boolean defaulting to `true`) whether the reprocessing should consume the mail in its originating mail repository. Passing +this value to `false` allows non destructive reprocessing as you keep a copy of the email in the mail repository and can be valuable +when debugging. +- `limit` (integer value. Optional, default is empty). It enables to limit the count of elements reprocessed. +If unspecified the count of the processed elements is unbounded. +- `maxRetries` Optional integer, defaults to no max retries limit. Only processed emails that had been retried less +than this value. Ignored by default. + +redeliver_group_events + +.... +curl -XPATCH 'http://ip:port/mailRepositories/var%2Fmail%2Ferror%2F/mails?action=reprocess&processor=transport&queue=spool' +.... + +Note that the `action` query parameter is compulsary and can only take +value `reprocess`. + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +Response codes: + +* 201: Task generation succeeded. Corresponding task id is returned. +* 404: Could not find that mail repository + +The scheduled task will have the following type `reprocessing-all` and +the following `additionalInformation`: + +.... +{ + "mailRepositoryPath":"var/mail/error/", + "targetQueue":"spool", + "targetProcessor":"transport", + "initialCount": 243, + "remainingCount": 17 +} +.... + +=== Reprocessing a specific mail from a mail repository + +To reprocess a specific mail from a mail repository: + +.... +curl -XPATCH http://ip:port/mailRepositories/{encodedPathOfTheRepository}/mails/mailKey?action=reprocess +.... + +Resource name `encodedPathOfTheRepository` should be the resource id of +an existing mail repository. Resource name `mailKey` should be the key +of a mail stored in that repository. Example: + +For instance: + +.... +curl -XPATCH http://ip:port/mailRepositories/var%2Fmail%2Ferror%2F/mails/name1?action=reprocess +.... + +Additional query parameters are supported: + +- `queue` allows you to +target the mail queue you want to enqueue the mails in. Defaults to +`spool`. +- `processor` allows you to overwrite the state of the +reprocessing mails, and thus select the processors they will start their +processing in. Defaults to the `state` field of each processed email. +- `consume` (boolean defaulting to `true`) whether the reprocessing should consume the mail in its originating mail repository. Passing +this value to `false` allows non destructive reprocessing as you keep a copy of the email in the mail repository and can be valuable +when debugging. + +While `processor` being an optional parameter, not specifying it will +result reprocessing the mails in their current state +(https://james.apache.org/server/feature-mailetcontainer.html#Processors[see +documentation about processors and state]). Consequently, only few cases +will give a different result, definitively storing them out of the mail +repository. + +For instance: + +.... +curl -XPATCH 'http://ip:port/mailRepositories/var%2Fmail%2Ferror%2F/mails/name1?action=reprocess&processor=transport&queue=spool' +.... + +Note that the `action` query parameter is compulsary and can only take +value `reprocess`. + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +Response codes: + +* 201: Task generation succeeded. Corresponding task id is returned. +* 404: Could not find that mail repository + +The scheduled task will have the following type `reprocessing-one` and +the following `additionalInformation`: + +.... +{ + "mailRepositoryPath":"var/mail/error/", + "targetQueue":"spool", + "targetProcessor":"transport", + "mailKey":"name1" +} +.... + +== Administrating mail queues + +=== Listing mail queues + +.... +curl -XGET http://ip:port/mailQueues +.... + +The answer looks like: + +.... +["outgoing","spool"] +.... + +Response codes: + +* 200: The list of mail queues + +=== Getting a mail queue details + +.... +curl -XGET http://ip:port/mailQueues/{mailQueueName} +.... + +Resource name `mailQueueName` is the name of a mail queue, this command +will return the details of the given mail queue. For instance: + +.... +{"name":"outgoing","size":0} +.... + +Response codes: + +* 200: Success +* 400: Mail queue is not valid +* 404: The mail queue does not exist + +=== Listing the mails of a mail queue + +.... +curl -XGET http://ip:port/mailQueues/{mailQueueName}/mails +.... + +Additional URL query parameters: + +* `limit`: Maximum number of mails returned in a single call. Only +strictly positive integer values are accepted. Example: + +.... +curl -XGET http://ip:port/mailQueues/{mailQueueName}/mails?limit=100 +.... + +The answer looks like: + +.... +[{ + "name": "Mail1516976156284-8b3093b9-eebf-4c40-9c26-1450f4fcdc3c-to-test.com", + "sender": "user@james.linagora.com", + "recipients": ["someone@test.com"], + "nextDelivery": "1969-12-31T23:59:59.999Z" +}] +.... + +Response codes: + +* 200: Success +* 400: Mail queue is not valid or limit is invalid +* 404: The mail queue does not exist + +=== Deleting mails from a mail queue + +.... +curl -XDELETE http://ip:port/mailQueues/{mailQueueName}/mails?sender=senderMailAddress +.... + +This request should have exactly one query parameter from the following +list: + +* sender: which is a mail address (i.e. sender@james.org) +* name: which is a string +* recipient: which is a mail address (i.e. recipient@james.org) + +The mails from the given mail queue matching the query parameter will be +deleted. + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +Response codes: + +* 201: Task generation succeeded. Corresponding task id is returned. +* 400: Invalid request +* 404: The mail queue does not exist + +The scheduled task will have the following type +`delete-mails-from-mail-queue` and the following +`additionalInformation`: + +.... +{ + "queue":"outgoing", + "initialCount":10, + "remainingCount": 5, + "sender": "sender@james.org", + "name": "Java Developer", + "recipient: "recipient@james.org" +} +.... + +=== Clearing a mail queue + +.... +curl -XDELETE http://ip:port/mailQueues/{mailQueueName}/mails +.... + +All mails from the given mail queue will be deleted. + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +Response codes: + +* 201: Task generation succeeded. Corresponding task id is returned. +* 400: Invalid request +* 404: The mail queue does not exist + +The scheduled task will have the following type `clear-mail-queue` and +the following `additionalInformation`: + +.... +{ + "queue":"outgoing", + "initialCount":10, + "remainingCount": 0 +} +.... + +=== Flushing mails from a mail queue + +.... +curl -XPATCH http://ip:port/mailQueues/{mailQueueName}?delayed=true \ + -d '{"delayed": false}' \ + -H "Content-Type: application/json" +.... + +This request should have the query parameter _delayed_ set to _true_, in +order to indicate only delayed mails are affected. The payload should +set the `delayed` field to false inorder to remove the delay. This is +the only supported combination, and it performs a flush. + +The mails delayed in the given mail queue will be flushed. + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +Response codes: + +* 204: Success (No content) +* 400: Invalid request +* 404: The mail queue does not exist + +=== RabbitMQ republishing a mail queue from {backend-name} + +.... +curl -XPOST 'http://ip:port/mailQueues/{mailQueueName}?action=RepublishNotProcessedMails&olderThan=1d' +.... + +This method is specific to the distributed flavor of James, which relies +on {backend-name} and RabbitMQ for implementing a mail queue. In case of a +RabbitMQ crash resulting in a loss of messages, this task can be +launched to repopulate the `mailQueueName` queue in RabbitMQ using the +information stored in {backend-name}. + +The `olderThan` parameter is mandatory. It filters the mails to be +restored, by taking into account only the mails older than the given +value. The expected value should be expressed in the following format: +`Nunit`. `N` should be strictly positive. `unit` could be either in the +short form (`h`, `d`, `w`, etc.), or in the long form (`day`, `week`, +`month`, etc.). + +Examples: + +* `5h` +* `7d` +* `1y` + +Response codes: + +* 201: Task created +* 400: Invalid request + +The response body contains the id of the republishing task. +`{ "taskId": "a650a66a-5984-431e-bdad-f1baad885856" }` + +include::{admin-mail-queues-extend}[] + +== Sending email over webAdmin + +.... +curl -XPOST /mail-transfer-service + +{MIME message} +.... + +Will send the following email to the recipients specified in the MIME message. + +The `{MIME message}` payload must match `message/rfc822` format. + +== Event Dead Letter + +The EventBus allows to register `group listeners' that are called in a +distributed fashion. These group listeners enable the implementation of +some advanced mailbox manager feature like indexing, spam reporting, +quota management and the like. + +Upon exceptions, a bounded number of retries are performed (with +exponential backoff delays). If after those retries the listener is +still failing, then the event will be stored in the ``Event Dead +Letter''. This API allows diagnosing issues, as well as performing event +replay. + +=== Listing mailbox listener groups + +This endpoint allows discovering the list of mailbox listener groups. + +.... +curl -XGET http://ip:port/events/deadLetter/groups +.... + +Will return a list of group names that can be further used to interact +with the dead letter API: + +.... +["org.apache.james.mailbox.events.EventBusTestFixture$GroupA", "org.apache.james.mailbox.events.GenericGroup-abc"] +.... + +Response codes: + +* 200: Success. A list of group names is returned. + +=== Listing failed events + +This endpoint allows listing failed events for a given group: + +.... +curl -XGET http://ip:port/events/deadLetter/groups/org.apache.james.mailbox.events.EventBusTestFixture$GroupA +.... + +Will return a list of insertionIds: + +.... +["6e0dd59d-660e-4d9b-b22f-0354479f47b4", "58a8f59d-660e-4d9b-b22f-0354486322a2"] +.... + +Response codes: + +* 200: Success. A list of insertion ids is returned. +* 400: Invalid group name + +=== Getting event details + +.... +curl -XGET http://ip:port/events/deadLetter/groups/org.apache.james.mailbox.events.EventBusTestFixture$GroupA/6e0dd59d-660e-4d9b-b22f-0354479f47b4 +.... + +Will return the full JSON associated with this event. + +Response codes: + +* 200: Success. A JSON representing this event is returned. +* 400: Invalid group name or `insertionId` +* 404: No event with this `insertionId` + +=== Deleting an event + +.... +curl -XDELETE http://ip:port/events/deadLetter/groups/org.apache.james.mailbox.events.EventBusTestFixture$GroupA/6e0dd59d-660e-4d9b-b22f-0354479f47b4 +.... + +Will delete this event. + +Response codes: + +* 204: Success +* 400: Invalid group name or `insertionId` + +=== Deleting all events of a group + +.... +curl -XDELETE http://ip:port/events/deadLetter/groups/org.apache.james.mailbox.events.EventBusTestFixture$GroupA +.... + +Will delete all events of this group. + +Response codes: + +* 204: Success +* 400: Invalid group name + +=== Redeliver all events + +.... +curl -XPOST http://ip:port/events/deadLetter?action=reDeliver +.... + +Additional query parameters are supported: + +- `limit` (integer value. Optional, default is empty). It enables to limit the count of elements redelivered. +If unspecified the count of the processed elements is unbounded + +For instance: + +.... +curl -XPOST http://ip:port/events/deadLetter?action=reDeliver&limit=10 +.... + +Will create a task that will attempt to redeliver all events stored in +``Event Dead Letter''. If successful, redelivered events will then be +removed from ``Dead Letter''. + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +Response codes: + +* 201: the taskId of the created task +* 400: Invalid action argument + +=== Redeliver group events + +.... +curl -XPOST http://ip:port/events/deadLetter/groups/org.apache.james.mailbox.events.EventBusTestFixture$GroupA?action=reDeliver +.... + +Will create a task that will attempt to redeliver all events of a +particular group stored in ``Event Dead Letter''. If successful, +redelivered events will then be removed from ``Dead Letter''. + +Additional query parameters are supported: + +- `limit` (integer value. Optional, default is empty). It enables to limit the count of elements redelivered. +If unspecified the count of the processed elements is unbounded + +For instance: + +.... +curl -XPOST http://ip:port/events/deadLetter/groups/org.apache.james.mailbox.events.EventBusTestFixture$GroupA?action=reDeliver&limit=10 +.... + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +Response codes: + +* 201: the taskId of the created task +* 400: Invalid group name or action argument + +=== Redeliver a single event + +.... +curl -XPOST http://ip:port/events/deadLetter/groups/org.apache.james.mailbox.events.EventBusTestFixture$GroupA/6e0dd59d-660e-4d9b-b22f-0354479f47b4?action=reDeliver +.... + +Will create a task that will attempt to redeliver a single event of a +particular group stored in ``Event Dead Letter''. If successful, +redelivered event will then be removed from ``Dead Letter''. + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +Response codes: + +* 201: the taskId of the created task +* 400: Invalid group name, insertion id or action argument +* 404: No event with this insertionId + +== Deleted Messages Vault + +The `Deleted Message Vault plugin' allows you to keep users deleted +messages during a given retention time. This set of routes allow you to +_restore_ users deleted messages or export them in an archive. + +To move deleted messages in the vault, you need to specifically +configure the DeletedMessageVault PreDeletionHook. + +=== Restore Deleted Messages + +Deleted messages of a specific user can be restored by calling the +following endpoint: + +.... +curl -XPOST http://ip:port/deletedMessages/users/userToRestore@domain.ext?action=restore + +{ + "combinator": "and", + "criteria": [ + { + "fieldName": "subject", + "operator": "containsIgnoreCase", + "value": "Apache James" + }, + { + "fieldName": "deliveryDate", + "operator": "beforeOrEquals", + "value": "2014-10-30T14:12:00Z" + }, + { + "fieldName": "deletionDate", + "operator": "afterOrEquals", + "value": "2015-10-20T09:08:00Z" + }, + { + "fieldName": "recipients"," + "operator": "contains"," + "value": "recipient@james.org" + }, + { + "fieldName": "hasAttachment", + "operator": "equals", + "value": "false" + }, + { + "fieldName": "sender", + "operator": "equals", + "value": "sender@apache.org" + }, + { + "fieldName": "originMailboxes", + "operator": "contains", + "value": "02874f7c-d10e-102f-acda-0015176f7922" + } + ] +}; +.... + +The requested Json body is made from a list of criterion objects which +have the following structure: + +.... +{ + "fieldName": "supportedFieldName", + "operator": "supportedOperator", + "value": "A plain string representing the matching value of the corresponding field" +} +.... + +Deleted Messages which are matched with the *all* criterion in the query +body will be restored. Here are a list of supported fieldName for the +restoring: + +* subject: represents for deleted message `subject` field matching. +Supports below string operators: +** contains +** containsIgnoreCase +** equals +** equalsIgnoreCase +* deliveryDate: represents for deleted message `deliveryDate` field +matching. Tested value should follow the right date time with zone +offset format (ISO-8601) like `2008-09-15T15:53:00+05:00` or +`2008-09-15T15:53:00Z` Supports below date time operators: +** beforeOrEquals: is the deleted message’s `deliveryDate` before or +equals the time of tested value. +** afterOrEquals: is the deleted message’s `deliveryDate` after or +equals the time of tested value +* deletionDate: represents for deleted message `deletionDate` field +matching. Tested value & Supports operators: similar to `deliveryDate` +* sender: represents for deleted message `sender` field matching. Tested +value should be a valid mail address. Supports mail address operator: +** equals: does the tested sender equal to the sender of the tested +deleted message ? + +* recipients: represents for deleted message `recipients` field +matching. Tested value should be a valid mail address. Supports list +mail address operator: +** contains: does the tested deleted message’s recipients contain tested +recipient ? +* hasAttachment: represents for deleted message `hasAttachment` field +matching. Tested value could be `false` or `true`. Supports boolean +operator: +** equals: does the tested deleted message’s hasAttachment property +equal to the tested hasAttachment value? +* originMailboxes: represents for deleted message `originMailboxes` +field matching. Tested value is a string serialized of mailbox id. +Supports list mailbox id operators: +** contains: does the tested deleted message’s originMailbox ids contain +tested mailbox id ? + +Messages in the Deleted Messages Vault of a specified user that are +matched with Query Json Object in the body will be appended to his +`Restored-Messages' mailbox, which will be created if needed. + +*Note*: + +* Query parameter `action` is required and should have the value +`restore` to represent the restoring feature. Otherwise, a bad request +response will be returned +* Query parameter `action` is case sensitive +* fieldName & operator passed to the routes are case sensitive +* Currently, we only support query combinator `and` value, otherwise, +requests will be rejected +* If you only want to restore by only one criterion, the json body could +be simplified to a single criterion: + +.... +{ + "fieldName": "subject", + "operator": "containsIgnoreCase", + "value": "Apache James" +} +.... + +* For restoring all deleted messages, passing a query json with an empty +criterion list to represent `matching all deleted messages`: + +.... +{ + "combinator": "and", + "criteria": [] +} +.... + +* For limiting the number of restored messages, you can use the `limit` query property: + +.... +{ + "combinator": "and", + "limit": 99 + "criteria": [] +} +.... + +*Warning*: Current web-admin uses `US` locale as the default. Therefore, +there might be some conflicts when using String `containsIgnoreCase` +comparators to apply on the String data of other special locales stored +in the Vault. More details at +https://issues.apache.org/jira/browse/MAILBOX-384[JIRA] + +Response code: + +* 201: Task for restoring deleted has been created +* 400: Bad request: +** action query param is not present +** action query param is not a valid action +** user parameter is invalid +** can not parse the JSON body +** Json query object contains unsupported operator, fieldName +** Json query object values violate parsing rules +* 404: User not found + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +The scheduled task will have the following type +`deleted-messages-restore` and the following `additionalInformation`: + +.... +{ + "successfulRestoreCount": 47, + "errorRestoreCount": 0, + "user": "userToRestore@domain.ext" +} +.... + +while: + +* successfulRestoreCount: number of restored messages +* errorRestoreCount: number of messages that failed to restore +* user: owner of deleted messages need to restore + +=== Export Deleted Messages + +Retrieve deleted messages matched with requested query from an user then +share the content to a targeted mail address (exportTo) + +.... +curl -XPOST 'http://ip:port/deletedMessages/users/userExportFrom@domain.ext?action=export&exportTo=userReceiving@domain.ext' + +BODY: is the json query has the same structure with Restore Deleted Messages section +.... + +*Note*: Json query passing into the body follows the same rules & +restrictions like in link:#_restore_deleted_messages[Restore Deleted +Messages] + +Response code: + +* 201: Task for exporting has been created +* 400: Bad request: +** exportTo query param is not present +** exportTo query param is not a valid mail address +** action query param is not present +** action query param is not a valid action +** user parameter is invalid +** can not parse the JSON body +** Json query object contains unsupported operator, fieldName +** Json query object values violate parsing rules +* 404: User not found + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +The scheduled task will have the following type +`deleted-messages-export` and the following `additionalInformation`: + +.... +{ + "userExportFrom": "userToRestore@domain.ext", + "exportTo": "userReceiving@domain.ext", + "totalExportedMessages": 1432 +} +.... + +while: + +* userExportFrom: export deleted messages from this user +* exportTo: content of deleted messages have been shared to this mail +address +* totalExportedMessages: number of deleted messages match with +json query, then being shared to sharee. + +=== Purge Deleted Messages + +You can overwrite `retentionPeriod' configuration in +`deletedMessageVault' configuration file or use the default value of 1 +year. + +Purge all deleted messages older than the configured `retentionPeriod' + +.... +curl -XDELETE http://ip:port/deletedMessages?scope=expired +.... + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +Response code: + +* 201: Task for purging has been created +* 400: Bad request: +** action query param is not present +** action query param is not a valid action + +You may want to call this endpoint on a regular basis. + +=== Permanently Remove Deleted Message + +Delete a Deleted Message with `MessageId` + +.... +curl -XDELETE http://ip:port/deletedMessages/users/user@domain.ext/messages/3294a976-ce63-491e-bd52-1b6f465ed7a2 +.... + +link:#_endpoints_returning_a_task[More details about endpoints returning +a task]. + +Response code: + +* 201: Task for deleting message has been created +* 400: Bad request: +** user parameter is invalid +** messageId parameter is invalid +* 404: User not found + +The scheduled task will have the following type +`deleted-messages-delete` and the following `additionalInformation`: + +.... + { + "userName": "user@domain.ext", + "messageId": "3294a976-ce63-491e-bd52-1b6f465ed7a2" + } +.... + +while: - user: delete deleted messages from this user - deleteMessageId: +messageId of deleted messages will be delete + +== Administrating DLP Configuration + +DLP (stands for Data Leak Prevention) is supported by James. A DLP +matcher will, on incoming emails, execute regular expressions on email +sender, recipients or content, in order to report suspicious emails to +an administrator. WebAdmin can be used to manage these DLP rules on a +per `senderDomain` basis. + +`senderDomain` is domain of the sender of incoming emails, for example: +`apache.org`, `james.org`,… Each `senderDomain` correspond to a distinct +DLP configuration. + +=== List DLP configuration by sender domain + +Retrieve a DLP configuration for corresponding `senderDomain`, a +configuration contains list of configuration items + +.... +curl -XGET http://ip:port/dlp/rules/{senderDomain} +.... + +Response codes: + +* 200: A list of dlp configuration items is returned +* 400: Invalid `senderDomain` or payload in request +* 404: The domain does not exist. + +This is an example of returned body. The rules field is a list of rules +as described below. + +.... +{"rules : [ + { + "id": "1", + "expression": "james.org", + "explanation": "Find senders or recipients containing james[any char]org", + "targetsSender": true, + "targetsRecipients": true, + "targetsContent": false + }, + { + "id": "2", + "expression": "Find senders containing apache[any char]org", + "explanation": "apache.org", + "targetsSender": true, + "targetsRecipients": false, + "targetsContent": false + } +]} +.... + +=== Store DLP configuration by sender domain + +Store a DLP configuration for corresponding `senderDomain`, if any item +of DLP configuration in the request is stored before, it will not be +stored anymore + +.... +curl -XPUT http://ip:port/dlp/rules/{senderDomain} +.... + +The body can contain a list of DLP configuration items formed by those +fields: - `id`(String) is mandatory, unique identifier of the +configuration item - `expression`(String) is mandatory, regular +expression to match contents of targets - `explanation`(String) is +optional, description of the configuration item - +`targetsSender`(boolean) is optional and defaults to false. If true, +`expression` will be applied to Sender and to From headers of the mail - +`targetsContent`(boolean) is optional and defaults to false. If true, +`expression` will be applied to Subject headers and textual bodies +(text/plain and text/html) of the mail - `targetsRecipients`(boolean) is +optional and defaults to false. If true, `expression` will be applied to +recipients of the mail + +This is an example of returned body. The rules field is a list of rules +as described below. + +.... +{"rules": [ + { + "id": "1", + "expression": "james.org", + "explanation": "Find senders or recipients containing james[any char]org", + "targetsSender": true, + "targetsRecipients": true, + "targetsContent": false + }, + { + "id": "2", + "expression": "Find senders containing apache[any char]org", + "explanation": "apache.org", + "targetsSender": true, + "targetsRecipients": false, + "targetsContent": false + } +]} +.... + +Response codes: + +* 204: List of dlp configuration items is stored +* 400: Invalid `senderDomain` or payload in request +* 404: The domain does not exist. + +=== Remove DLP configuration by sender domain + +Remove a DLP configuration for corresponding `senderDomain` + +.... +curl -XDELETE http://ip:port/dlp/rules/{senderDomain} +.... + +Response codes: + +* 204: DLP configuration is removed +* 400: Invalid `senderDomain` or payload in request +* 404: The domain does not exist. + +=== Fetch a DLP configuration item by sender domain and rule id + +Retrieve a DLP configuration rule for corresponding `senderDomain` and a +`ruleId` + +.... +curl -XGET http://ip:port/dlp/rules/{senderDomain}/rules/{ruleId} +.... + +Response codes: + +* 200: A dlp configuration item is returned +* 400: Invalid `senderDomain` or payload in request +* 404: The domain and/or the rule does not exist. + +This is an example of returned body. + +.... +{ + "id": "1", + "expression": "james.org", + "explanation": "Find senders or recipients containing james[any char]org", + "targetsSender": true, + "targetsRecipients": true, + "targetsContent": false +} +.... + +== Reloading server certificates + +Certificates for TCP based protocols (IMAP, SMTP, POP3, LMTP and ManageSieve) can be updated at +runtime, without service interuption and without closing existing connections. + +In order to do so: + +- Generate / retrieve your cryptographic materials and replace the ones specified in James configuration. +- Then call the following endpoint: + +.... +curl -XPOST http://ip:port/servers?reload-certificate +.... + +Optional query parameters: + +- `port`: positive integer (valid port number). Only reload certificates for the specific port. + +Return code: + +- 204: the certificate is reloaded +- 400: Invalid request. \ No newline at end of file From 26242e16c62c702a40b8a093818800661dda2596 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Fri, 30 Aug 2024 08:46:46 +0700 Subject: [PATCH 006/341] [Antora] Adapt after partial - [ANTORA] Make extending section generic Adapt commit: a7f8ded01807d47d808a0c80ce51c9079fcec57b --- docs/modules/servers/partials/configure/extensions.adoc | 2 +- docs/modules/servers/partials/configure/imap.adoc | 2 +- docs/modules/servers/partials/configure/mailetcontainer.adoc | 2 +- .../servers/partials/configure/mailrepositorystore.adoc | 2 +- docs/modules/servers/partials/configure/webadmin.adoc | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/modules/servers/partials/configure/extensions.adoc b/docs/modules/servers/partials/configure/extensions.adoc index 6c2ae7cbaa9..e26adc69ee8 100644 --- a/docs/modules/servers/partials/configure/extensions.adoc +++ b/docs/modules/servers/partials/configure/extensions.adoc @@ -57,4 +57,4 @@ Recording it in extensions.properties : guice.extension.tasks=com.project.RspamdTaskExtensionModule .... -Read xref:{pages-path}/extending/index.adoc#_defining_custom_injections_for_your_extensions[this page] for more details. \ No newline at end of file +Read xref:customization:index.adoc#_defining_custom_injections_for_your_extensions[this page] for more details. \ No newline at end of file diff --git a/docs/modules/servers/partials/configure/imap.adoc b/docs/modules/servers/partials/configure/imap.adoc index 05decc72200..ad910019124 100644 --- a/docs/modules/servers/partials/configure/imap.adoc +++ b/docs/modules/servers/partials/configure/imap.adoc @@ -153,7 +153,7 @@ It uses the Keycloak OIDC provider, but usage of similar technologies is definit == Extending IMAP -IMAP decoders, processors and encoder can be customized. xref:{pages-path}/extending/imap.adoc[Read more]. +IMAP decoders, processors and encoder can be customized. xref:customization:imap.adoc[Read more]. Check this link:https://github.com/apache/james-project/tree/master/examples/custom-imap[example]. diff --git a/docs/modules/servers/partials/configure/mailetcontainer.adoc b/docs/modules/servers/partials/configure/mailetcontainer.adoc index a3e7c56e29a..18ef8a5aee2 100644 --- a/docs/modules/servers/partials/configure/mailetcontainer.adoc +++ b/docs/modules/servers/partials/configure/mailetcontainer.adoc @@ -7,7 +7,7 @@ xref:{pages-path}/architecture/index.adoc#_mail_processing[the mailet container Apache James Server includes a number of xref:{pages-path}/configure/mailets.adoc[Packaged Mailets] and xref:{pages-path}/configure/matchers.adoc[Packaged Matchers]. -Furthermore, you can write and use with James xref:{pages-path}/extending/mail-processing.adoc[your own mailet and matchers]. +Furthermore, you can write and use with James xref:customization:mail-processing.adoc[your own mailet and matchers]. Consult this link:{sample-configuration-prefix-url}/mailetcontainer.xml[example] to get some examples and hints. diff --git a/docs/modules/servers/partials/configure/mailrepositorystore.adoc b/docs/modules/servers/partials/configure/mailrepositorystore.adoc index e0c7b88bc24..2f3589df670 100644 --- a/docs/modules/servers/partials/configure/mailrepositorystore.adoc +++ b/docs/modules/servers/partials/configure/mailrepositorystore.adoc @@ -8,7 +8,7 @@ For instance in the url `{mailet-repository-path-prefix}://var/mail/error/` `{ma The *mailrepositorystore.xml* file allows registration of available protocols, and their binding to actual MailRepository implementation. Note that extension developers can write their own MailRepository implementations, load them via the -`extensions-jars` mechanism as documented in xref:{pages-path}/extending/index.adoc['writing your own extensions'], and finally +`extensions-jars` mechanism as documented in xref:customization:index.adoc['writing your own extensions'], and finally associated to a protocol in *mailrepositorystore.xml* for a usage in *mailetcontainer.xml*. == Configuration diff --git a/docs/modules/servers/partials/configure/webadmin.adoc b/docs/modules/servers/partials/configure/webadmin.adoc index 61da6ed21fa..6a9a6fb79c9 100644 --- a/docs/modules/servers/partials/configure/webadmin.adoc +++ b/docs/modules/servers/partials/configure/webadmin.adoc @@ -65,7 +65,7 @@ Defaults to the `jwt.publickeypem.url` value of `jmap.properties` file if unspec | extensions.routes | List of Routes specified as fully qualified class name that should be loaded in addition to your product routes list. Routes needs to be on the classpath or in the ./extensions-jars folder. Read mode about -xref:{pages-path}/extending/webadmin-routes.adoc[creating you own webadmin routes]. +xref:customization:webadmin-routes.adoc[creating you own webadmin routes]. | maxThreadCount | Maximum threads used by the underlying Jetty server. Optional. @@ -101,4 +101,4 @@ The public key can be referenced as `jwt.publickeypem.url` of the `jmap.properti WebAdmin adds the value of `X-Real-IP` header as part of the logging MDC. -This allows for reverse proxies to cary other the IP address of the client down to the JMAP server for diagnostic purpose. \ No newline at end of file +This allows for reverse proxies to cary other the IP address of the client down to the JMAP server for diagnostic purpose. From f981c14ef3ea0792d1b735ad2a7dd80ffa07f7da Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Fri, 30 Aug 2024 08:46:54 +0700 Subject: [PATCH 007/341] [Antora] Adapt after partial - JAMES-4048 Expose unboindid basic pool setup Adapt commit: a96615b9a60bfa4a4bc34fc30c01d64289d4d0f0 --- .../servers/partials/configure/usersrepository.adoc | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/modules/servers/partials/configure/usersrepository.adoc b/docs/modules/servers/partials/configure/usersrepository.adoc index e8d40e67a6a..390a772528d 100644 --- a/docs/modules/servers/partials/configure/usersrepository.adoc +++ b/docs/modules/servers/partials/configure/usersrepository.adoc @@ -124,3 +124,15 @@ for instance in conjunction with "resolveLocalPartAttribute". This can also be u disactivated users (in "userListBase" but not in "userBase"). Note that "userListBase" can not be specified on a per-domain-basis. + +=== LDAP connection pool size tuning + +Apache James offers some options for configuring the LDAP connection pool used by unboundid: + +* *poolSize*: (optional, default = 4) The maximum number of connection in the pool. Note that if the pool is exhausted, +extra connections will be created on the fly as needed. +* *maxWaitTime*: (optional, default = 1000) the number of milli seconds to wait before creating off-pool connections, +using a pool connection if released in time. This effectively smooth out traffic burst, thus in some case can help +not overloading the LDAP +* *connectionTimeout:* (optional) Sets the connection timeout on the underlying to the specified integer value +* *readTimeout:* (optional) Sets property the read timeout to the specified integer value. From 096a38c177318ad14d8a875c4477212f1de1c6f2 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Fri, 30 Aug 2024 08:46:59 +0700 Subject: [PATCH 008/341] [Antora] Adapt after partial - JAMES-4052 Document OpenSearch dashboard set up (#2360) Adapt commit: 8fefca2226fdd1843eb7f84477430249568c563f --- docs/modules/servers/partials/operate/index.adoc | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/modules/servers/partials/operate/index.adoc b/docs/modules/servers/partials/operate/index.adoc index 32127a3910a..e3b5ec8b67c 100644 --- a/docs/modules/servers/partials/operate/index.adoc +++ b/docs/modules/servers/partials/operate/index.adoc @@ -19,4 +19,6 @@ graphs, that can be visualized, for instance in *Grafana*. We did put together a xref:{xref-base}/operate/guide.adoc[detailed guide] for {server-tag} James operators. We also propose a xref:{xref-base}/operate/performanceChecklist.adoc[performance checklist]. -We also included a guide for xref:{xref-base}/operate/migrating.adoc[migrating existing data] into the {server-tag} server. \ No newline at end of file +We also included a guide for xref:{xref-base}/operate/migrating.adoc[migrating existing data] into the {server-tag} server. + +Additional functional visualisations can be set up using OpenSearch dashboards as documented in link:https://github.com/apache/james-project/tree/master/examples/opensearch-dahsboard[this example]. From c021fdda341f021423219b7b4ced9f01f299799c Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Fri, 30 Aug 2024 08:47:06 +0700 Subject: [PATCH 009/341] [Antora] Adapt after partial - Add missing link in run with docker page to james cli commands documentation page --- docs/modules/servers/pages/distributed/run/run-docker.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/modules/servers/pages/distributed/run/run-docker.adoc b/docs/modules/servers/pages/distributed/run/run-docker.adoc index 986821b29c0..c5953537377 100644 --- a/docs/modules/servers/pages/distributed/run/run-docker.adoc +++ b/docs/modules/servers/pages/distributed/run/run-docker.adoc @@ -28,7 +28,7 @@ A default domain, james.local, has been created. You can see this by running: James will respond to IMAP port 143 and SMTP port 25. You have to create users before playing with james. You may also want to create other domains. -Follow the 'Useful commands' section for more information about James CLI. +Follow the xref:distributed/operate/cli.adoc['Useful commands'] section for more information about James CLI. == Run with docker From fc549ff3b8646d99604622997a78a69bf79b29c1 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Fri, 30 Aug 2024 08:47:09 +0700 Subject: [PATCH 010/341] [Antora] Adapt after partial - JAMES-3824 SMTP Extension for Message Transfer Priorities Adapt commit: 57ccc5a1882d9ecdb2b0497b14c751dddf42869c --- .../architecture/implemented-standards.adoc | 2 +- .../partials/configure/smtp-hooks.adoc | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/modules/servers/partials/architecture/implemented-standards.adoc b/docs/modules/servers/partials/architecture/implemented-standards.adoc index 707c1bcc7aa..5a338bc06e1 100644 --- a/docs/modules/servers/partials/architecture/implemented-standards.adoc +++ b/docs/modules/servers/partials/architecture/implemented-standards.adoc @@ -34,7 +34,7 @@ This page details standards implemented by the {server-name}. - link:https://datatracker.ietf.org/doc/html/rfc2142[RFC-2142] Mailbox Names For Common Services, Roles And Functions - link:https://datatracker.ietf.org/doc/html/rfc2197[RFC-2197] SMTP Service Extension for Command Pipelining - link:https://datatracker.ietf.org/doc/html/rfc2554[RFC-2554] ESMTP Service Extension for Authentication -- link:https://datatracker.ietf.org/doc/html/rfc1893[RFC-1893] Enhanced Mail System Status Codes +- link:https://datatracker.ietf.org/doc/rfc6710/[RFC-6710] SMTP Extension for Message Transfer Priorities == LMTP diff --git a/docs/modules/servers/partials/configure/smtp-hooks.adoc b/docs/modules/servers/partials/configure/smtp-hooks.adoc index a660051a1d6..5744849845a 100644 --- a/docs/modules/servers/partials/configure/smtp-hooks.adoc +++ b/docs/modules/servers/partials/configure/smtp-hooks.adoc @@ -329,6 +329,25 @@ The {server-name} has optional support for FUTURERELEASE (link:https://www.rfc-e .... +== Message Transfer Priorities hooks + +The Distributed server has optional support for SMTP Extension for Message Transfer Priorities (link:https://www.rfc-editor.org/rfc/rfc6710.html[RFC-6710]) + +The SMTP server changes the priority of the message from -9 to -1 as 0 according to the implemented mail priority support policy. +The SMTP server does not allow positive priorities from unauthorized sources and sets the priority to the default value (0). + +.... + + <...> + + + + + + + +.... + == DKIM checks hooks Hook for verifying DKIM signatures of incoming mails. From 665f12ae33f58ec4fa7c71938c323dab7f0d7fc8 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Fri, 30 Aug 2024 08:47:13 +0700 Subject: [PATCH 011/341] [Antora] Adapt after partial - [DOC] Document multi-thread Redis configuration Adapt commit: 2e8fc24420c2b97dee5b15b96f43e4faab334dd1 --- .../servers/partials/configure/redis.adoc | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/modules/servers/partials/configure/redis.adoc b/docs/modules/servers/partials/configure/redis.adoc index 183e335b671..6b1fcfd2457 100644 --- a/docs/modules/servers/partials/configure/redis.adoc +++ b/docs/modules/servers/partials/configure/redis.adoc @@ -26,3 +26,19 @@ Reference: https://github.com/redis/lettuce/wiki/ReadFrom-Settings | redis.workerThreads | Worker threads to be using for the underlying driver. If unspecified driver defaults applies. |=== + +== Enabling Multithreading in Redis + +Redis 6 and later versions support multithreading, but by default, Redis operates as a single-threaded process. + +On a virtual machine with multiple CPU cores, you can enhance Redis performance by enabling multithreading. This can significantly improve I/O operations, particularly for workloads with high concurrency or large data volumes. + +See link:https://redis.io/docs/latest/operate/oss_and_stack/management/config-file/[THREADED I/O section]. + +Example if you have a 4 cores CPU, you can enable the following lines in the `redis.conf` file: +.... +io-threads 3 +io-threads-do-reads yes +.... + +However, if your machine has only 1 CPU core or your Redis usage is not intensive, you will not benefit from this. From 178d13316c9feefecfb2840b36d8caaf245440dc Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Fri, 30 Aug 2024 08:47:18 +0700 Subject: [PATCH 012/341] [Antora] Adapt after partial - [DOC] Document benchmark tool for Redis Adapt commit: a5feadb60b01a27b78c5d9159826e731f0582b5e --- .../servers/partials/benchmark/db-benchmark.adoc | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/modules/servers/partials/benchmark/db-benchmark.adoc b/docs/modules/servers/partials/benchmark/db-benchmark.adoc index fca15401e95..ab7a7abd5c6 100644 --- a/docs/modules/servers/partials/benchmark/db-benchmark.adoc +++ b/docs/modules/servers/partials/benchmark/db-benchmark.adoc @@ -359,4 +359,15 @@ Download performance with 8 MB objects (b2-30) We believe that the actual OVH Swift S3' throughput should be at least about 100 MB/s. This was not fully achieved due to network limitations of the client machine performing the benchmark. +=== Benchmark Redis +==== Benchmark methodology + +We can use the built-in https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/benchmarks/[redis-benchmark utility]. + +The tool is easy to use with good documentation. Just to be sure that you specify the redis-benchmark to use multi-thread if it runs against a multi-thread Redis instance. + +Example: +``` +redis-benchmark -n 1000000 --threads 4 +``` From dd04dacf366357736bed7e883d4ceeda6db30cc5 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Fri, 30 Aug 2024 08:47:22 +0700 Subject: [PATCH 013/341] [Antora] Adapt after partial - [Antora] Update document operate/logging Adapt commit: 50ec3d5a1c62fcb235e108f346697bcc45fb15dd --- .../operate/logging/docker-compose-block.adoc | 16 ++-- .../servers/partials/operate/logging.adoc | 94 +------------------ 2 files changed, 13 insertions(+), 97 deletions(-) diff --git a/docs/modules/servers/pages/distributed/operate/logging/docker-compose-block.adoc b/docs/modules/servers/pages/distributed/operate/logging/docker-compose-block.adoc index 7d32b9146dc..77b46b433f6 100644 --- a/docs/modules/servers/pages/distributed/operate/logging/docker-compose-block.adoc +++ b/docs/modules/servers/pages/distributed/operate/logging/docker-compose-block.adoc @@ -5,7 +5,7 @@ version: "3" services: james: depends_on: - - elasticsearch + - opensearch - cassandra - rabbitmq - s3 @@ -27,8 +27,8 @@ services: - "993:993" - "8080:8000" - elasticsearch: - image: docker.elastic.co/elasticsearch/elasticsearch:7.10.2 + opensearch: + image: opensearchproject/opensearch:2.14.0 ports: - "9200:9200" environment: @@ -65,14 +65,14 @@ services: - "24224:24224" - "24224:24224/udp" depends_on: - - elasticsearch + - opensearch - kibana: - image: docker.elastic.co/kibana/kibana:7.10.2 + opensearch-dashboards: + image: opensearchproject/opensearch-dashboards:2.16.0 environment: - ELASTICSEARCH_HOSTS: http://elasticsearch:9200 + OPENSEARCH_HOSTS: http://opensearch:9200 ports: - "5601:5601" depends_on: - - elasticsearch + - opensearch ---- \ No newline at end of file diff --git a/docs/modules/servers/partials/operate/logging.adoc b/docs/modules/servers/partials/operate/logging.adoc index 4ee06a26a31..f48f35d92ae 100644 --- a/docs/modules/servers/partials/operate/logging.adoc +++ b/docs/modules/servers/partials/operate/logging.adoc @@ -27,90 +27,6 @@ link:http://logback.qos.ch/manual/configuration.html[here]. == Structured logging -=== Pushing logs to ElasticSearch - -{server-name} leverages the use of MDC in order to achieve structured logging, -and better add context to the logged information. We furthermore ship -link:https://github.com/linagora/logback-elasticsearch-appender[Logback Elasticsearch Appender] -on the classpath to easily allow direct log indexation in -link:https://www.elastic.co/elasticsearch[ElasticSearch]. - -Here is a sample `conf/logback.xml` configuration file for logback with the following -pre-requisites: - -* Logging both in an unstructured fashion on the console and in a structured fashion in ElasticSearch -* Logging ElasticSearch Log appender logs in the console - -Configuration for pushing log direct to ElasticSearch - -* Logging ElasticSearch Log appender logs in the console - -.... - - - - - true - - - - - %d{yyyy.MM.dd HH:mm:ss.SSS} %highlight([%-5level]) %logger{15} - %msg%n%rEx - false - - - - - http://elasticsearch:9200/_bulk - logs-james-%date{yyyy.MM.dd} - tester - true - host - es-error-logger - - - host - ${HOSTNAME} - false - - - severity - %level - - - thread - %thread - - - stacktrace - %ex - - - logger - %logger - - - -
    - Content-Type - application/json -
    -
    -
    - - - - - - - - - - - -
    -.... - === Using FluentBit as a log forwarder ==== Using Docker @@ -119,7 +35,7 @@ Configuration for pushing log direct to ElasticSearch Here is a sample conf/logback.xml configuration file for logback with the following pre-requisites: Logging in a structured json fashion and write to file for centralizing logging. -Centralize logging third party like FluentBit can tail from logging’s file then filter/process and put in to ElastichSearch +Centralize logging third party like FluentBit can tail from logging’s file then filter/process and put in to OpenSearch .... @@ -166,7 +82,7 @@ docker-compose: include::{docker-compose-code-block-sample}[] FluentBit config as: -the `Host elasticsearch` pointing to `elasticsearch` service in docker-compose file. +the `Host opensearch` pointing to `opensearch` service in docker-compose file. .... [SERVICE] Parsers_File /fluent-bit/etc/parsers.conf @@ -189,7 +105,7 @@ the `Host elasticsearch` pointing to `elasticsearch` service in docker-compose f [OUTPUT] Name es Match * - Host elasticsearch + Host opensearch Port 9200 Index fluentbit Logstash_Format On @@ -248,8 +164,8 @@ Here is a sample conf/logback.xml configuration file for achieving this: .... -Regarding FluentBit on Kubernetes, you need to install it as a DaemonSet. Some official template exist -with FluentBit outputting logs to ElasticSearch. For more information on how to install it, +Regarding FluentBit on Kubernetes, you need to install it as a DaemonSet. Some official template exist +with FluentBit outputting logs to OpenSearch. For more information on how to install it, with your cluster, you can look at this https://docs.fluentbit.io/manual/installation/kubernetes[documentation]. As stated by the https://docs.fluentbit.io/manual/installation/kubernetes#details[detail] of the From c73cc1206a0ad33040d02bc4b0d87bf1d18c8acc Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Fri, 30 Aug 2024 08:47:26 +0700 Subject: [PATCH 014/341] [Antora] Adapt after partial - JAMES-4057 Enable fuzziness search Adapt commit: 1f91e2af885c97d235500d1d94dd681fa42b940d --- docs/modules/servers/partials/configure/opensearch.adoc | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/modules/servers/partials/configure/opensearch.adoc b/docs/modules/servers/partials/configure/opensearch.adoc index 14b1a929b96..f4b29f6a151 100644 --- a/docs/modules/servers/partials/configure/opensearch.adoc +++ b/docs/modules/servers/partials/configure/opensearch.adoc @@ -124,6 +124,11 @@ turning off headers indexing result in non-strict compliance with the IMAP / JMA If the message is not found, it will fall back to the old behavior (The message will be indexed from the blobStore source) Default to false. +| opensearch.text.fuzziness.search +| Use fuzziness on text searches. This option helps to correct user typing mistakes and makes the result a bit more flexible. + +Default to false. + | opensearch.indexBody | Indicates if you wish to index body or not (default: true). This can be used to decrease the performance cost associated with indexing. |=== From 8e6c129343c3abf243fc2808d56eac67acb23e30 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Fri, 30 Aug 2024 08:47:30 +0700 Subject: [PATCH 015/341] [Antora] Adapt after partial - Proposal to Extend Priority Range for Mail Queues Adapt commit: 396fadcec3f7f977636fb972c2deb1f1bf5cc0b6 --- docs/modules/servers/partials/configure/smtp-hooks.adoc | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/modules/servers/partials/configure/smtp-hooks.adoc b/docs/modules/servers/partials/configure/smtp-hooks.adoc index 5744849845a..d2ba36c2718 100644 --- a/docs/modules/servers/partials/configure/smtp-hooks.adoc +++ b/docs/modules/servers/partials/configure/smtp-hooks.adoc @@ -333,7 +333,6 @@ The {server-name} has optional support for FUTURERELEASE (link:https://www.rfc-e The Distributed server has optional support for SMTP Extension for Message Transfer Priorities (link:https://www.rfc-editor.org/rfc/rfc6710.html[RFC-6710]) -The SMTP server changes the priority of the message from -9 to -1 as 0 according to the implemented mail priority support policy. The SMTP server does not allow positive priorities from unauthorized sources and sets the priority to the default value (0). .... From 004da42b9ca2c7ea6597144c44235de510091c0d Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Fri, 30 Aug 2024 08:47:34 +0700 Subject: [PATCH 016/341] [Antora] Adapt after partial - Add missing doc and options for the deleted message vault Adapt commit: d0aa047de30d5683b87b9ca21b9de448cc077a55 --- docs/modules/servers/partials/configure/vault.adoc | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/modules/servers/partials/configure/vault.adoc b/docs/modules/servers/partials/configure/vault.adoc index b631222e748..89496861750 100644 --- a/docs/modules/servers/partials/configure/vault.adoc +++ b/docs/modules/servers/partials/configure/vault.adoc @@ -21,6 +21,12 @@ to get some examples and hints. |=== | Property name | explanation +| enabled +| Allows to enable or disable usage of the Deleted Message Vault. Default to false. + +| workQueueEnabled +| Enable work queue to be used with deleted message vault. Default to false. + | retentionPeriod | Deleted messages stored in the Deleted Messages Vault are expired after this period (default: 1 year). It can be expressed in *y* years, *d* days, *h* hours, ... From 4f136dd0378c83144ec5e714710fe4bb94d7b574 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Fri, 30 Aug 2024 08:47:38 +0700 Subject: [PATCH 017/341] [Antora] Adapt after partial - [JAMES-4065] drop habeas warrant mark mailet Adapt commit: 6afa656b497aeb187f5331907b02c706ed7fc3bc --- docs/modules/servers/partials/configure/mailets.adoc | 2 -- docs/modules/servers/partials/configure/matchers.adoc | 2 -- 2 files changed, 4 deletions(-) diff --git a/docs/modules/servers/partials/configure/mailets.adoc b/docs/modules/servers/partials/configure/mailets.adoc index 5c20ff872a1..9c534c12748 100644 --- a/docs/modules/servers/partials/configure/mailets.adoc +++ b/docs/modules/servers/partials/configure/mailets.adoc @@ -113,8 +113,6 @@ include::partial$WithStorageDirective.adoc[] == Experimental mailets -include::partial$AddHabeasWarrantMark.adoc[] - include::partial$ClamAVScan.adoc[] include::partial$ClassifyBounce.adoc[] diff --git a/docs/modules/servers/partials/configure/matchers.adoc b/docs/modules/servers/partials/configure/matchers.adoc index a7e7526a512..8d7915949cd 100644 --- a/docs/modules/servers/partials/configure/matchers.adoc +++ b/docs/modules/servers/partials/configure/matchers.adoc @@ -119,8 +119,6 @@ include::partial$CompareNumericHeaderValue.adoc[] include::partial$FileRegexMatcher.adoc[] -include::partial$HasHabeasWarrantMark.adoc[] - include::partial$InSpammerBlacklist.adoc[] include::partial$NESSpamCheck.adoc[] From 91ae0b7d415a3018f5776f1a307e29516927ee3e Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Fri, 30 Aug 2024 08:47:41 +0700 Subject: [PATCH 018/341] [Antora] Adapt after partial - [DOC] Minor fixes to consistency-model.adoc (#2368) Adapt commit: fe339838bdd548938ff01c83f92d17a6bd687200 --- ...consistency_model_data_replication_extend.adoc | 4 +++- .../partials/architecture/consistency-model.adoc | 15 ++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/modules/servers/pages/distributed/architecture/consistency_model_data_replication_extend.adoc b/docs/modules/servers/pages/distributed/architecture/consistency_model_data_replication_extend.adoc index d6e3cee9159..08ac3316c5a 100644 --- a/docs/modules/servers/pages/distributed/architecture/consistency_model_data_replication_extend.adoc +++ b/docs/modules/servers/pages/distributed/architecture/consistency_model_data_replication_extend.adoc @@ -12,7 +12,9 @@ link:https://cassandra.apache.org/doc/latest/operating/read_repair.html[Read rep The {server-name} tries to mitigate inconsistencies by relying on link:https://docs.datastax.com/en/archived/cassandra/3.0/cassandra/dml/dmlConfigConsistency.html[QUORUM] read and write levels. -This means that a majority of replica are needed for read and write operations to be performed. +This means that a majority of replica are needed for read and write operations to be performed. This guaranty is needed +as the Mailbox is a complex datamodel with several layers of metadata, and needs "read-your-writes" guaranties that QUORUM +read+writes delivers. Critical business operations, like UID allocation, rely on strong consistency mechanisms brought by link:https://www.datastax.com/blog/2013/07/lightweight-transactions-cassandra-20[lightweight transaction]. diff --git a/docs/modules/servers/partials/architecture/consistency-model.adoc b/docs/modules/servers/partials/architecture/consistency-model.adoc index c104535b0a0..2c6ce8f1680 100644 --- a/docs/modules/servers/partials/architecture/consistency-model.adoc +++ b/docs/modules/servers/partials/architecture/consistency-model.adoc @@ -6,9 +6,11 @@ points to the tools built around it. The {server-name} relies on different storage technologies, all having their own consistency models. -These data stores replicate data in order to enforce some level of availability. We call -this process replication. By consistency, we mean the ability for all replica to hold the -same data. By availability, we mean the ability for a replica to answer a request. +These data stores replicate data in order to enforce some level of availability. + +By consistency, we mean the ability for all replica to hold the same data. + +By availability, we mean the ability for a replica to answer a request. In distributed systems, link:https://en.wikipedia.org/wiki/CAP_theorem[according to the CAP theorem], as we will necessarily encounter network partitions, then trade-offs need to be made between @@ -27,8 +29,9 @@ Be aware that data is asynchronously indexed in OpenSearch, changes will be even === RabbitMQ consistency model -The {server-name} relies out of the box on a single RabbitMQ server, thus consistency concerns -are not (yet) applicable. Availability concerns are applicable. +The {server-name} can be set up to rely on a RabbitMQ cluster. All queues can be set up in an high availability +fashion using link:https://www.rabbitmq.com/docs/quorum-queues[quorum queues] - those are replicated queues using the link:https://raft.github.io/[RAFT] consensus protocol and thus are +strongly consistent. include::{data_replication_extend}[] @@ -49,6 +52,8 @@ The primary data stores are composed of {backend-name} for metadata and Object s To ensure the data referenced in {backend-name} is pointing to a valid object in the object store, we write the object store payload first, then write the corresponding metadata in {backend-name}. +Similarly, metadata is destroyed first before the corresponding object is deleted. + Such a procedure avoids metadata pointing to un existing blobs, however might lead to some unreferenced blobs. From 682d62c2c3dc3495b8c3243b843e7a2623dcda44 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Fri, 30 Aug 2024 08:47:46 +0700 Subject: [PATCH 019/341] [Antora] Adapt after partial - JAMES-4052 Add optional user property to OpenSearch index (#2363) Adapt commit: 5ccc59b3e3582bf0d8fc86494f187e8f85ea52f8 --- docs/modules/servers/partials/configure/opensearch.adoc | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/modules/servers/partials/configure/opensearch.adoc b/docs/modules/servers/partials/configure/opensearch.adoc index f4b29f6a151..970c33550f6 100644 --- a/docs/modules/servers/partials/configure/opensearch.adoc +++ b/docs/modules/servers/partials/configure/opensearch.adoc @@ -131,6 +131,10 @@ Default to false. | opensearch.indexBody | Indicates if you wish to index body or not (default: true). This can be used to decrease the performance cost associated with indexing. + +| opensearch.indexUser +| Indicates if you wish to index user or not (default: false). This can be used to have per user reports in OpenSearch Dashboards. + |=== === Quota search From 53fcb0467a3001a24e0eebee6abdee2d3af93bd1 Mon Sep 17 00:00:00 2001 From: vttran Date: Mon, 30 Oct 2023 11:42:06 +0700 Subject: [PATCH 020/341] JAMES-2586 - Postgres - Init backend common module for postgres - artifactId: apache-james-backends-postgres --- backends-common/pom.xml | 1 + backends-common/postgres/pom.xml | 82 +++++++++++++++++++ .../postgres/utils/PostgresExecutor.java | 47 +++++++++++ .../postgres/PostgresClusterExtension.java | 67 +++++++++++++++ 4 files changed, 197 insertions(+) create mode 100644 backends-common/postgres/pom.xml create mode 100644 backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java create mode 100644 backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresClusterExtension.java diff --git a/backends-common/pom.xml b/backends-common/pom.xml index a0ae2dc5827..0f46ba17d7a 100644 --- a/backends-common/pom.xml +++ b/backends-common/pom.xml @@ -37,6 +37,7 @@ cassandra jpa opensearch + postgres pulsar rabbitmq redis diff --git a/backends-common/postgres/pom.xml b/backends-common/postgres/pom.xml new file mode 100644 index 00000000000..3ac583cc14a --- /dev/null +++ b/backends-common/postgres/pom.xml @@ -0,0 +1,82 @@ + + + + 4.0.0 + + org.apache.james + james-backends-common + 3.9.0-SNAPSHOT + + + apache-james-backends-postgres + Apache James :: Backends Common :: Postgres + + + 42.5.1 + 3.16.22 + 1.0.2.RELEASE + + + + + ${james.groupId} + james-core + + + ${james.groupId} + james-server-util + + + ${james.groupId} + testing-base + test + + + javax.inject + javax.inject + + + org.jooq + jooq + ${jooq.version} + + + org.postgresql + postgresql + ${postgresql.driver.version} + + + org.postgresql + r2dbc-postgresql + ${r2dbc.postgresql.version} + + + org.testcontainers + postgresql + 1.19.1 + test + + + org.testcontainers + testcontainers + test + + + diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java new file mode 100644 index 00000000000..f3a86d41a3d --- /dev/null +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -0,0 +1,47 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres.utils; + +import javax.inject.Inject; + +import org.jooq.DSLContext; +import org.jooq.SQLDialect; +import org.jooq.conf.Settings; +import org.jooq.impl.DSL; + +import io.r2dbc.spi.Connection; +import reactor.core.publisher.Mono; + +public class PostgresExecutor { + + private static final SQLDialect PGSQL_DIALECT = SQLDialect.POSTGRES; + private static final Settings SETTINGS = new Settings() + .withRenderFormatted(true); + private final Mono connection; + + @Inject + public PostgresExecutor(Mono connection) { + this.connection = connection; + } + + public Mono dslContext() { + return connection.map(con -> DSL.using(con, PGSQL_DIALECT, SETTINGS)); + } +} diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresClusterExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresClusterExtension.java new file mode 100644 index 00000000000..bd2be62669c --- /dev/null +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresClusterExtension.java @@ -0,0 +1,67 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres; + +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junit.jupiter.api.extension.ParameterResolver; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.PostgreSQLContainer; + +public class PostgresClusterExtension implements BeforeAllCallback, BeforeEachCallback, AfterAllCallback, AfterEachCallback, ParameterResolver { + + // TODO + private GenericContainer container = new PostgreSQLContainer("postgres:11.1"); + + @Override + public void afterAll(ExtensionContext extensionContext) throws Exception { + + } + + @Override + public void afterEach(ExtensionContext extensionContext) throws Exception { + + } + + @Override + public void beforeAll(ExtensionContext extensionContext) throws Exception { + + } + + @Override + public void beforeEach(ExtensionContext extensionContext) throws Exception { + + } + + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + return false; + } + + @Override + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + return null; + } +} From 28040da5063898b762bf9bd318525d74fd1a288a Mon Sep 17 00:00:00 2001 From: vttran Date: Mon, 30 Oct 2023 11:43:59 +0700 Subject: [PATCH 021/341] JAMES-2586 - Postgres - Init postgres mailbox module - artifactId: apache-james-mailbox-postgres - Copy from mailbox/jpa -> mailbox/postgres --- mailbox/pom.xml | 2 + mailbox/postgres/pom.xml | 181 ++++++ .../jpa/JPAAttachmentContentLoader.java | 34 + .../org/apache/james/mailbox/jpa/JPAId.java | 84 +++ .../jpa/JPAMailboxSessionMapperFactory.java | 124 ++++ .../mailbox/jpa/JPATransactionalMapper.java | 96 +++ .../mailbox/jpa/mail/JPAAnnotationMapper.java | 168 +++++ .../mailbox/jpa/mail/JPAAttachmentMapper.java | 118 ++++ .../mailbox/jpa/mail/JPAMailboxMapper.java | 240 ++++++++ .../mailbox/jpa/mail/JPAMessageMapper.java | 540 ++++++++++++++++ .../mailbox/jpa/mail/JPAModSeqProvider.java | 105 ++++ .../mailbox/jpa/mail/JPAUidProvider.java | 99 +++ .../james/mailbox/jpa/mail/MessageUtils.java | 113 ++++ .../mailbox/jpa/mail/model/JPAAttachment.java | 193 ++++++ .../mailbox/jpa/mail/model/JPAMailbox.java | 205 +++++++ .../jpa/mail/model/JPAMailboxAnnotation.java | 99 +++ .../mail/model/JPAMailboxAnnotationId.java | 62 ++ .../mailbox/jpa/mail/model/JPAProperty.java | 129 ++++ .../mailbox/jpa/mail/model/JPAUserFlag.java | 120 ++++ .../openjpa/AbstractJPAMailboxMessage.java | 579 ++++++++++++++++++ .../model/openjpa/EncryptDecryptHelper.java | 66 ++ .../openjpa/JPAEncryptedMailboxMessage.java | 112 ++++ .../mail/model/openjpa/JPAMailboxMessage.java | 126 ++++ ...PAMailboxMessageWithAttachmentStorage.java | 155 +++++ .../openjpa/JPAStreamingMailboxMessage.java | 125 ++++ .../jpa/openjpa/OpenJPAMailboxManager.java | 94 +++ .../jpa/openjpa/OpenJPAMessageFactory.java | 67 ++ .../jpa/openjpa/OpenJPAMessageManager.java | 103 ++++ .../jpa/quota/JPAPerUserMaxQuotaDAO.java | 238 +++++++ .../jpa/quota/JPAPerUserMaxQuotaManager.java | 292 +++++++++ .../jpa/quota/JpaCurrentQuotaManager.java | 131 ++++ .../jpa/quota/model/JpaCurrentQuota.java | 69 +++ .../quota/model/MaxDomainMessageCount.java | 54 ++ .../jpa/quota/model/MaxDomainStorage.java | 55 ++ .../quota/model/MaxGlobalMessageCount.java | 54 ++ .../jpa/quota/model/MaxGlobalStorage.java | 54 ++ .../jpa/quota/model/MaxUserMessageCount.java | 52 ++ .../jpa/quota/model/MaxUserStorage.java | 53 ++ .../jpa/user/JPASubscriptionMapper.java | 135 ++++ .../jpa/user/model/JPASubscription.java | 136 ++++ .../resources/META-INF/spring/mailbox-jpa.xml | 109 ++++ .../main/resources/james-database.properties | 51 ++ mailbox/postgres/src/reporting-site/site.xml | 29 + .../james/mailbox/jpa/JPAMailboxFixture.java | 85 +++ .../mailbox/jpa/JPAMailboxManagerTest.java | 80 +++ .../jpa/JPASubscriptionManagerTest.java | 72 +++ .../jpa/JpaMailboxManagerProvider.java | 87 +++ .../jpa/JpaMailboxManagerStressTest.java | 57 ++ .../jpa/mail/JPAAttachmentMapperTest.java | 102 +++ .../mailbox/jpa/mail/JPAMapperProvider.java | 122 ++++ .../JPAMessageWithAttachmentMapperTest.java | 132 ++++ .../jpa/mail/JpaAnnotationMapperTest.java | 52 ++ .../jpa/mail/JpaMailboxMapperTest.java | 90 +++ .../jpa/mail/JpaMessageMapperTest.java | 156 +++++ .../mailbox/jpa/mail/JpaMessageMoveTest.java | 42 ++ .../mailbox/jpa/mail/MessageUtilsTest.java | 105 ++++ .../mail/TransactionalAnnotationMapper.java | 86 +++ .../mail/TransactionalAttachmentMapper.java | 78 +++ .../jpa/mail/TransactionalMailboxMapper.java | 98 +++ .../jpa/mail/TransactionalMessageMapper.java | 146 +++++ .../model/openjpa/JPAMailboxMessageTest.java | 56 ++ .../JPARecomputeCurrentQuotasServiceTest.java | 148 +++++ .../jpa/quota/JPACurrentQuotaManagerTest.java | 42 ++ .../jpa/quota/JPAPerUserMaxQuotaTest.java | 41 ++ .../src/test/resources/persistence.xml | 53 ++ pom.xml | 11 + 66 files changed, 7592 insertions(+) create mode 100644 mailbox/postgres/pom.xml create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAAttachmentContentLoader.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAId.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAMailboxSessionMapperFactory.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPATransactionalMapper.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAAnnotationMapper.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAAttachmentMapper.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAMailboxMapper.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAMessageMapper.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAModSeqProvider.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAUidProvider.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/MessageUtils.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAAttachment.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAMailbox.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAMailboxAnnotation.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAMailboxAnnotationId.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAProperty.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAUserFlag.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/AbstractJPAMailboxMessage.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/EncryptDecryptHelper.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAEncryptedMailboxMessage.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAMailboxMessage.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAMailboxMessageWithAttachmentStorage.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAStreamingMailboxMessage.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMailboxManager.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMessageFactory.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMessageManager.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/JPAPerUserMaxQuotaDAO.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/JPAPerUserMaxQuotaManager.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/JpaCurrentQuotaManager.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/JpaCurrentQuota.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxDomainMessageCount.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxDomainStorage.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxGlobalMessageCount.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxGlobalStorage.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxUserMessageCount.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxUserStorage.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/JPASubscriptionMapper.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/model/JPASubscription.java create mode 100644 mailbox/postgres/src/main/resources/META-INF/spring/mailbox-jpa.xml create mode 100644 mailbox/postgres/src/main/resources/james-database.properties create mode 100644 mailbox/postgres/src/reporting-site/site.xml create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPAMailboxFixture.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPAMailboxManagerTest.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPASubscriptionManagerTest.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerProvider.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerStressTest.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JPAAttachmentMapperTest.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JPAMapperProvider.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JPAMessageWithAttachmentMapperTest.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaAnnotationMapperTest.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaMailboxMapperTest.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaMessageMapperTest.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaMessageMoveTest.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/MessageUtilsTest.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalAnnotationMapper.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalAttachmentMapper.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalMailboxMapper.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalMessageMapper.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAMailboxMessageTest.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/task/JPARecomputeCurrentQuotasServiceTest.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/quota/JPACurrentQuotaManagerTest.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/quota/JPAPerUserMaxQuotaTest.java create mode 100644 mailbox/postgres/src/test/resources/persistence.xml diff --git a/mailbox/pom.xml b/mailbox/pom.xml index e79c7da284d..629c089807e 100644 --- a/mailbox/pom.xml +++ b/mailbox/pom.xml @@ -58,6 +58,8 @@ plugin/quota-search-opensearch plugin/quota-search-scanning + postgres + scanning-search spring store diff --git a/mailbox/postgres/pom.xml b/mailbox/postgres/pom.xml new file mode 100644 index 00000000000..d63b4312817 --- /dev/null +++ b/mailbox/postgres/pom.xml @@ -0,0 +1,181 @@ + + + + 4.0.0 + + org.apache.james + apache-james-mailbox + 3.9.0-SNAPSHOT + ../pom.xml + + + apache-james-mailbox-postgres + Apache James :: Mailbox :: Postgres + + + + + + ${james.groupId} + apache-james-backends-jpa + + + ${james.groupId} + apache-james-backends-jpa + test-jar + test + + + ${james.groupId} + apache-james-backends-postgres + + + ${james.groupId} + apache-james-backends-postgres + test-jar + test + + + ${james.groupId} + apache-james-mailbox-api + + + ${james.groupId} + apache-james-mailbox-api + test-jar + test + + + ${james.groupId} + apache-james-mailbox-store + + + ${james.groupId} + apache-james-mailbox-store + test-jar + test + + + ${james.groupId} + apache-james-mailbox-tools-quota-recompute + test + + + ${james.groupId} + apache-james-mailbox-tools-quota-recompute + test-jar + test + + + ${james.groupId} + event-bus-api + test-jar + test + + + ${james.groupId} + event-bus-in-vm + test + + + ${james.groupId} + james-server-data-jpa + test + + + ${james.groupId} + james-server-testing + test + + + ${james.groupId} + james-server-util + + + ${james.groupId} + metrics-tests + test + + + ${james.groupId} + testing-base + test + + + com.sun.mail + javax.mail + + + org.apache.derby + derby + test + + + org.jasypt + jasypt + + + org.mockito + mockito-core + test + + + org.slf4j + slf4j-api + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + false + 1 + -Djava.library.path= + -javaagent:"${settings.localRepository}"/org/jacoco/org.jacoco.agent/${jacoco-maven-plugin.version}/org.jacoco.agent-${jacoco-maven-plugin.version}-runtime.jar=destfile=${basedir}/target/jacoco.exec + -Xms512m -Xmx1024m -Dopenjpa.Multithreaded=true + + + + org.apache.openjpa + openjpa-maven-plugin + ${apache.openjpa.version} + + org/apache/james/mailbox/jpa/*/model/**/*.class + org/apache/james/mailbox/jpa/mail/model/openjpa/EncryptDecryptHelper.class + true + true + ${basedir}/src/test/resources/persistence.xml + + + + enhancer + + enhance + + process-classes + + + + + + diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAAttachmentContentLoader.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAAttachmentContentLoader.java new file mode 100644 index 00000000000..fb9500b5070 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAAttachmentContentLoader.java @@ -0,0 +1,34 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa; + +import java.io.InputStream; + +import org.apache.commons.lang3.NotImplementedException; +import org.apache.james.mailbox.AttachmentContentLoader; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.model.AttachmentMetadata; + +public class JPAAttachmentContentLoader implements AttachmentContentLoader { + @Override + public InputStream load(AttachmentMetadata attachment, MailboxSession mailboxSession) { + throw new NotImplementedException("JPA doesn't support loading attachment separately from Message"); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAId.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAId.java new file mode 100644 index 00000000000..d613e016fc8 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAId.java @@ -0,0 +1,84 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.mailbox.jpa; + +import java.io.Serializable; + +import org.apache.james.mailbox.model.MailboxId; + +public class JPAId implements MailboxId, Serializable { + + public static class Factory implements MailboxId.Factory { + @Override + public JPAId fromString(String serialized) { + return of(Long.parseLong(serialized)); + } + } + + public static JPAId of(long value) { + return new JPAId(value); + } + + private final long value; + + public JPAId(long value) { + this.value = value; + } + + @Override + public String serialize() { + return String.valueOf(value); + } + + @Override + public String toString() { + return String.valueOf(value); + } + + public long getRawId() { + return value; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + (int) (value ^ (value >>> 32)); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + JPAId other = (JPAId) obj; + if (value != other.value) { + return false; + } + return true; + } + +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAMailboxSessionMapperFactory.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAMailboxSessionMapperFactory.java new file mode 100644 index 00000000000..670651b13f9 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAMailboxSessionMapperFactory.java @@ -0,0 +1,124 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.mailbox.jpa; + +import javax.inject.Inject; +import javax.persistence.EntityManager; +import javax.persistence.EntityManagerFactory; + +import org.apache.commons.lang3.NotImplementedException; +import org.apache.james.backends.jpa.EntityManagerUtils; +import org.apache.james.backends.jpa.JPAConfiguration; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.jpa.mail.JPAAnnotationMapper; +import org.apache.james.mailbox.jpa.mail.JPAAttachmentMapper; +import org.apache.james.mailbox.jpa.mail.JPAMailboxMapper; +import org.apache.james.mailbox.jpa.mail.JPAMessageMapper; +import org.apache.james.mailbox.jpa.mail.JPAModSeqProvider; +import org.apache.james.mailbox.jpa.mail.JPAUidProvider; +import org.apache.james.mailbox.jpa.user.JPASubscriptionMapper; +import org.apache.james.mailbox.store.MailboxSessionMapperFactory; +import org.apache.james.mailbox.store.mail.AnnotationMapper; +import org.apache.james.mailbox.store.mail.AttachmentMapper; +import org.apache.james.mailbox.store.mail.AttachmentMapperFactory; +import org.apache.james.mailbox.store.mail.MailboxMapper; +import org.apache.james.mailbox.store.mail.MessageIdMapper; +import org.apache.james.mailbox.store.mail.MessageMapper; +import org.apache.james.mailbox.store.mail.ModSeqProvider; +import org.apache.james.mailbox.store.mail.UidProvider; +import org.apache.james.mailbox.store.user.SubscriptionMapper; + +/** + * JPA implementation of {@link MailboxSessionMapperFactory} + * + */ +public class JPAMailboxSessionMapperFactory extends MailboxSessionMapperFactory implements AttachmentMapperFactory { + + private final EntityManagerFactory entityManagerFactory; + private final JPAUidProvider uidProvider; + private final JPAModSeqProvider modSeqProvider; + private final AttachmentMapper attachmentMapper; + private final JPAConfiguration jpaConfiguration; + + @Inject + public JPAMailboxSessionMapperFactory(EntityManagerFactory entityManagerFactory, JPAUidProvider uidProvider, + JPAModSeqProvider modSeqProvider, JPAConfiguration jpaConfiguration) { + this.entityManagerFactory = entityManagerFactory; + this.uidProvider = uidProvider; + this.modSeqProvider = modSeqProvider; + EntityManagerUtils.safelyClose(createEntityManager()); + this.attachmentMapper = new JPAAttachmentMapper(entityManagerFactory); + this.jpaConfiguration = jpaConfiguration; + } + + @Override + public MailboxMapper createMailboxMapper(MailboxSession session) { + return new JPAMailboxMapper(entityManagerFactory); + } + + @Override + public MessageMapper createMessageMapper(MailboxSession session) { + return new JPAMessageMapper(uidProvider, modSeqProvider, entityManagerFactory, jpaConfiguration); + } + + @Override + public MessageIdMapper createMessageIdMapper(MailboxSession session) { + throw new NotImplementedException("not implemented"); + } + + @Override + public SubscriptionMapper createSubscriptionMapper(MailboxSession session) { + return new JPASubscriptionMapper(entityManagerFactory); + } + + /** + * Return a new {@link EntityManager} instance + * + * @return manager + */ + private EntityManager createEntityManager() { + return entityManagerFactory.createEntityManager(); + } + + @Override + public AnnotationMapper createAnnotationMapper(MailboxSession session) { + return new JPAAnnotationMapper(entityManagerFactory); + } + + @Override + public UidProvider getUidProvider() { + return uidProvider; + } + + @Override + public ModSeqProvider getModSeqProvider() { + return modSeqProvider; + } + + @Override + public AttachmentMapper createAttachmentMapper(MailboxSession session) { + return new JPAAttachmentMapper(entityManagerFactory); + } + + @Override + public AttachmentMapper getAttachmentMapper(MailboxSession session) { + return attachmentMapper; + } + +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPATransactionalMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPATransactionalMapper.java new file mode 100644 index 00000000000..9bfcf8e9f15 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPATransactionalMapper.java @@ -0,0 +1,96 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.mailbox.jpa; + +import javax.persistence.EntityManager; +import javax.persistence.EntityManagerFactory; +import javax.persistence.EntityTransaction; +import javax.persistence.PersistenceException; + +import org.apache.james.backends.jpa.EntityManagerUtils; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.store.transaction.TransactionalMapper; + +/** + * JPA implementation of TransactionMapper. This class is not thread-safe! + * + */ +public abstract class JPATransactionalMapper extends TransactionalMapper { + + protected EntityManagerFactory entityManagerFactory; + protected EntityManager entityManager; + + public JPATransactionalMapper(EntityManagerFactory entityManagerFactory) { + this.entityManagerFactory = entityManagerFactory; + } + + /** + * Return the currently used {@link EntityManager} or a new one if none exists. + * + * @return entitymanger + */ + public EntityManager getEntityManager() { + if (entityManager != null) { + return entityManager; + } + entityManager = entityManagerFactory.createEntityManager(); + return entityManager; + } + + @Override + protected void begin() throws MailboxException { + try { + getEntityManager().getTransaction().begin(); + } catch (PersistenceException e) { + throw new MailboxException("Begin of transaction failed", e); + } + } + + /** + * Commit the Transaction and close the EntityManager + */ + @Override + protected void commit() throws MailboxException { + try { + getEntityManager().getTransaction().commit(); + } catch (PersistenceException e) { + throw new MailboxException("Commit of transaction failed",e); + } + } + + @Override + protected void rollback() throws MailboxException { + EntityTransaction transaction = entityManager.getTransaction(); + // check if we have a transaction to rollback + if (transaction.isActive()) { + getEntityManager().getTransaction().rollback(); + } + } + + /** + * Close open {@link EntityManager} + */ + @Override + public void endRequest() { + EntityManagerUtils.safelyClose(entityManager); + entityManager = null; + } + + +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAAnnotationMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAAnnotationMapper.java new file mode 100644 index 00000000000..f0cfbe07859 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAAnnotationMapper.java @@ -0,0 +1,168 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.mail; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Predicate; + +import javax.persistence.EntityManagerFactory; +import javax.persistence.NoResultException; +import javax.persistence.PersistenceException; + +import org.apache.james.mailbox.jpa.JPAId; +import org.apache.james.mailbox.jpa.JPATransactionalMapper; +import org.apache.james.mailbox.jpa.mail.model.JPAMailboxAnnotation; +import org.apache.james.mailbox.jpa.mail.model.JPAMailboxAnnotationId; +import org.apache.james.mailbox.model.MailboxAnnotation; +import org.apache.james.mailbox.model.MailboxAnnotationKey; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.store.mail.AnnotationMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; + +public class JPAAnnotationMapper extends JPATransactionalMapper implements AnnotationMapper { + + private static final Logger LOGGER = LoggerFactory.getLogger(JPAAnnotationMapper.class); + + public static final Function READ_ROW = + input -> MailboxAnnotation.newInstance(new MailboxAnnotationKey(input.getKey()), input.getValue()); + + public JPAAnnotationMapper(EntityManagerFactory entityManagerFactory) { + super(entityManagerFactory); + } + + @Override + public List getAllAnnotations(MailboxId mailboxId) { + JPAId jpaId = (JPAId) mailboxId; + return getEntityManager().createNamedQuery("retrieveAllAnnotations", JPAMailboxAnnotation.class) + .setParameter("idParam", jpaId.getRawId()) + .getResultList() + .stream() + .map(READ_ROW) + .collect(ImmutableList.toImmutableList()); + } + + @Override + public List getAnnotationsByKeys(MailboxId mailboxId, Set keys) { + try { + final JPAId jpaId = (JPAId) mailboxId; + return keys.stream() + .map(input -> READ_ROW.apply( + getEntityManager() + .createNamedQuery("retrieveByKey", JPAMailboxAnnotation.class) + .setParameter("idParam", jpaId.getRawId()) + .setParameter("keyParam", input.asString()) + .getSingleResult())) + .collect(ImmutableList.toImmutableList()); + } catch (NoResultException e) { + return ImmutableList.of(); + } + } + + @Override + public List getAnnotationsByKeysWithOneDepth(MailboxId mailboxId, Set keys) { + return getFilteredLikes((JPAId) mailboxId, + keys, + key -> + annotation -> + key.isParentOrIsEqual(annotation.getKey())); + } + + @Override + public List getAnnotationsByKeysWithAllDepth(MailboxId mailboxId, Set keys) { + return getFilteredLikes((JPAId) mailboxId, + keys, + key -> + annotation -> key.isAncestorOrIsEqual(annotation.getKey())); + } + + private List getFilteredLikes(final JPAId jpaId, Set keys, final Function> predicateFunction) { + try { + return keys.stream() + .flatMap(key -> getEntityManager() + .createNamedQuery("retrieveByKeyLike", JPAMailboxAnnotation.class) + .setParameter("idParam", jpaId.getRawId()) + .setParameter("keyParam", key.asString() + '%') + .getResultList() + .stream() + .map(READ_ROW) + .filter(predicateFunction.apply(key))) + .collect(ImmutableList.toImmutableList()); + } catch (NoResultException e) { + return ImmutableList.of(); + } + } + + @Override + public void deleteAnnotation(MailboxId mailboxId, MailboxAnnotationKey key) { + try { + JPAId jpaId = (JPAId) mailboxId; + JPAMailboxAnnotation jpaMailboxAnnotation = getEntityManager() + .find(JPAMailboxAnnotation.class, new JPAMailboxAnnotationId(jpaId.getRawId(), key.asString())); + getEntityManager().remove(jpaMailboxAnnotation); + } catch (NoResultException e) { + LOGGER.debug("Mailbox annotation not found for ID {} and key {}", mailboxId.serialize(), key.asString()); + } catch (PersistenceException pe) { + throw new RuntimeException(pe); + } + } + + @Override + public void insertAnnotation(MailboxId mailboxId, MailboxAnnotation mailboxAnnotation) { + Preconditions.checkArgument(!mailboxAnnotation.isNil()); + JPAId jpaId = (JPAId) mailboxId; + if (getAnnotationsByKeys(mailboxId, ImmutableSet.of(mailboxAnnotation.getKey())).isEmpty()) { + getEntityManager().persist( + new JPAMailboxAnnotation(jpaId.getRawId(), + mailboxAnnotation.getKey().asString(), + mailboxAnnotation.getValue().orElse(null))); + } else { + getEntityManager().find(JPAMailboxAnnotation.class, + new JPAMailboxAnnotationId(jpaId.getRawId(), mailboxAnnotation.getKey().asString())) + .setValue(mailboxAnnotation.getValue().orElse(null)); + } + } + + @Override + public boolean exist(MailboxId mailboxId, MailboxAnnotation mailboxAnnotation) { + JPAId jpaId = (JPAId) mailboxId; + Optional row = Optional.ofNullable(getEntityManager().find(JPAMailboxAnnotation.class, + new JPAMailboxAnnotationId(jpaId.getRawId(), mailboxAnnotation.getKey().asString()))); + return row.isPresent(); + } + + @Override + public int countAnnotations(MailboxId mailboxId) { + try { + JPAId jpaId = (JPAId) mailboxId; + return ((Long)getEntityManager().createNamedQuery("countAnnotationsInMailbox") + .setParameter("idParam", jpaId.getRawId()).getSingleResult()).intValue(); + } catch (PersistenceException pe) { + throw new RuntimeException(pe); + } + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAAttachmentMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAAttachmentMapper.java new file mode 100644 index 00000000000..9985cad784c --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAAttachmentMapper.java @@ -0,0 +1,118 @@ +/*************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.mail; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Collection; +import java.util.List; + +import javax.persistence.EntityManagerFactory; +import javax.persistence.NoResultException; + +import org.apache.commons.io.IOUtils; +import org.apache.james.mailbox.exception.AttachmentNotFoundException; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.jpa.JPATransactionalMapper; +import org.apache.james.mailbox.jpa.mail.model.JPAAttachment; +import org.apache.james.mailbox.model.AttachmentId; +import org.apache.james.mailbox.model.AttachmentMetadata; +import org.apache.james.mailbox.model.MessageAttachmentMetadata; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.model.ParsedAttachment; +import org.apache.james.mailbox.store.mail.AttachmentMapper; + +import com.github.fge.lambdas.Throwing; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; + +public class JPAAttachmentMapper extends JPATransactionalMapper implements AttachmentMapper { + + private static final String ID_PARAM = "idParam"; + + public JPAAttachmentMapper(EntityManagerFactory entityManagerFactory) { + super(entityManagerFactory); + } + + @Override + public InputStream loadAttachmentContent(AttachmentId attachmentId) { + Preconditions.checkArgument(attachmentId != null); + return getEntityManager().createNamedQuery("findAttachmentById", JPAAttachment.class) + .setParameter(ID_PARAM, attachmentId.getId()) + .getSingleResult().getContent(); + } + + @Override + public AttachmentMetadata getAttachment(AttachmentId attachmentId) throws AttachmentNotFoundException { + Preconditions.checkArgument(attachmentId != null); + AttachmentMetadata attachmentMetadata = getAttachmentMetadata(attachmentId); + if (attachmentMetadata == null) { + throw new AttachmentNotFoundException(attachmentId.getId()); + } + return attachmentMetadata; + } + + @Override + public List getAttachments(Collection attachmentIds) { + Preconditions.checkArgument(attachmentIds != null); + ImmutableList.Builder builder = ImmutableList.builder(); + for (AttachmentId attachmentId : attachmentIds) { + AttachmentMetadata attachmentMetadata = getAttachmentMetadata(attachmentId); + if (attachmentMetadata != null) { + builder.add(attachmentMetadata); + } + } + return builder.build(); + } + + @Override + public List storeAttachments(Collection parsedAttachments, MessageId ownerMessageId) { + Preconditions.checkArgument(parsedAttachments != null); + Preconditions.checkArgument(ownerMessageId != null); + return parsedAttachments.stream() + .map(Throwing.function( + typedContent -> storeAttachmentForMessage(ownerMessageId, typedContent)) + .sneakyThrow()) + .collect(ImmutableList.toImmutableList()); + } + + private AttachmentMetadata getAttachmentMetadata(AttachmentId attachmentId) { + try { + return getEntityManager().createNamedQuery("findAttachmentById", JPAAttachment.class) + .setParameter(ID_PARAM, attachmentId.getId()) + .getSingleResult() + .toAttachmentMetadata(); + } catch (NoResultException e) { + return null; + } + } + + private MessageAttachmentMetadata storeAttachmentForMessage(MessageId ownerMessageId, ParsedAttachment parsedAttachment) throws MailboxException { + try { + byte[] bytes = IOUtils.toByteArray(parsedAttachment.getContent().openStream()); + JPAAttachment persistedAttachment = new JPAAttachment(parsedAttachment.asMessageAttachment(AttachmentId.random(), ownerMessageId), bytes); + getEntityManager().persist(persistedAttachment); + AttachmentId attachmentId = AttachmentId.from(persistedAttachment.getAttachmentId()); + return parsedAttachment.asMessageAttachment(attachmentId, bytes.length, ownerMessageId); + } catch (IOException e) { + throw new MailboxException("Failed to store attachment for message " + ownerMessageId, e); + } + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAMailboxMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAMailboxMapper.java new file mode 100644 index 00000000000..f691f5c1c36 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAMailboxMapper.java @@ -0,0 +1,240 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.mail; + +import java.util.NoSuchElementException; + +import javax.persistence.EntityExistsException; +import javax.persistence.EntityManagerFactory; +import javax.persistence.NoResultException; +import javax.persistence.PersistenceException; +import javax.persistence.RollbackException; +import javax.persistence.TypedQuery; + +import org.apache.james.core.Username; +import org.apache.james.mailbox.acl.ACLDiff; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.exception.MailboxExistsException; +import org.apache.james.mailbox.exception.MailboxNotFoundException; +import org.apache.james.mailbox.jpa.JPAId; +import org.apache.james.mailbox.jpa.JPATransactionalMapper; +import org.apache.james.mailbox.jpa.mail.model.JPAMailbox; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxACL; +import org.apache.james.mailbox.model.MailboxACL.Right; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.UidValidity; +import org.apache.james.mailbox.model.search.MailboxQuery; +import org.apache.james.mailbox.store.MailboxExpressionBackwardCompatibility; +import org.apache.james.mailbox.store.mail.MailboxMapper; + +import com.google.common.base.Preconditions; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** + * Data access management for mailbox. + */ +public class JPAMailboxMapper extends JPATransactionalMapper implements MailboxMapper { + + private static final char SQL_WILDCARD_CHAR = '%'; + private String lastMailboxName; + + public JPAMailboxMapper(EntityManagerFactory entityManagerFactory) { + super(entityManagerFactory); + } + + /** + * Commit the transaction. If the commit fails due a conflict in a unique key constraint a {@link MailboxExistsException} + * will get thrown + */ + @Override + protected void commit() throws MailboxException { + try { + getEntityManager().getTransaction().commit(); + } catch (PersistenceException e) { + if (e instanceof EntityExistsException) { + throw new MailboxExistsException(lastMailboxName); + } + if (e instanceof RollbackException) { + Throwable t = e.getCause(); + if (t instanceof EntityExistsException) { + throw new MailboxExistsException(lastMailboxName); + } + } + throw new MailboxException("Commit of transaction failed", e); + } + } + + @Override + public Mono create(MailboxPath mailboxPath, UidValidity uidValidity) { + return assertPathIsNotAlreadyUsedByAnotherMailbox(mailboxPath) + .then(Mono.fromCallable(() -> { + this.lastMailboxName = mailboxPath.getName(); + JPAMailbox persistedMailbox = new JPAMailbox(mailboxPath, uidValidity); + getEntityManager().persist(persistedMailbox); + + return new Mailbox(mailboxPath, uidValidity, persistedMailbox.getMailboxId()); + }).subscribeOn(Schedulers.boundedElastic())) + .onErrorMap(PersistenceException.class, e -> new MailboxException("Save of mailbox " + mailboxPath.getName() + " failed", e)); + } + + @Override + public Mono rename(Mailbox mailbox) { + Preconditions.checkNotNull(mailbox.getMailboxId(), "A mailbox we want to rename should have a defined mailboxId"); + + return assertPathIsNotAlreadyUsedByAnotherMailbox(mailbox.generateAssociatedPath()) + .then(Mono.fromCallable(() -> { + this.lastMailboxName = mailbox.getName(); + JPAMailbox persistedMailbox = jpaMailbox(mailbox); + + getEntityManager().persist(persistedMailbox); + return (MailboxId) persistedMailbox.getMailboxId(); + }).subscribeOn(Schedulers.boundedElastic())) + .onErrorMap(PersistenceException.class, e -> new MailboxException("Save of mailbox " + mailbox.getName() + " failed", e)); + } + + private JPAMailbox jpaMailbox(Mailbox mailbox) throws MailboxException { + JPAMailbox result = loadJpaMailbox(mailbox.getMailboxId()); + result.setNamespace(mailbox.getNamespace()); + result.setUser(mailbox.getUser().asString()); + result.setName(mailbox.getName()); + return result; + } + + private Mono assertPathIsNotAlreadyUsedByAnotherMailbox(MailboxPath mailboxPath) { + return findMailboxByPath(mailboxPath) + .flatMap(ignored -> Mono.error(new MailboxExistsException(mailboxPath.getName()))); + } + + @Override + public Mono findMailboxByPath(MailboxPath mailboxPath) { + return Mono.fromCallable(() -> getEntityManager().createNamedQuery("findMailboxByNameWithUser", JPAMailbox.class) + .setParameter("nameParam", mailboxPath.getName()) + .setParameter("namespaceParam", mailboxPath.getNamespace()) + .setParameter("userParam", mailboxPath.getUser().asString()) + .getSingleResult() + .toMailbox()) + .onErrorResume(NoResultException.class, e -> Mono.empty()) + .onErrorResume(NoSuchElementException.class, e -> Mono.empty()) + .onErrorResume(PersistenceException.class, e -> Mono.error(new MailboxException("Exception upon JPA execution", e))) + .subscribeOn(Schedulers.boundedElastic()); + } + + @Override + public Mono findMailboxById(MailboxId id) { + return Mono.fromCallable(() -> loadJpaMailbox(id).toMailbox()) + .subscribeOn(Schedulers.boundedElastic()) + .onErrorMap(PersistenceException.class, e -> new MailboxException("Search of mailbox " + id.serialize() + " failed", e)); + } + + private JPAMailbox loadJpaMailbox(MailboxId id) throws MailboxNotFoundException { + JPAId mailboxId = (JPAId)id; + try { + return getEntityManager().createNamedQuery("findMailboxById", JPAMailbox.class) + .setParameter("idParam", mailboxId.getRawId()) + .getSingleResult(); + } catch (NoResultException e) { + throw new MailboxNotFoundException(mailboxId); + } + } + + @Override + public Mono delete(Mailbox mailbox) { + return Mono.fromRunnable(() -> { + JPAId mailboxId = (JPAId) mailbox.getMailboxId(); + getEntityManager().createNamedQuery("deleteMessages").setParameter("idParam", mailboxId.getRawId()).executeUpdate(); + JPAMailbox jpaMailbox = getEntityManager().find(JPAMailbox.class, mailboxId.getRawId()); + getEntityManager().remove(jpaMailbox); + }) + .subscribeOn(Schedulers.boundedElastic()) + .onErrorMap(PersistenceException.class, e -> new MailboxException("Delete of mailbox " + mailbox + " failed", e)) + .then(); + } + + @Override + public Flux findMailboxWithPathLike(MailboxQuery.UserBound query) { + String pathLike = MailboxExpressionBackwardCompatibility.getPathLike(query); + return Mono.fromCallable(() -> findMailboxWithPathLikeTypedQuery(query.getFixedNamespace(), query.getFixedUser(), pathLike)) + .subscribeOn(Schedulers.boundedElastic()) + .flatMapIterable(TypedQuery::getResultList) + .map(JPAMailbox::toMailbox) + .filter(query::matches) + .onErrorMap(PersistenceException.class, e -> new MailboxException("Search of mailbox " + query + " failed", e)); + } + + private TypedQuery findMailboxWithPathLikeTypedQuery(String namespace, Username username, String pathLike) { + return getEntityManager().createNamedQuery("findMailboxWithNameLikeWithUser", JPAMailbox.class) + .setParameter("nameParam", pathLike) + .setParameter("namespaceParam", namespace) + .setParameter("userParam", username.asString()); + } + + @Override + public Mono hasChildren(Mailbox mailbox, char delimiter) { + final String name = mailbox.getName() + delimiter + SQL_WILDCARD_CHAR; + + return Mono.defer(() -> Mono.justOrEmpty((Long) getEntityManager() + .createNamedQuery("countMailboxesWithNameLikeWithUser") + .setParameter("nameParam", name) + .setParameter("namespaceParam", mailbox.getNamespace()) + .setParameter("userParam", mailbox.getUser().asString()) + .getSingleResult())) + .subscribeOn(Schedulers.boundedElastic()) + .filter(numberOfChildMailboxes -> numberOfChildMailboxes > 0) + .hasElement(); + } + + @Override + public Flux list() { + return Mono.fromCallable(() -> getEntityManager().createNamedQuery("listMailboxes", JPAMailbox.class)) + .subscribeOn(Schedulers.boundedElastic()) + .flatMapIterable(TypedQuery::getResultList) + .onErrorMap(PersistenceException.class, e -> new MailboxException("Delete of mailboxes failed", e)) + .map(JPAMailbox::toMailbox); + } + + @Override + public Mono updateACL(Mailbox mailbox, MailboxACL.ACLCommand mailboxACLCommand) { + return Mono.fromCallable(() -> { + MailboxACL oldACL = mailbox.getACL(); + MailboxACL newACL = mailbox.getACL().apply(mailboxACLCommand); + mailbox.setACL(newACL); + return ACLDiff.computeDiff(oldACL, newACL); + }).subscribeOn(Schedulers.boundedElastic()); + } + + @Override + public Mono setACL(Mailbox mailbox, MailboxACL mailboxACL) { + return Mono.fromCallable(() -> { + MailboxACL oldMailboxAcl = mailbox.getACL(); + mailbox.setACL(mailboxACL); + return ACLDiff.computeDiff(oldMailboxAcl, mailboxACL); + }).subscribeOn(Schedulers.boundedElastic()); + } + + @Override + public Flux findNonPersonalMailboxes(Username userName, Right right) { + return Flux.empty(); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAMessageMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAMessageMapper.java new file mode 100644 index 00000000000..b4e7de4327c --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAMessageMapper.java @@ -0,0 +1,540 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.mailbox.jpa.mail; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +import javax.mail.Flags; +import javax.persistence.EntityManagerFactory; +import javax.persistence.PersistenceException; +import javax.persistence.Query; + +import org.apache.james.backends.jpa.JPAConfiguration; +import org.apache.james.mailbox.ApplicableFlagBuilder; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.jpa.JPAId; +import org.apache.james.mailbox.jpa.JPATransactionalMapper; +import org.apache.james.mailbox.jpa.mail.MessageUtils.MessageChangedFlags; +import org.apache.james.mailbox.jpa.mail.model.JPAAttachment; +import org.apache.james.mailbox.jpa.mail.model.JPAMailbox; +import org.apache.james.mailbox.jpa.mail.model.openjpa.AbstractJPAMailboxMessage; +import org.apache.james.mailbox.jpa.mail.model.openjpa.JPAEncryptedMailboxMessage; +import org.apache.james.mailbox.jpa.mail.model.openjpa.JPAMailboxMessage; +import org.apache.james.mailbox.jpa.mail.model.openjpa.JPAMailboxMessageWithAttachmentStorage; +import org.apache.james.mailbox.jpa.mail.model.openjpa.JPAStreamingMailboxMessage; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxCounters; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.MessageAttachmentMetadata; +import org.apache.james.mailbox.model.MessageMetaData; +import org.apache.james.mailbox.model.MessageRange; +import org.apache.james.mailbox.model.MessageRange.Type; +import org.apache.james.mailbox.model.UpdatedFlags; +import org.apache.james.mailbox.store.FlagsUpdateCalculator; +import org.apache.james.mailbox.store.mail.MessageMapper; +import org.apache.james.mailbox.store.mail.model.MailboxMessage; +import org.apache.openjpa.persistence.ArgumentException; + +import com.github.fge.lambdas.Throwing; +import com.google.common.collect.ImmutableList; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** + * JPA implementation of a {@link MessageMapper}. This class is not thread-safe! + */ +public class JPAMessageMapper extends JPATransactionalMapper implements MessageMapper { + private static final int UNLIMIT_MAX_SIZE = -1; + private static final int UNLIMITED = -1; + + private final MessageUtils messageMetadataMapper; + private final JPAUidProvider uidProvider; + private final JPAModSeqProvider modSeqProvider; + private final JPAConfiguration jpaConfiguration; + + public JPAMessageMapper(JPAUidProvider uidProvider, JPAModSeqProvider modSeqProvider, EntityManagerFactory entityManagerFactory, + JPAConfiguration jpaConfiguration) { + super(entityManagerFactory); + this.messageMetadataMapper = new MessageUtils(uidProvider, modSeqProvider); + this.uidProvider = uidProvider; + this.modSeqProvider = modSeqProvider; + this.jpaConfiguration = jpaConfiguration; + } + + @Override + public MailboxCounters getMailboxCounters(Mailbox mailbox) throws MailboxException { + return MailboxCounters.builder() + .mailboxId(mailbox.getMailboxId()) + .count(countMessagesInMailbox(mailbox)) + .unseen(countUnseenMessagesInMailbox(mailbox)) + .build(); + } + + @Override + public Flux listAllMessageUids(Mailbox mailbox) { + return Mono.fromCallable(() -> { + try { + JPAId mailboxId = (JPAId) mailbox.getMailboxId(); + Query query = getEntityManager().createNamedQuery("listUidsInMailbox") + .setParameter("idParam", mailboxId.getRawId()); + return query.getResultStream().map(result -> MessageUid.of((Long) result)); + } catch (PersistenceException e) { + throw new MailboxException("Search of recent messages failed in mailbox " + mailbox, e); + } + }).flatMapMany(Flux::fromStream) + .subscribeOn(Schedulers.boundedElastic()); + } + + @Override + public Flux findInMailboxReactive(Mailbox mailbox, MessageRange messageRange, FetchType ftype, int limitAsInt) { + return Flux.defer(Throwing.supplier(() -> Flux.fromIterable(findAsList(mailbox.getMailboxId(), messageRange, limitAsInt))).sneakyThrow()) + .subscribeOn(Schedulers.boundedElastic()); + } + + @Override + public Iterator findInMailbox(Mailbox mailbox, MessageRange set, FetchType fType, int max) + throws MailboxException { + + return findAsList(mailbox.getMailboxId(), set, max).iterator(); + } + + private List findAsList(MailboxId mailboxId, MessageRange set, int max) throws MailboxException { + try { + MessageUid from = set.getUidFrom(); + MessageUid to = set.getUidTo(); + Type type = set.getType(); + JPAId jpaId = (JPAId) mailboxId; + + switch (type) { + default: + case ALL: + return findMessagesInMailbox(jpaId, max); + case FROM: + return findMessagesInMailboxAfterUID(jpaId, from, max); + case ONE: + return findMessagesInMailboxWithUID(jpaId, from); + case RANGE: + return findMessagesInMailboxBetweenUIDs(jpaId, from, to, max); + } + } catch (PersistenceException e) { + throw new MailboxException("Search of MessageRange " + set + " failed in mailbox " + mailboxId.serialize(), e); + } + } + + @Override + public long countMessagesInMailbox(Mailbox mailbox) throws MailboxException { + JPAId mailboxId = (JPAId) mailbox.getMailboxId(); + return countMessagesInMailbox(mailboxId); + } + + private long countMessagesInMailbox(JPAId mailboxId) throws MailboxException { + try { + return (Long) getEntityManager().createNamedQuery("countMessagesInMailbox") + .setParameter("idParam", mailboxId.getRawId()).getSingleResult(); + } catch (PersistenceException e) { + throw new MailboxException("Count of messages failed in mailbox " + mailboxId, e); + } + } + + public long countUnseenMessagesInMailbox(Mailbox mailbox) throws MailboxException { + JPAId mailboxId = (JPAId) mailbox.getMailboxId(); + return countUnseenMessagesInMailbox(mailboxId); + } + + private long countUnseenMessagesInMailbox(JPAId mailboxId) throws MailboxException { + try { + return (Long) getEntityManager().createNamedQuery("countUnseenMessagesInMailbox") + .setParameter("idParam", mailboxId.getRawId()).getSingleResult(); + } catch (PersistenceException e) { + throw new MailboxException("Count of useen messages failed in mailbox " + mailboxId, e); + } + } + + @Override + public void delete(Mailbox mailbox, MailboxMessage message) throws MailboxException { + try { + AbstractJPAMailboxMessage jpaMessage = getEntityManager().find(AbstractJPAMailboxMessage.class, buildKey(mailbox, message)); + getEntityManager().remove(jpaMessage); + + } catch (PersistenceException e) { + throw new MailboxException("Delete of message " + message + " failed in mailbox " + mailbox, e); + } + } + + private AbstractJPAMailboxMessage.MailboxIdUidKey buildKey(Mailbox mailbox, MailboxMessage message) { + JPAId mailboxId = (JPAId) mailbox.getMailboxId(); + AbstractJPAMailboxMessage.MailboxIdUidKey key = new AbstractJPAMailboxMessage.MailboxIdUidKey(); + key.mailbox = mailboxId.getRawId(); + key.uid = message.getUid().asLong(); + return key; + } + + @Override + @SuppressWarnings("unchecked") + public MessageUid findFirstUnseenMessageUid(Mailbox mailbox) throws MailboxException { + try { + JPAId mailboxId = (JPAId) mailbox.getMailboxId(); + Query query = getEntityManager().createNamedQuery("findUnseenMessagesInMailboxOrderByUid").setParameter( + "idParam", mailboxId.getRawId()); + query.setMaxResults(1); + List result = query.getResultList(); + if (result.isEmpty()) { + return null; + } else { + return result.get(0).getUid(); + } + } catch (PersistenceException e) { + throw new MailboxException("Search of first unseen message failed in mailbox " + mailbox, e); + } + } + + @Override + @SuppressWarnings("unchecked") + public List findRecentMessageUidsInMailbox(Mailbox mailbox) throws MailboxException { + try { + JPAId mailboxId = (JPAId) mailbox.getMailboxId(); + Query query = getEntityManager().createNamedQuery("findRecentMessageUidsInMailbox").setParameter("idParam", + mailboxId.getRawId()); + List resultList = query.getResultList(); + ImmutableList.Builder results = ImmutableList.builder(); + for (long result: resultList) { + results.add(MessageUid.of(result)); + } + return results.build(); + } catch (PersistenceException e) { + throw new MailboxException("Search of recent messages failed in mailbox " + mailbox, e); + } + } + + + + @Override + public List retrieveMessagesMarkedForDeletion(Mailbox mailbox, MessageRange messageRange) throws MailboxException { + try { + JPAId mailboxId = (JPAId) mailbox.getMailboxId(); + List messages = findDeletedMessages(messageRange, mailboxId); + return getUidList(messages); + } catch (PersistenceException e) { + throw new MailboxException("Search of MessageRange " + messageRange + " failed in mailbox " + mailbox, e); + } + } + + private List findDeletedMessages(MessageRange messageRange, JPAId mailboxId) { + MessageUid from = messageRange.getUidFrom(); + MessageUid to = messageRange.getUidTo(); + + switch (messageRange.getType()) { + case ONE: + return findDeletedMessagesInMailboxWithUID(mailboxId, from); + case RANGE: + return findDeletedMessagesInMailboxBetweenUIDs(mailboxId, from, to); + case FROM: + return findDeletedMessagesInMailboxAfterUID(mailboxId, from); + case ALL: + return findDeletedMessagesInMailbox(mailboxId); + default: + throw new RuntimeException("Cannot find deleted messages, range type " + messageRange.getType() + " doesn't exist"); + } + } + + @Override + public Map deleteMessages(Mailbox mailbox, List uids) throws MailboxException { + JPAId mailboxId = (JPAId) mailbox.getMailboxId(); + Map data = new HashMap<>(); + List ranges = MessageRange.toRanges(uids); + + ranges.forEach(Throwing.consumer(range -> { + List messages = findAsList(mailboxId, range, JPAMessageMapper.UNLIMITED); + data.putAll(createMetaData(messages)); + deleteMessages(range, mailboxId); + }).sneakyThrow()); + + return data; + } + + private void deleteMessages(MessageRange messageRange, JPAId mailboxId) { + MessageUid from = messageRange.getUidFrom(); + MessageUid to = messageRange.getUidTo(); + + switch (messageRange.getType()) { + case ONE: + deleteMessagesInMailboxWithUID(mailboxId, from); + break; + case RANGE: + deleteMessagesInMailboxBetweenUIDs(mailboxId, from, to); + break; + case FROM: + deleteMessagesInMailboxAfterUID(mailboxId, from); + break; + case ALL: + deleteMessagesInMailbox(mailboxId); + break; + default: + throw new RuntimeException("Cannot delete messages, range type " + messageRange.getType() + " doesn't exist"); + } + } + + @Override + public MessageMetaData move(Mailbox mailbox, MailboxMessage original) throws MailboxException { + JPAId originalMailboxId = (JPAId) original.getMailboxId(); + JPAMailbox originalMailbox = getEntityManager().find(JPAMailbox.class, originalMailboxId.getRawId()); + + MessageMetaData messageMetaData = copy(mailbox, original); + delete(originalMailbox.toMailbox(), original); + + return messageMetaData; + } + + @Override + public MessageMetaData add(Mailbox mailbox, MailboxMessage message) throws MailboxException { + messageMetadataMapper.enrichMessage(mailbox, message); + + return save(mailbox, message); + } + + @Override + public Iterator updateFlags(Mailbox mailbox, FlagsUpdateCalculator flagsUpdateCalculator, + MessageRange set) throws MailboxException { + Iterator messages = findInMailbox(mailbox, set, FetchType.METADATA, UNLIMIT_MAX_SIZE); + + MessageChangedFlags messageChangedFlags = messageMetadataMapper.updateFlags(mailbox, flagsUpdateCalculator, messages); + + for (MailboxMessage mailboxMessage : messageChangedFlags.getChangedFlags()) { + save(mailbox, mailboxMessage); + } + + return messageChangedFlags.getUpdatedFlags(); + } + + @Override + public MessageMetaData copy(Mailbox mailbox, MailboxMessage original) throws MailboxException { + return copy(mailbox, uidProvider.nextUid(mailbox), modSeqProvider.nextModSeq(mailbox), original); + } + + @Override + public Optional getLastUid(Mailbox mailbox) throws MailboxException { + return uidProvider.lastUid(mailbox, getEntityManager()); + } + + @Override + public ModSeq getHighestModSeq(Mailbox mailbox) throws MailboxException { + return modSeqProvider.highestModSeq(mailbox.getMailboxId(), getEntityManager()); + } + + @Override + public Flags getApplicableFlag(Mailbox mailbox) throws MailboxException { + JPAId jpaId = (JPAId) mailbox.getMailboxId(); + ApplicableFlagBuilder builder = ApplicableFlagBuilder.builder(); + List flags = getEntityManager().createNativeQuery("SELECT DISTINCT USERFLAG_NAME FROM JAMES_MAIL_USERFLAG WHERE MAILBOX_ID=?") + .setParameter(1, jpaId.getRawId()) + .getResultList(); + flags.forEach(builder::add); + return builder.build(); + } + + private MessageMetaData copy(Mailbox mailbox, MessageUid uid, ModSeq modSeq, MailboxMessage original) + throws MailboxException { + MailboxMessage copy; + JPAMailbox currentMailbox = JPAMailbox.from(mailbox); + + if (original instanceof JPAStreamingMailboxMessage) { + copy = new JPAStreamingMailboxMessage(currentMailbox, uid, modSeq, original); + } else if (original instanceof JPAEncryptedMailboxMessage) { + copy = new JPAEncryptedMailboxMessage(currentMailbox, uid, modSeq, original); + } else if (original instanceof JPAMailboxMessageWithAttachmentStorage) { + copy = new JPAMailboxMessageWithAttachmentStorage(currentMailbox, uid, modSeq, original); + } else { + copy = new JPAMailboxMessage(currentMailbox, uid, modSeq, original); + } + return save(mailbox, copy); + } + + protected MessageMetaData save(Mailbox mailbox, MailboxMessage message) throws MailboxException { + try { + // We need to reload a "JPA attached" mailbox, because the provide + // mailbox is already "JPA detached" + // If we don't this, we will get an + // org.apache.openjpa.persistence.ArgumentException. + JPAId mailboxId = (JPAId) mailbox.getMailboxId(); + JPAMailbox currentMailbox = getEntityManager().find(JPAMailbox.class, mailboxId.getRawId()); + + boolean isAttachmentStorage = false; + if (Objects.nonNull(jpaConfiguration)) { + isAttachmentStorage = jpaConfiguration.isAttachmentStorageEnabled().orElse(false); + } + + if (message instanceof AbstractJPAMailboxMessage) { + ((AbstractJPAMailboxMessage) message).setMailbox(currentMailbox); + + getEntityManager().persist(message); + return message.metaData(); + } else if (isAttachmentStorage) { + JPAMailboxMessageWithAttachmentStorage persistData = new JPAMailboxMessageWithAttachmentStorage(currentMailbox, message.getUid(), message.getModSeq(), message); + persistData.setFlags(message.createFlags()); + + if (message.getAttachments().isEmpty()) { + getEntityManager().persist(persistData); + } else { + List attachments = getAttachments(message); + if (attachments.isEmpty()) { + persistData.setAttachments(message.getAttachments().stream() + .map(JPAAttachment::new) + .collect(Collectors.toList())); + getEntityManager().persist(persistData); + } else { + persistData.setAttachments(attachments); + getEntityManager().merge(persistData); + } + } + return persistData.metaData(); + } else { + JPAMailboxMessage persistData = new JPAMailboxMessage(currentMailbox, message.getUid(), message.getModSeq(), message); + persistData.setFlags(message.createFlags()); + getEntityManager().persist(persistData); + return persistData.metaData(); + } + + } catch (PersistenceException | ArgumentException e) { + throw new MailboxException("Save of message " + message + " failed in mailbox " + mailbox, e); + } + } + + private List getAttachments(MailboxMessage message) { + return message.getAttachments().stream() + .map(MessageAttachmentMetadata::getAttachmentId) + .map(attachmentId -> getEntityManager().createNamedQuery("findAttachmentById", JPAAttachment.class) + .setParameter("idParam", attachmentId.getId()) + .getSingleResult()) + .collect(Collectors.toList()); + } + + @SuppressWarnings("unchecked") + private List findMessagesInMailboxAfterUID(JPAId mailboxId, MessageUid from, int batchSize) { + Query query = getEntityManager().createNamedQuery("findMessagesInMailboxAfterUID") + .setParameter("idParam", mailboxId.getRawId()).setParameter("uidParam", from.asLong()); + + if (batchSize > 0) { + query.setMaxResults(batchSize); + } + + return query.getResultList(); + } + + @SuppressWarnings("unchecked") + private List findMessagesInMailboxWithUID(JPAId mailboxId, MessageUid from) { + return getEntityManager().createNamedQuery("findMessagesInMailboxWithUID") + .setParameter("idParam", mailboxId.getRawId()).setParameter("uidParam", from.asLong()).setMaxResults(1) + .getResultList(); + } + + @SuppressWarnings("unchecked") + private List findMessagesInMailboxBetweenUIDs(JPAId mailboxId, MessageUid from, MessageUid to, + int batchSize) { + Query query = getEntityManager().createNamedQuery("findMessagesInMailboxBetweenUIDs") + .setParameter("idParam", mailboxId.getRawId()).setParameter("fromParam", from.asLong()) + .setParameter("toParam", to.asLong()); + + if (batchSize > 0) { + query.setMaxResults(batchSize); + } + + return query.getResultList(); + } + + @SuppressWarnings("unchecked") + private List findMessagesInMailbox(JPAId mailboxId, int batchSize) { + Query query = getEntityManager().createNamedQuery("findMessagesInMailbox").setParameter("idParam", + mailboxId.getRawId()); + if (batchSize > 0) { + query.setMaxResults(batchSize); + } + return query.getResultList(); + } + + private Map createMetaData(List uids) { + final Map data = new HashMap<>(); + for (MailboxMessage m : uids) { + data.put(m.getUid(), m.metaData()); + } + return data; + } + + private List getUidList(List messages) { + return messages.stream() + .map(MailboxMessage::getUid) + .collect(ImmutableList.toImmutableList()); + } + + private int deleteMessagesInMailbox(JPAId mailboxId) { + return getEntityManager().createNamedQuery("deleteMessagesInMailbox") + .setParameter("idParam", mailboxId.getRawId()).executeUpdate(); + } + + private int deleteMessagesInMailboxAfterUID(JPAId mailboxId, MessageUid from) { + return getEntityManager().createNamedQuery("deleteMessagesInMailboxAfterUID") + .setParameter("idParam", mailboxId.getRawId()).setParameter("uidParam", from.asLong()).executeUpdate(); + } + + private int deleteMessagesInMailboxWithUID(JPAId mailboxId, MessageUid from) { + return getEntityManager().createNamedQuery("deleteMessagesInMailboxWithUID") + .setParameter("idParam", mailboxId.getRawId()).setParameter("uidParam", from.asLong()).executeUpdate(); + } + + private int deleteMessagesInMailboxBetweenUIDs(JPAId mailboxId, MessageUid from, MessageUid to) { + return getEntityManager().createNamedQuery("deleteMessagesInMailboxBetweenUIDs") + .setParameter("idParam", mailboxId.getRawId()).setParameter("fromParam", from.asLong()) + .setParameter("toParam", to.asLong()).executeUpdate(); + } + + @SuppressWarnings("unchecked") + private List findDeletedMessagesInMailbox(JPAId mailboxId) { + return getEntityManager().createNamedQuery("findDeletedMessagesInMailbox") + .setParameter("idParam", mailboxId.getRawId()).getResultList(); + } + + @SuppressWarnings("unchecked") + private List findDeletedMessagesInMailboxAfterUID(JPAId mailboxId, MessageUid from) { + return getEntityManager().createNamedQuery("findDeletedMessagesInMailboxAfterUID") + .setParameter("idParam", mailboxId.getRawId()).setParameter("uidParam", from.asLong()).getResultList(); + } + + @SuppressWarnings("unchecked") + private List findDeletedMessagesInMailboxWithUID(JPAId mailboxId, MessageUid from) { + return getEntityManager().createNamedQuery("findDeletedMessagesInMailboxWithUID") + .setParameter("idParam", mailboxId.getRawId()).setParameter("uidParam", from.asLong()).setMaxResults(1) + .getResultList(); + } + + @SuppressWarnings("unchecked") + private List findDeletedMessagesInMailboxBetweenUIDs(JPAId mailboxId, MessageUid from, MessageUid to) { + return getEntityManager().createNamedQuery("findDeletedMessagesInMailboxBetweenUIDs") + .setParameter("idParam", mailboxId.getRawId()).setParameter("fromParam", from.asLong()) + .setParameter("toParam", to.asLong()).getResultList(); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAModSeqProvider.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAModSeqProvider.java new file mode 100644 index 00000000000..5f1414d32cb --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAModSeqProvider.java @@ -0,0 +1,105 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.mailbox.jpa.mail; + +import javax.inject.Inject; +import javax.persistence.EntityManager; +import javax.persistence.EntityManagerFactory; +import javax.persistence.PersistenceException; + +import org.apache.james.backends.jpa.EntityManagerUtils; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.jpa.JPAId; +import org.apache.james.mailbox.jpa.mail.model.JPAMailbox; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.store.mail.ModSeqProvider; + +public class JPAModSeqProvider implements ModSeqProvider { + + private final EntityManagerFactory factory; + + @Inject + public JPAModSeqProvider(EntityManagerFactory factory) { + this.factory = factory; + } + + @Override + public ModSeq highestModSeq(Mailbox mailbox) throws MailboxException { + JPAId mailboxId = (JPAId) mailbox.getMailboxId(); + return highestModSeq(mailboxId); + } + + @Override + public ModSeq nextModSeq(Mailbox mailbox) throws MailboxException { + return nextModSeq((JPAId) mailbox.getMailboxId()); + } + + @Override + public ModSeq nextModSeq(MailboxId mailboxId) throws MailboxException { + return nextModSeq((JPAId) mailboxId); + } + + @Override + public ModSeq highestModSeq(MailboxId mailboxId) throws MailboxException { + return highestModSeq((JPAId) mailboxId); + } + + private ModSeq nextModSeq(JPAId mailboxId) throws MailboxException { + EntityManager manager = null; + try { + manager = factory.createEntityManager(); + manager.getTransaction().begin(); + JPAMailbox m = manager.find(JPAMailbox.class, mailboxId.getRawId()); + long modSeq = m.consumeModSeq(); + manager.persist(m); + manager.getTransaction().commit(); + return ModSeq.of(modSeq); + } catch (PersistenceException e) { + if (manager != null && manager.getTransaction().isActive()) { + manager.getTransaction().rollback(); + } + throw new MailboxException("Unable to save highest mod-sequence for mailbox " + mailboxId.serialize(), e); + } finally { + EntityManagerUtils.safelyClose(manager); + } + } + + private ModSeq highestModSeq(JPAId mailboxId) throws MailboxException { + EntityManager manager = factory.createEntityManager(); + try { + return highestModSeq(mailboxId, manager); + } finally { + EntityManagerUtils.safelyClose(manager); + } + } + + public ModSeq highestModSeq(MailboxId mailboxId, EntityManager manager) throws MailboxException { + JPAId jpaId = (JPAId) mailboxId; + try { + long highest = (Long) manager.createNamedQuery("findHighestModSeq") + .setParameter("idParam", jpaId.getRawId()) + .getSingleResult(); + return ModSeq.of(highest); + } catch (PersistenceException e) { + throw new MailboxException("Unable to get highest mod-sequence for mailbox " + mailboxId.serialize(), e); + } + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAUidProvider.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAUidProvider.java new file mode 100644 index 00000000000..94e197b4f94 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAUidProvider.java @@ -0,0 +1,99 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.mailbox.jpa.mail; + +import java.util.Optional; + +import javax.inject.Inject; +import javax.persistence.EntityManager; +import javax.persistence.EntityManagerFactory; +import javax.persistence.PersistenceException; + +import org.apache.james.backends.jpa.EntityManagerUtils; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.jpa.JPAId; +import org.apache.james.mailbox.jpa.mail.model.JPAMailbox; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.store.mail.UidProvider; + +public class JPAUidProvider implements UidProvider { + + private final EntityManagerFactory factory; + + @Inject + public JPAUidProvider(EntityManagerFactory factory) { + this.factory = factory; + } + + @Override + public Optional lastUid(Mailbox mailbox) throws MailboxException { + EntityManager manager = factory.createEntityManager(); + try { + return lastUid(mailbox, manager); + } finally { + EntityManagerUtils.safelyClose(manager); + } + } + + public Optional lastUid(Mailbox mailbox, EntityManager manager) throws MailboxException { + try { + JPAId mailboxId = (JPAId) mailbox.getMailboxId(); + long uid = (Long) manager.createNamedQuery("findLastUid").setParameter("idParam", mailboxId.getRawId()).getSingleResult(); + if (uid == 0) { + return Optional.empty(); + } + return Optional.of(MessageUid.of(uid)); + } catch (PersistenceException e) { + throw new MailboxException("Unable to get last uid for mailbox " + mailbox, e); + } + } + + @Override + public MessageUid nextUid(Mailbox mailbox) throws MailboxException { + return nextUid((JPAId) mailbox.getMailboxId()); + } + + @Override + public MessageUid nextUid(MailboxId mailboxId) throws MailboxException { + return nextUid((JPAId) mailboxId); + } + + private MessageUid nextUid(JPAId mailboxId) throws MailboxException { + EntityManager manager = null; + try { + manager = factory.createEntityManager(); + manager.getTransaction().begin(); + JPAMailbox m = manager.find(JPAMailbox.class, mailboxId.getRawId()); + long uid = m.consumeUid(); + manager.persist(m); + manager.getTransaction().commit(); + return MessageUid.of(uid); + } catch (PersistenceException e) { + if (manager != null && manager.getTransaction().isActive()) { + manager.getTransaction().rollback(); + } + throw new MailboxException("Unable to save next uid for mailbox " + mailboxId.serialize(), e); + } finally { + EntityManagerUtils.safelyClose(manager); + } + } + +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/MessageUtils.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/MessageUtils.java new file mode 100644 index 00000000000..bd5d513c5cc --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/MessageUtils.java @@ -0,0 +1,113 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.mail; + +import java.util.Iterator; +import java.util.List; + +import javax.mail.Flags; + +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.UpdatedFlags; +import org.apache.james.mailbox.store.FlagsUpdateCalculator; +import org.apache.james.mailbox.store.mail.ModSeqProvider; +import org.apache.james.mailbox.store.mail.UidProvider; +import org.apache.james.mailbox.store.mail.model.MailboxMessage; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; + +class MessageUtils { + private final UidProvider uidProvider; + private final ModSeqProvider modSeqProvider; + + MessageUtils(UidProvider uidProvider, ModSeqProvider modSeqProvider) { + Preconditions.checkNotNull(uidProvider); + Preconditions.checkNotNull(modSeqProvider); + + this.uidProvider = uidProvider; + this.modSeqProvider = modSeqProvider; + } + + void enrichMessage(Mailbox mailbox, MailboxMessage message) throws MailboxException { + message.setUid(nextUid(mailbox)); + message.setModSeq(nextModSeq(mailbox)); + } + + MessageChangedFlags updateFlags(Mailbox mailbox, FlagsUpdateCalculator flagsUpdateCalculator, + Iterator messages) throws MailboxException { + ImmutableList.Builder updatedFlags = ImmutableList.builder(); + ImmutableList.Builder changedFlags = ImmutableList.builder(); + + ModSeq modSeq = nextModSeq(mailbox); + + while (messages.hasNext()) { + MailboxMessage member = messages.next(); + Flags originalFlags = member.createFlags(); + member.setFlags(flagsUpdateCalculator.buildNewFlags(originalFlags)); + Flags newFlags = member.createFlags(); + if (UpdatedFlags.flagsChanged(originalFlags, newFlags)) { + member.setModSeq(modSeq); + changedFlags.add(member); + } + + updatedFlags.add(UpdatedFlags.builder() + .uid(member.getUid()) + .modSeq(member.getModSeq()) + .newFlags(newFlags) + .oldFlags(originalFlags) + .build()); + } + + return new MessageChangedFlags(updatedFlags.build().iterator(), changedFlags.build()); + } + + @VisibleForTesting + MessageUid nextUid(Mailbox mailbox) throws MailboxException { + return uidProvider.nextUid(mailbox); + } + + @VisibleForTesting + ModSeq nextModSeq(Mailbox mailbox) throws MailboxException { + return modSeqProvider.nextModSeq(mailbox); + } + + static class MessageChangedFlags { + private final Iterator updatedFlags; + private final List changedFlags; + + public MessageChangedFlags(Iterator updatedFlags, List changedFlags) { + this.updatedFlags = updatedFlags; + this.changedFlags = changedFlags; + } + + public Iterator getUpdatedFlags() { + return updatedFlags; + } + + public List getChangedFlags() { + return changedFlags; + } + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAAttachment.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAAttachment.java new file mode 100644 index 00000000000..60e3e9ad4b5 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAAttachment.java @@ -0,0 +1,193 @@ +/*************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.mail.model; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.Arrays; +import java.util.Objects; +import java.util.Optional; + +import javax.persistence.Basic; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import javax.persistence.Lob; +import javax.persistence.NamedQuery; +import javax.persistence.Table; + +import org.apache.james.mailbox.model.AttachmentId; +import org.apache.james.mailbox.model.AttachmentMetadata; +import org.apache.james.mailbox.model.Cid; +import org.apache.james.mailbox.model.MessageAttachmentMetadata; +import org.apache.james.mailbox.store.mail.model.DefaultMessageId; + +@Entity(name = "Attachment") +@Table(name = "JAMES_ATTACHMENT") +@NamedQuery(name = "findAttachmentById", query = "SELECT attachment FROM Attachment attachment WHERE attachment.attachmentId = :idParam") +public class JPAAttachment { + + private static final String TOSTRING_SEPARATOR = " "; + private static final byte[] EMPTY_ARRAY = new byte[]{}; + + @Id + @GeneratedValue + @Column(name = "ATTACHMENT_ID", nullable = false) + private String attachmentId; + + @Basic(optional = false) + @Column(name = "TYPE", nullable = false) + private String type; + + @Basic(optional = false) + @Column(name = "SIZE", nullable = false) + private long size; + + @Basic(optional = false, fetch = FetchType.LAZY) + @Column(name = "CONTENT", length = 1048576000, nullable = false) + @Lob + private byte[] content; + + @Basic(optional = true) + @Column(name = "NAME") + private String name; + + @Basic(optional = true) + @Column(name = "CID") + private String cid; + + @Basic(optional = false) + @Column(name = "INLINE", nullable = false) + private boolean isInline; + + public JPAAttachment() { + } + + public JPAAttachment(MessageAttachmentMetadata messageAttachmentMetadata, byte[] bytes) { + setMetadata(messageAttachmentMetadata, bytes); + } + + public JPAAttachment(MessageAttachmentMetadata messageAttachmentMetadata) { + setMetadata(messageAttachmentMetadata, new byte[0]); + } + + private void setMetadata(MessageAttachmentMetadata messageAttachmentMetadata, byte[] bytes) { + this.name = messageAttachmentMetadata.getName().orElse(null); + messageAttachmentMetadata.getCid() + .ifPresentOrElse(c -> this.cid = c.getValue(), () -> this.cid = ""); + this.type = messageAttachmentMetadata.getAttachment().getType().asString(); + this.size = messageAttachmentMetadata.getAttachment().getSize(); + this.isInline = messageAttachmentMetadata.isInline(); + this.content = bytes; + } + + public AttachmentMetadata toAttachmentMetadata() { + return AttachmentMetadata.builder() + .attachmentId(AttachmentId.from(attachmentId)) + .messageId(new DefaultMessageId()) + .type(type) + .size(size) + .build(); + } + + public MessageAttachmentMetadata toMessageAttachmentMetadata() { + return MessageAttachmentMetadata.builder() + .attachment(toAttachmentMetadata()) + .name(Optional.ofNullable(name)) + .cid(Optional.of(Cid.from(cid))) + .isInline(isInline) + .build(); + } + + public String getAttachmentId() { + return attachmentId; + } + + public String getType() { + return type; + } + + public long getSize() { + return size; + } + + public String getName() { + return name; + } + + public boolean isInline() { + return isInline; + } + + public String getCid() { + return cid; + } + + public InputStream getContent() { + return new ByteArrayInputStream(Objects.requireNonNullElse(content, EMPTY_ARRAY)); + } + + public void setType(String type) { + this.type = type; + } + + public void setSize(long size) { + this.size = size; + } + + public void setContent(byte[] bytes) { + this.content = bytes; + } + + @Override + public String toString() { + return "Attachment ( " + + "attachmentId = " + this.attachmentId + TOSTRING_SEPARATOR + + "name = " + this.type + TOSTRING_SEPARATOR + + "type = " + this.type + TOSTRING_SEPARATOR + + "size = " + this.size + TOSTRING_SEPARATOR + + "cid = " + this.cid + TOSTRING_SEPARATOR + + "isInline = " + this.isInline + TOSTRING_SEPARATOR + + " )"; + } + + @Override + public final boolean equals(Object o) { + if (o instanceof JPAAttachment) { + JPAAttachment that = (JPAAttachment) o; + + return Objects.equals(this.size, that.size) + && Objects.equals(this.attachmentId, that.attachmentId) + && Objects.equals(this.cid, that.cid) + && Arrays.equals(this.content, that.content) + && Objects.equals(this.isInline, that.isInline) + && Objects.equals(this.name, that.name) + && Objects.equals(this.type, that.type); + } + return false; + } + + @Override + public final int hashCode() { + return Objects.hash(attachmentId, type, size, name, cid, isInline); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAMailbox.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAMailbox.java new file mode 100644 index 00000000000..2bedbe5ac1b --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAMailbox.java @@ -0,0 +1,205 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.mailbox.jpa.mail.model; + +import java.util.Objects; + +import javax.persistence.Basic; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import javax.persistence.NamedQueries; +import javax.persistence.NamedQuery; +import javax.persistence.Table; + +import org.apache.james.core.Username; +import org.apache.james.mailbox.jpa.JPAId; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.UidValidity; + +import com.google.common.annotations.VisibleForTesting; + +@Entity(name = "Mailbox") +@Table(name = "JAMES_MAILBOX") +@NamedQueries({ + @NamedQuery(name = "findMailboxById", + query = "SELECT mailbox FROM Mailbox mailbox WHERE mailbox.mailboxId = :idParam"), + @NamedQuery(name = "findMailboxByName", + query = "SELECT mailbox FROM Mailbox mailbox WHERE mailbox.name = :nameParam and mailbox.user is NULL and mailbox.namespace= :namespaceParam"), + @NamedQuery(name = "findMailboxByNameWithUser", + query = "SELECT mailbox FROM Mailbox mailbox WHERE mailbox.name = :nameParam and mailbox.user= :userParam and mailbox.namespace= :namespaceParam"), + @NamedQuery(name = "findMailboxWithNameLikeWithUser", + query = "SELECT mailbox FROM Mailbox mailbox WHERE mailbox.name LIKE :nameParam and mailbox.user= :userParam and mailbox.namespace= :namespaceParam"), + @NamedQuery(name = "findMailboxWithNameLike", + query = "SELECT mailbox FROM Mailbox mailbox WHERE mailbox.name LIKE :nameParam and mailbox.user is NULL and mailbox.namespace= :namespaceParam"), + @NamedQuery(name = "countMailboxesWithNameLikeWithUser", + query = "SELECT COUNT(mailbox) FROM Mailbox mailbox WHERE mailbox.name LIKE :nameParam and mailbox.user= :userParam and mailbox.namespace= :namespaceParam"), + @NamedQuery(name = "countMailboxesWithNameLike", + query = "SELECT COUNT(mailbox) FROM Mailbox mailbox WHERE mailbox.name LIKE :nameParam and mailbox.user is NULL and mailbox.namespace= :namespaceParam"), + @NamedQuery(name = "listMailboxes", + query = "SELECT mailbox FROM Mailbox mailbox"), + @NamedQuery(name = "findHighestModSeq", + query = "SELECT mailbox.highestModSeq FROM Mailbox mailbox WHERE mailbox.mailboxId = :idParam"), + @NamedQuery(name = "findLastUid", + query = "SELECT mailbox.lastUid FROM Mailbox mailbox WHERE mailbox.mailboxId = :idParam") +}) +public class JPAMailbox { + + private static final String TAB = " "; + + public static JPAMailbox from(Mailbox mailbox) { + return new JPAMailbox(mailbox); + } + + /** The value for the mailboxId field */ + @Id + @GeneratedValue + @Column(name = "MAILBOX_ID") + private long mailboxId; + + /** The value for the name field */ + @Basic(optional = false) + @Column(name = "MAILBOX_NAME", nullable = false, length = 200) + private String name; + + /** The value for the uidValidity field */ + @Basic(optional = false) + @Column(name = "MAILBOX_UID_VALIDITY", nullable = false) + private long uidValidity; + + @Basic(optional = true) + @Column(name = "USER_NAME", nullable = true, length = 200) + private String user; + + @Basic(optional = false) + @Column(name = "MAILBOX_NAMESPACE", nullable = false, length = 200) + private String namespace; + + @Basic(optional = false) + @Column(name = "MAILBOX_LAST_UID", nullable = true) + private long lastUid; + + @Basic(optional = false) + @Column(name = "MAILBOX_HIGHEST_MODSEQ", nullable = true) + private long highestModSeq; + + /** + * JPA only + */ + @Deprecated + public JPAMailbox() { + } + + public JPAMailbox(MailboxPath path, UidValidity uidValidity) { + this(path, uidValidity.asLong()); + } + + @VisibleForTesting + public JPAMailbox(MailboxPath path, long uidValidity) { + this.name = path.getName(); + this.user = path.getUser().asString(); + this.namespace = path.getNamespace(); + this.uidValidity = uidValidity; + } + + public JPAMailbox(Mailbox mailbox) { + this(mailbox.generateAssociatedPath(), mailbox.getUidValidity()); + } + + public JPAId getMailboxId() { + return JPAId.of(mailboxId); + } + + public long consumeUid() { + return ++lastUid; + } + + public long consumeModSeq() { + return ++highestModSeq; + } + + public Mailbox toMailbox() { + MailboxPath path = new MailboxPath(namespace, Username.of(user), name); + return new Mailbox(path, sanitizeUidValidity(), new JPAId(mailboxId)); + } + + private UidValidity sanitizeUidValidity() { + if (UidValidity.isValid(uidValidity)) { + return UidValidity.of(uidValidity); + } + UidValidity sanitizedUidValidity = UidValidity.generate(); + // Update storage layer thanks to JPA magics! + setUidValidity(sanitizedUidValidity.asLong()); + return sanitizedUidValidity; + } + + public void setMailboxId(long mailboxId) { + this.mailboxId = mailboxId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getUser() { + return user; + } + + public void setUser(String user) { + this.user = user; + } + + public void setNamespace(String namespace) { + this.namespace = namespace; + } + + public void setUidValidity(long uidValidity) { + this.uidValidity = uidValidity; + } + + @Override + public String toString() { + return "Mailbox ( " + + "mailboxId = " + this.mailboxId + TAB + + "name = " + this.name + TAB + + "uidValidity = " + this.uidValidity + TAB + + " )"; + } + + @Override + public final boolean equals(Object o) { + if (o instanceof JPAMailbox) { + JPAMailbox that = (JPAMailbox) o; + + return Objects.equals(this.mailboxId, that.mailboxId); + } + return false; + } + + @Override + public final int hashCode() { + return Objects.hash(mailboxId); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAMailboxAnnotation.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAMailboxAnnotation.java new file mode 100644 index 00000000000..6627becbf71 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAMailboxAnnotation.java @@ -0,0 +1,99 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.mail.model; + +import javax.persistence.Basic; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.IdClass; +import javax.persistence.NamedQueries; +import javax.persistence.NamedQuery; +import javax.persistence.Table; + +import com.google.common.base.Objects; + +@Entity(name = "MailboxAnnotation") +@Table(name = "JAMES_MAILBOX_ANNOTATION") +@NamedQueries({ + @NamedQuery(name = "retrieveAllAnnotations", query = "SELECT annotation FROM MailboxAnnotation annotation WHERE annotation.mailboxId = :idParam"), + @NamedQuery(name = "retrieveByKey", query = "SELECT annotation FROM MailboxAnnotation annotation WHERE annotation.mailboxId = :idParam AND annotation.key = :keyParam"), + @NamedQuery(name = "countAnnotationsInMailbox", query = "SELECT COUNT(annotation) FROM MailboxAnnotation annotation WHERE annotation.mailboxId = :idParam"), + @NamedQuery(name = "retrieveByKeyLike", query = "SELECT annotation FROM MailboxAnnotation annotation WHERE annotation.mailboxId = :idParam AND annotation.key LIKE :keyParam")}) +@IdClass(JPAMailboxAnnotationId.class) +public class JPAMailboxAnnotation { + + public static final String MAILBOX_ID = "MAILBOX_ID"; + public static final String ANNOTATION_KEY = "ANNOTATION_KEY"; + public static final String VALUE = "VALUE"; + + @Id + @Column(name = MAILBOX_ID) + private long mailboxId; + + @Id + @Column(name = ANNOTATION_KEY, length = 200) + private String key; + + @Basic() + @Column(name = VALUE) + private String value; + + public JPAMailboxAnnotation() { + } + + public JPAMailboxAnnotation(long mailboxId, String key, String value) { + this.mailboxId = mailboxId; + this.key = key; + this.value = value; + } + + public long getMailboxId() { + return mailboxId; + } + + public String getKey() { + return key; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + @Override + public boolean equals(Object o) { + if (o instanceof JPAMailboxAnnotation) { + JPAMailboxAnnotation that = (JPAMailboxAnnotation) o; + return Objects.equal(this.mailboxId, that.mailboxId) + && Objects.equal(this.key, that.key) + && Objects.equal(this.value, that.value); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hashCode(mailboxId, key, value); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAMailboxAnnotationId.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAMailboxAnnotationId.java new file mode 100644 index 00000000000..1fcc71280d3 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAMailboxAnnotationId.java @@ -0,0 +1,62 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.mail.model; + +import java.io.Serializable; + +import javax.persistence.Embeddable; + +import com.google.common.base.Objects; + +@Embeddable +public final class JPAMailboxAnnotationId implements Serializable { + private long mailboxId; + private String key; + + public JPAMailboxAnnotationId(long mailboxId, String key) { + this.mailboxId = mailboxId; + this.key = key; + } + + public JPAMailboxAnnotationId() { + } + + public long getMailboxId() { + return mailboxId; + } + + public String getKey() { + return key; + } + + @Override + public boolean equals(Object o) { + if (o instanceof JPAMailboxAnnotationId) { + JPAMailboxAnnotationId that = (JPAMailboxAnnotationId) o; + return Objects.equal(this.mailboxId, that.mailboxId) && Objects.equal(this.key, that.key); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hashCode(mailboxId, key); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAProperty.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAProperty.java new file mode 100644 index 00000000000..ee7c54e36ce --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAProperty.java @@ -0,0 +1,129 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.mailbox.jpa.mail.model; + +import java.util.Objects; + +import javax.persistence.Basic; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import javax.persistence.Table; + +import org.apache.james.mailbox.store.mail.model.Property; +import org.apache.openjpa.persistence.jdbc.Index; + +@Entity(name = "Property") +@Table(name = "JAMES_MAIL_PROPERTY") +public class JPAProperty { + + /** The system unique key */ + @Id + @GeneratedValue + @Column(name = "PROPERTY_ID", nullable = true) + private long id; + + /** Order within the list of properties */ + @Basic(optional = false) + @Column(name = "PROPERTY_LINE_NUMBER", nullable = false) + @Index(name = "INDEX_PROPERTY_LINE_NUMBER") + private int line; + + /** Local part of the name of this property */ + @Basic(optional = false) + @Column(name = "PROPERTY_LOCAL_NAME", nullable = false, length = 500) + private String localName; + + /** Namespace part of the name of this property */ + @Basic(optional = false) + @Column(name = "PROPERTY_NAME_SPACE", nullable = false, length = 500) + private String namespace; + + /** Value of this property */ + @Basic(optional = false) + @Column(name = "PROPERTY_VALUE", nullable = false, length = 1024) + private String value; + + /** + * @deprecated enhancement only + */ + @Deprecated + public JPAProperty() { + } + + /** + * Constructs a property. + * + * @param localName + * not null + * @param namespace + * not null + * @param value + * not null + */ + public JPAProperty(String namespace, String localName, String value, int order) { + super(); + this.localName = localName; + this.namespace = namespace; + this.value = value; + this.line = order; + } + + /** + * Constructs a property cloned from the given. + * + * @param property + * not null + */ + public JPAProperty(Property property, int order) { + this(property.getNamespace(), property.getLocalName(), property.getValue(), order); + } + + public Property toProperty() { + return new Property(namespace, localName, value); + } + + @Override + public final boolean equals(Object o) { + if (o instanceof JPAProperty) { + JPAProperty that = (JPAProperty) o; + + return Objects.equals(this.id, that.id); + } + return false; + } + + @Override + public final int hashCode() { + return Objects.hash(id); + } + + /** + * Constructs a String with all attributes in name = value + * format. + * + * @return a String representation of this object. + */ + public String toString() { + return "JPAProperty ( " + "id = " + this.id + " " + "localName = " + this.localName + " " + + "namespace = " + this.namespace + " " + "value = " + this.value + " )"; + } + +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAUserFlag.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAUserFlag.java new file mode 100644 index 00000000000..318dfa05f4f --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAUserFlag.java @@ -0,0 +1,120 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.mailbox.jpa.mail.model; + +import javax.persistence.Basic; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import javax.persistence.Table; + +@Entity(name = "UserFlag") +@Table(name = "JAMES_MAIL_USERFLAG") +public class JPAUserFlag { + + + /** The system unique key */ + @Id + @GeneratedValue + @Column(name = "USERFLAG_ID", nullable = true) + private long id; + + /** Local part of the name of this property */ + @Basic(optional = false) + @Column(name = "USERFLAG_NAME", nullable = false, length = 500) + private String name; + + + /** + * @deprecated enhancement only + */ + @Deprecated + public JPAUserFlag() { + + } + + /** + * Constructs a User Flag. + * @param name not null + */ + public JPAUserFlag(String name) { + super(); + this.name = name; + } + + /** + * Constructs a User Flag, cloned from the given. + * @param flag not null + */ + public JPAUserFlag(JPAUserFlag flag) { + this(flag.getName()); + } + + + + /** + * Gets the name. + * @return not null + */ + public String getName() { + return name; + } + + @Override + public int hashCode() { + final int PRIME = 31; + int result = 1; + result = PRIME * result + (int) (id ^ (id >>> 32)); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + final JPAUserFlag other = (JPAUserFlag) obj; + if (id != other.id) { + return false; + } + return true; + } + + /** + * Constructs a String with all attributes + * in name = value format. + * + * @return a String representation + * of this object. + */ + public String toString() { + return "JPAUserFlag ( " + + "id = " + this.id + " " + + "name = " + this.name + + " )"; + } + +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/AbstractJPAMailboxMessage.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/AbstractJPAMailboxMessage.java new file mode 100644 index 00000000000..8b9f1fe0a9f --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/AbstractJPAMailboxMessage.java @@ -0,0 +1,579 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.mailbox.jpa.mail.model.openjpa; + +import java.io.IOException; +import java.io.InputStream; +import java.io.SequenceInputStream; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +import javax.mail.Flags; +import javax.persistence.Basic; +import javax.persistence.CascadeType; +import javax.persistence.Column; +import javax.persistence.Embeddable; +import javax.persistence.FetchType; +import javax.persistence.Id; +import javax.persistence.IdClass; +import javax.persistence.ManyToOne; +import javax.persistence.MappedSuperclass; +import javax.persistence.NamedQuery; +import javax.persistence.OneToMany; +import javax.persistence.OrderBy; + +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.jpa.JPAId; +import org.apache.james.mailbox.jpa.mail.model.JPAMailbox; +import org.apache.james.mailbox.jpa.mail.model.JPAProperty; +import org.apache.james.mailbox.jpa.mail.model.JPAUserFlag; +import org.apache.james.mailbox.model.AttachmentId; +import org.apache.james.mailbox.model.ComposedMessageId; +import org.apache.james.mailbox.model.ComposedMessageIdWithMetaData; +import org.apache.james.mailbox.model.MessageAttachmentMetadata; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.model.ParsedAttachment; +import org.apache.james.mailbox.model.ThreadId; +import org.apache.james.mailbox.store.mail.model.DefaultMessageId; +import org.apache.james.mailbox.store.mail.model.DelegatingMailboxMessage; +import org.apache.james.mailbox.store.mail.model.FlagsFactory; +import org.apache.james.mailbox.store.mail.model.MailboxMessage; +import org.apache.james.mailbox.store.mail.model.Property; +import org.apache.james.mailbox.store.mail.model.impl.MessageParser; +import org.apache.james.mailbox.store.mail.model.impl.Properties; +import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; +import org.apache.openjpa.persistence.jdbc.ElementJoinColumn; +import org.apache.openjpa.persistence.jdbc.ElementJoinColumns; +import org.apache.openjpa.persistence.jdbc.Index; + +import com.github.fge.lambdas.Throwing; +import com.google.common.base.Objects; +import com.google.common.collect.ImmutableList; + +/** + * Abstract base class for JPA based implementations of + * {@link DelegatingMailboxMessage} + */ +@IdClass(AbstractJPAMailboxMessage.MailboxIdUidKey.class) +@NamedQuery(name = "findRecentMessageUidsInMailbox", query = "SELECT message.uid FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.recent = TRUE ORDER BY message.uid ASC") +@NamedQuery(name = "listUidsInMailbox", query = "SELECT message.uid FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam ORDER BY message.uid ASC") +@NamedQuery(name = "findUnseenMessagesInMailboxOrderByUid", query = "SELECT message FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.seen = FALSE ORDER BY message.uid ASC") +@NamedQuery(name = "findMessagesInMailbox", query = "SELECT message FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam ORDER BY message.uid ASC") +@NamedQuery(name = "findMessagesInMailboxBetweenUIDs", query = "SELECT message FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.uid BETWEEN :fromParam AND :toParam ORDER BY message.uid ASC") +@NamedQuery(name = "findMessagesInMailboxWithUID", query = "SELECT message FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.uid=:uidParam ORDER BY message.uid ASC") +@NamedQuery(name = "findMessagesInMailboxAfterUID", query = "SELECT message FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.uid>=:uidParam ORDER BY message.uid ASC") +@NamedQuery(name = "findDeletedMessagesInMailbox", query = "SELECT message FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.deleted=TRUE ORDER BY message.uid ASC") +@NamedQuery(name = "findDeletedMessagesInMailboxBetweenUIDs", query = "SELECT message FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.uid BETWEEN :fromParam AND :toParam AND message.deleted=TRUE ORDER BY message.uid ASC") +@NamedQuery(name = "findDeletedMessagesInMailboxWithUID", query = "SELECT message FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.uid=:uidParam AND message.deleted=TRUE ORDER BY message.uid ASC") +@NamedQuery(name = "findDeletedMessagesInMailboxAfterUID", query = "SELECT message FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.uid>=:uidParam AND message.deleted=TRUE ORDER BY message.uid ASC") + +@NamedQuery(name = "deleteMessagesInMailbox", query = "DELETE FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam") +@NamedQuery(name = "deleteMessagesInMailboxBetweenUIDs", query = "DELETE FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.uid BETWEEN :fromParam AND :toParam") +@NamedQuery(name = "deleteMessagesInMailboxWithUID", query = "DELETE FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.uid=:uidParam") +@NamedQuery(name = "deleteMessagesInMailboxAfterUID", query = "DELETE FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.uid>=:uidParam") + +@NamedQuery(name = "countUnseenMessagesInMailbox", query = "SELECT COUNT(message) FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.seen=FALSE") +@NamedQuery(name = "countMessagesInMailbox", query = "SELECT COUNT(message) FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam") +@NamedQuery(name = "deleteMessages", query = "DELETE FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam") +@NamedQuery(name = "findLastUidInMailbox", query = "SELECT message.uid FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam ORDER BY message.uid DESC") +@NamedQuery(name = "findHighestModSeqInMailbox", query = "SELECT message.modSeq FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam ORDER BY message.modSeq DESC") +@MappedSuperclass +public abstract class AbstractJPAMailboxMessage implements MailboxMessage { + private static final String TOSTRING_SEPARATOR = " "; + + /** + * Identifies composite key + */ + @Embeddable + public static class MailboxIdUidKey implements Serializable { + + private static final long serialVersionUID = 7847632032426660997L; + + public MailboxIdUidKey() { + } + + /** + * The value for the mailbox field + */ + public long mailbox; + + /** + * The value for the uid field + */ + public long uid; + + @Override + public int hashCode() { + final int PRIME = 31; + int result = 1; + result = PRIME * result + (int) (mailbox ^ (mailbox >>> 32)); + result = PRIME * result + (int) (uid ^ (uid >>> 32)); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + final MailboxIdUidKey other = (MailboxIdUidKey) obj; + if (mailbox != other.mailbox) { + return false; + } + return uid == other.uid; + } + + } + + /** + * The value for the mailboxId field + */ + @Id + @ManyToOne(cascade = {CascadeType.PERSIST, CascadeType.REFRESH, CascadeType.MERGE}, fetch = FetchType.EAGER) + @Column(name = "MAILBOX_ID", nullable = true) + private JPAMailbox mailbox; + + /** + * The value for the uid field + */ + @Id + @Column(name = "MAIL_UID") + private long uid; + + /** + * The value for the modSeq field + */ + @Index + @Column(name = "MAIL_MODSEQ") + private long modSeq; + + /** + * The value for the internalDate field + */ + @Basic(optional = false) + @Column(name = "MAIL_DATE") + private Date internalDate; + + /** + * The value for the answered field + */ + @Basic(optional = false) + @Column(name = "MAIL_IS_ANSWERED", nullable = false) + private boolean answered = false; + + /** + * The value for the deleted field + */ + @Basic(optional = false) + @Column(name = "MAIL_IS_DELETED", nullable = false) + @Index + private boolean deleted = false; + + /** + * The value for the draft field + */ + @Basic(optional = false) + @Column(name = "MAIL_IS_DRAFT", nullable = false) + private boolean draft = false; + + /** + * The value for the flagged field + */ + @Basic(optional = false) + @Column(name = "MAIL_IS_FLAGGED", nullable = false) + private boolean flagged = false; + + /** + * The value for the recent field + */ + @Basic(optional = false) + @Column(name = "MAIL_IS_RECENT", nullable = false) + @Index + private boolean recent = false; + + /** + * The value for the seen field + */ + @Basic(optional = false) + @Column(name = "MAIL_IS_SEEN", nullable = false) + @Index + private boolean seen = false; + + /** + * The first body octet + */ + @Basic(optional = false) + @Column(name = "MAIL_BODY_START_OCTET", nullable = false) + private int bodyStartOctet; + + /** + * Number of octets in the full document content + */ + @Basic(optional = false) + @Column(name = "MAIL_CONTENT_OCTETS_COUNT", nullable = false) + private long contentOctets; + + /** + * MIME media type + */ + @Basic(optional = true) + @Column(name = "MAIL_MIME_TYPE", nullable = true, length = 200) + private String mediaType; + + /** + * MIME subtype + */ + @Basic(optional = true) + @Column(name = "MAIL_MIME_SUBTYPE", nullable = true, length = 200) + private String subType; + + /** + * THE CRFL count when this document is textual, null otherwise + */ + @Basic(optional = true) + @Column(name = "MAIL_TEXTUAL_LINE_COUNT", nullable = true) + private Long textualLineCount; + + /** + * Metadata for this message + */ + @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER) + @OrderBy("line") + @ElementJoinColumns({@ElementJoinColumn(name = "MAILBOX_ID", referencedColumnName = "MAILBOX_ID"), + @ElementJoinColumn(name = "MAIL_UID", referencedColumnName = "MAIL_UID")}) + private List properties; + + @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER, orphanRemoval = true) + @OrderBy("id") + @ElementJoinColumns({@ElementJoinColumn(name = "MAILBOX_ID", referencedColumnName = "MAILBOX_ID"), + @ElementJoinColumn(name = "MAIL_UID", referencedColumnName = "MAIL_UID")}) + private List userFlags; + + + protected AbstractJPAMailboxMessage() { + } + + protected AbstractJPAMailboxMessage(JPAMailbox mailbox, Date internalDate, Flags flags, long contentOctets, + int bodyStartOctet, PropertyBuilder propertyBuilder) { + this.mailbox = mailbox; + this.internalDate = internalDate; + userFlags = new ArrayList<>(); + + setFlags(flags); + this.contentOctets = contentOctets; + this.bodyStartOctet = bodyStartOctet; + Properties properties = propertyBuilder.build(); + this.textualLineCount = properties.getTextualLineCount(); + this.mediaType = properties.getMediaType(); + this.subType = properties.getSubType(); + final List propertiesAsList = properties.toProperties(); + this.properties = new ArrayList<>(propertiesAsList.size()); + int order = 0; + for (Property property : propertiesAsList) { + this.properties.add(new JPAProperty(property, order++)); + } + + } + + /** + * Constructs a copy of the given message. All properties are cloned except + * mailbox and UID. + * + * @param mailbox new mailbox + * @param uid new UID + * @param modSeq new modSeq + * @param original message to be copied, not null + */ + protected AbstractJPAMailboxMessage(JPAMailbox mailbox, MessageUid uid, ModSeq modSeq, MailboxMessage original) + throws MailboxException { + super(); + this.mailbox = mailbox; + this.uid = uid.asLong(); + this.modSeq = modSeq.asLong(); + this.userFlags = new ArrayList<>(); + setFlags(original.createFlags()); + + // A copy of a message is recent + // See MAILBOX-85 + this.recent = true; + + this.contentOctets = original.getFullContentOctets(); + this.bodyStartOctet = (int) (original.getFullContentOctets() - original.getBodyOctets()); + this.internalDate = original.getInternalDate(); + + this.textualLineCount = original.getTextualLineCount(); + this.mediaType = original.getMediaType(); + this.subType = original.getSubType(); + final List properties = original.getProperties().toProperties(); + this.properties = new ArrayList<>(properties.size()); + int order = 0; + for (Property property : properties) { + this.properties.add(new JPAProperty(property, order++)); + } + } + + @Override + public int hashCode() { + return Objects.hashCode(getMailboxId().getRawId(), uid); + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof AbstractJPAMailboxMessage) { + AbstractJPAMailboxMessage other = (AbstractJPAMailboxMessage) obj; + return Objects.equal(getMailboxId(), other.getMailboxId()) + && Objects.equal(uid, other.getUid()); + } + return false; + } + + @Override + public ComposedMessageIdWithMetaData getComposedMessageIdWithMetaData() { + return ComposedMessageIdWithMetaData.builder() + .modSeq(getModSeq()) + .flags(createFlags()) + .composedMessageId(new ComposedMessageId(mailbox.getMailboxId(), getMessageId(), MessageUid.of(uid))) + .threadId(getThreadId()) + .build(); + } + + @Override + public ModSeq getModSeq() { + return ModSeq.of(modSeq); + } + + @Override + public void setModSeq(ModSeq modSeq) { + this.modSeq = modSeq.asLong(); + } + + @Override + public String getMediaType() { + return mediaType; + } + + @Override + public String getSubType() { + return subType; + } + + /** + * Gets a read-only list of meta-data properties. For properties with + * multiple values, this list will contain several enteries with the same + * namespace and local name. + * + * @return unmodifiable list of meta-data, not null + */ + @Override + public Properties getProperties() { + return new PropertyBuilder(properties.stream() + .map(JPAProperty::toProperty) + .collect(ImmutableList.toImmutableList())) + .build(); + } + + @Override + public Long getTextualLineCount() { + return textualLineCount; + } + + @Override + public long getFullContentOctets() { + return contentOctets; + } + + protected int getBodyStartOctet() { + return bodyStartOctet; + } + + @Override + public Date getInternalDate() { + return internalDate; + } + + @Override + public JPAId getMailboxId() { + return getMailbox().getMailboxId(); + } + + @Override + public MessageUid getUid() { + return MessageUid.of(uid); + } + + @Override + public boolean isAnswered() { + return answered; + } + + @Override + public boolean isDeleted() { + return deleted; + } + + @Override + public boolean isDraft() { + return draft; + } + + @Override + public boolean isFlagged() { + return flagged; + } + + @Override + public boolean isRecent() { + return recent; + } + + @Override + public boolean isSeen() { + return seen; + } + + @Override + public void setUid(MessageUid uid) { + this.uid = uid.asLong(); + } + + @Override + public void setSaveDate(Date saveDate) { + + } + + @Override + public long getHeaderOctets() { + return bodyStartOctet; + } + + @Override + public void setFlags(Flags flags) { + answered = flags.contains(Flags.Flag.ANSWERED); + deleted = flags.contains(Flags.Flag.DELETED); + draft = flags.contains(Flags.Flag.DRAFT); + flagged = flags.contains(Flags.Flag.FLAGGED); + recent = flags.contains(Flags.Flag.RECENT); + seen = flags.contains(Flags.Flag.SEEN); + + String[] userflags = flags.getUserFlags(); + userFlags.clear(); + for (String userflag : userflags) { + userFlags.add(new JPAUserFlag(userflag)); + } + } + + /** + * Utility getter on Mailbox. + */ + public JPAMailbox getMailbox() { + return mailbox; + } + + @Override + public Flags createFlags() { + return FlagsFactory.createFlags(this, createUserFlags()); + } + + protected String[] createUserFlags() { + return userFlags.stream() + .map(JPAUserFlag::getName) + .toArray(String[]::new); + } + + /** + * Utility setter on Mailbox. + */ + public void setMailbox(JPAMailbox mailbox) { + this.mailbox = mailbox; + } + + @Override + public InputStream getFullContent() throws IOException { + return new SequenceInputStream(getHeaderContent(), getBodyContent()); + } + + @Override + public long getBodyOctets() { + return getFullContentOctets() - getBodyStartOctet(); + } + + @Override + public MessageId getMessageId() { + return new DefaultMessageId(); + } + + @Override + public ThreadId getThreadId() { + return new ThreadId(getMessageId()); + } + + @Override + public Optional getSaveDate() { + return Optional.empty(); + } + + public String toString() { + return "message(" + + "mailboxId = " + this.getMailboxId() + TOSTRING_SEPARATOR + + "uid = " + this.uid + TOSTRING_SEPARATOR + + "internalDate = " + this.internalDate + TOSTRING_SEPARATOR + + "answered = " + this.answered + TOSTRING_SEPARATOR + + "deleted = " + this.deleted + TOSTRING_SEPARATOR + + "draft = " + this.draft + TOSTRING_SEPARATOR + + "flagged = " + this.flagged + TOSTRING_SEPARATOR + + "recent = " + this.recent + TOSTRING_SEPARATOR + + "seen = " + this.seen + TOSTRING_SEPARATOR + + " )"; + } + + @Override + public List getAttachments() { + try { + AtomicInteger counter = new AtomicInteger(0); + MessageParser.ParsingResult parsingResult = new MessageParser().retrieveAttachments(getFullContent()); + ImmutableList result = parsingResult + .getAttachments() + .stream() + .map(Throwing.function( + attachmentMetadata -> attachmentMetadata.asMessageAttachment(generateFixedAttachmentId(counter.incrementAndGet()), getMessageId())) + .sneakyThrow()) + .collect(ImmutableList.toImmutableList()); + parsingResult.dispose(); + return result; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private AttachmentId generateFixedAttachmentId(int position) { + return AttachmentId.from(getMailboxId().serialize() + "-" + getUid().asLong() + "-" + position); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/EncryptDecryptHelper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/EncryptDecryptHelper.java new file mode 100644 index 00000000000..40dfd0e53ef --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/EncryptDecryptHelper.java @@ -0,0 +1,66 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.mailbox.jpa.mail.model.openjpa; + +import org.jasypt.encryption.pbe.StandardPBEByteEncryptor; + +/** + * Helper class for encrypt and de-crypt data + * + * + */ +public class EncryptDecryptHelper { + + // Use one static instance as it is thread safe + private static final StandardPBEByteEncryptor encryptor = new StandardPBEByteEncryptor(); + + + /** + * Set the password for encrypt / de-crypt. This MUST be done before + * the usage of {@link #getDecrypted(byte[])} and {@link #getEncrypted(byte[])}. + * + * So to be safe its the best to call this in a constructor + * + * @param pass + */ + public static void init(String pass) { + encryptor.setPassword(pass); + } + + /** + * Encrypt the given array and return the encrypted one + * + * @param array + * @return enc-array + */ + public static byte[] getEncrypted(byte[] array) { + return encryptor.encrypt(array); + } + + /** + * Decrypt the given array and return the de-crypted one + * + * @param array + * @return dec-array + */ + public static byte[] getDecrypted(byte[] array) { + return encryptor.decrypt(array); + } + +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAEncryptedMailboxMessage.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAEncryptedMailboxMessage.java new file mode 100644 index 00000000000..062017947ea --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAEncryptedMailboxMessage.java @@ -0,0 +1,112 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.mailbox.jpa.mail.model.openjpa; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Date; + +import javax.mail.Flags; +import javax.persistence.Basic; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.Lob; +import javax.persistence.Table; + +import org.apache.commons.io.IOUtils; +import org.apache.commons.io.input.BoundedInputStream; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.jpa.mail.model.JPAMailbox; +import org.apache.james.mailbox.model.Content; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.store.mail.model.MailboxMessage; +import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; +import org.apache.openjpa.persistence.Externalizer; +import org.apache.openjpa.persistence.Factory; + +@Entity(name = "MailboxMessage") +@Table(name = "JAMES_MAIL") +public class JPAEncryptedMailboxMessage extends AbstractJPAMailboxMessage { + + /** The value for the body field. Lazy loaded */ + /** We use a max length to represent 1gb data. Thats prolly overkill, but who knows */ + @Basic(optional = false, fetch = FetchType.LAZY) + @Column(name = "MAIL_BYTES", length = 1048576000, nullable = false) + @Externalizer("EncryptDecryptHelper.getEncrypted") + @Factory("EncryptDecryptHelper.getDecrypted") + @Lob private byte[] body; + + + /** The value for the header field. Lazy loaded */ + /** We use a max length to represent 1gb data. Thats prolly overkill, but who knows */ + @Basic(optional = false, fetch = FetchType.LAZY) + @Column(name = "HEADER_BYTES", length = 10485760, nullable = false) + @Externalizer("EncryptDecryptHelper.getEncrypted") + @Factory("EncryptDecryptHelper.getDecrypted") + @Lob private byte[] header; + + public JPAEncryptedMailboxMessage(JPAMailbox mailbox, Date internalDate, int size, Flags flags, Content content, int bodyStartOctet, PropertyBuilder propertyBuilder) throws MailboxException { + super(mailbox, internalDate, flags, size, bodyStartOctet, propertyBuilder); + try { + int headerEnd = bodyStartOctet; + if (headerEnd < 0) { + headerEnd = 0; + } + InputStream stream = content.getInputStream(); + this.header = IOUtils.toByteArray(new BoundedInputStream(stream, getBodyStartOctet())); + this.body = IOUtils.toByteArray(stream); + + } catch (IOException e) { + throw new MailboxException("Unable to parse message",e); + } + } + + /** + * Create a copy of the given message + */ + public JPAEncryptedMailboxMessage(JPAMailbox mailbox, MessageUid uid, ModSeq modSeq, MailboxMessage message) throws MailboxException { + super(mailbox, uid, modSeq, message); + try { + this.body = IOUtils.toByteArray(message.getBodyContent()); + this.header = IOUtils.toByteArray(message.getHeaderContent()); + } catch (IOException e) { + throw new MailboxException("Unable to parse message",e); + } + } + + + @Override + public InputStream getBodyContent() throws IOException { + return new ByteArrayInputStream(body); + } + + @Override + public InputStream getHeaderContent() throws IOException { + return new ByteArrayInputStream(header); + } + + @Override + public MailboxMessage copy(Mailbox mailbox) throws MailboxException { + return new JPAEncryptedMailboxMessage(JPAMailbox.from(mailbox), getUid(), getModSeq(), this); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAMailboxMessage.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAMailboxMessage.java new file mode 100644 index 00000000000..9ad9be12ad8 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAMailboxMessage.java @@ -0,0 +1,126 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.mailbox.jpa.mail.model.openjpa; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Date; + +import javax.mail.Flags; +import javax.persistence.Basic; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.Lob; +import javax.persistence.Table; + +import org.apache.commons.io.IOUtils; +import org.apache.commons.io.input.BoundedInputStream; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.jpa.mail.model.JPAMailbox; +import org.apache.james.mailbox.model.Content; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.store.mail.model.MailboxMessage; +import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; + +import com.google.common.annotations.VisibleForTesting; + +@Entity(name = "MailboxMessage") +@Table(name = "JAMES_MAIL") +public class JPAMailboxMessage extends AbstractJPAMailboxMessage { + + private static final byte[] EMPTY_ARRAY = new byte[] {}; + + /** The value for the body field. Lazy loaded */ + /** We use a max length to represent 1gb data. Thats prolly overkill, but who knows */ + @Basic(optional = false, fetch = FetchType.LAZY) + @Column(name = "MAIL_BYTES", length = 1048576000, nullable = false) + @Lob private byte[] body; + + + /** The value for the header field. Lazy loaded */ + /** We use a max length to represent 10mb data. Thats prolly overkill, but who knows */ + @Basic(optional = false, fetch = FetchType.LAZY) + @Column(name = "HEADER_BYTES", length = 10485760, nullable = false) + @Lob private byte[] header; + + + public JPAMailboxMessage() { + + } + + @VisibleForTesting + protected JPAMailboxMessage(byte[] header, byte[] body) { + this.header = header; + this.body = body; + } + + public JPAMailboxMessage(JPAMailbox mailbox, Date internalDate, int size, Flags flags, Content content, int bodyStartOctet, PropertyBuilder propertyBuilder) throws MailboxException { + super(mailbox, internalDate, flags, size, bodyStartOctet, propertyBuilder); + try { + int headerEnd = bodyStartOctet; + if (headerEnd < 0) { + headerEnd = 0; + } + InputStream stream = content.getInputStream(); + this.header = IOUtils.toByteArray(new BoundedInputStream(stream, headerEnd)); + this.body = IOUtils.toByteArray(stream); + + } catch (IOException e) { + throw new MailboxException("Unable to parse message",e); + } + } + + /** + * Create a copy of the given message + */ + public JPAMailboxMessage(JPAMailbox mailbox, MessageUid uid, ModSeq modSeq, MailboxMessage message) throws MailboxException { + super(mailbox, uid, modSeq, message); + try { + this.body = IOUtils.toByteArray(message.getBodyContent()); + this.header = IOUtils.toByteArray(message.getHeaderContent()); + } catch (IOException e) { + throw new MailboxException("Unable to parse message",e); + } + } + + @Override + public InputStream getBodyContent() throws IOException { + if (body == null) { + return new ByteArrayInputStream(EMPTY_ARRAY); + } + return new ByteArrayInputStream(body); + } + + @Override + public InputStream getHeaderContent() throws IOException { + if (header == null) { + return new ByteArrayInputStream(EMPTY_ARRAY); + } + return new ByteArrayInputStream(header); + } + + @Override + public MailboxMessage copy(Mailbox mailbox) throws MailboxException { + return new JPAMailboxMessage(JPAMailbox.from(mailbox), getUid(), getModSeq(), this); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAMailboxMessageWithAttachmentStorage.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAMailboxMessageWithAttachmentStorage.java new file mode 100644 index 00000000000..2e4e1a969e4 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAMailboxMessageWithAttachmentStorage.java @@ -0,0 +1,155 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.mailbox.jpa.mail.model.openjpa; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; + +import javax.mail.Flags; +import javax.persistence.Basic; +import javax.persistence.CascadeType; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.Lob; +import javax.persistence.OneToMany; +import javax.persistence.OrderBy; +import javax.persistence.Table; + +import org.apache.commons.io.IOUtils; +import org.apache.commons.io.input.BoundedInputStream; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.jpa.mail.model.JPAAttachment; +import org.apache.james.mailbox.jpa.mail.model.JPAMailbox; +import org.apache.james.mailbox.model.Content; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MessageAttachmentMetadata; +import org.apache.james.mailbox.store.mail.model.MailboxMessage; +import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; +import org.apache.openjpa.persistence.jdbc.ElementJoinColumn; +import org.apache.openjpa.persistence.jdbc.ElementJoinColumns; + +@Entity(name = "MailboxMessage") +@Table(name = "JAMES_MAIL") +public class JPAMailboxMessageWithAttachmentStorage extends AbstractJPAMailboxMessage { + + private static final byte[] EMPTY_ARRAY = new byte[] {}; + + /** The value for the body field. Lazy loaded */ + /** We use a max length to represent 1gb data. Thats prolly overkill, but who knows */ + @Basic(optional = false, fetch = FetchType.LAZY) + @Column(name = "MAIL_BYTES", length = 1048576000, nullable = false) + @Lob + private byte[] body; + + /** The value for the header field. Lazy loaded */ + /** We use a max length to represent 10mb data. Thats prolly overkill, but who knows */ + @Basic(optional = false, fetch = FetchType.LAZY) + @Column(name = "HEADER_BYTES", length = 10485760, nullable = false) + @Lob private byte[] header; + + /** + * Metadata for attachments + */ + @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER) + @OrderBy("attachmentId") + @ElementJoinColumns({@ElementJoinColumn(name = "MAILBOX_ID", referencedColumnName = "MAILBOX_ID"), + @ElementJoinColumn(name = "MAIL_UID", referencedColumnName = "MAIL_UID")}) + private List attachments; + + + public JPAMailboxMessageWithAttachmentStorage() { + + } + + public JPAMailboxMessageWithAttachmentStorage(JPAMailbox mailbox, Date internalDate, int size, Flags flags, Content content, int bodyStartOctet, PropertyBuilder propertyBuilder) throws MailboxException { + super(mailbox, internalDate, flags, size, bodyStartOctet, propertyBuilder); + try { + int headerEnd = bodyStartOctet; + if (headerEnd < 0) { + headerEnd = 0; + } + InputStream stream = content.getInputStream(); + this.header = IOUtils.toByteArray(new BoundedInputStream(stream, headerEnd)); + this.body = IOUtils.toByteArray(stream); + + } catch (IOException e) { + throw new MailboxException("Unable to parse message",e); + } + attachments = new ArrayList<>(); + } + + /** + * Create a copy of the given message + */ + public JPAMailboxMessageWithAttachmentStorage(JPAMailbox mailbox, MessageUid uid, ModSeq modSeq, MailboxMessage message) throws MailboxException { + super(mailbox, uid, modSeq, message); + try { + this.body = IOUtils.toByteArray(message.getBodyContent()); + this.header = IOUtils.toByteArray(message.getHeaderContent()); + } catch (IOException e) { + throw new MailboxException("Unable to parse message",e); + } + attachments = new ArrayList<>(); + + } + + @Override + public InputStream getBodyContent() throws IOException { + if (body == null) { + return new ByteArrayInputStream(EMPTY_ARRAY); + } + return new ByteArrayInputStream(body); + } + + @Override + public InputStream getHeaderContent() throws IOException { + if (header == null) { + return new ByteArrayInputStream(EMPTY_ARRAY); + } + return new ByteArrayInputStream(header); + } + + @Override + public MailboxMessage copy(Mailbox mailbox) throws MailboxException { + return new JPAMailboxMessage(JPAMailbox.from(mailbox), getUid(), getModSeq(), this); + } + + /** + * Utility attachments' setter. + */ + public void setAttachments(List attachments) { + this.attachments = attachments; + } + + @Override + public List getAttachments() { + + return this.attachments.stream() + .map(JPAAttachment::toMessageAttachmentMetadata) + .collect(Collectors.toList()); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAStreamingMailboxMessage.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAStreamingMailboxMessage.java new file mode 100644 index 00000000000..8ffbd6090b3 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAStreamingMailboxMessage.java @@ -0,0 +1,125 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.mailbox.jpa.mail.model.openjpa; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Date; + +import javax.mail.Flags; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.Table; + +import org.apache.commons.io.input.BoundedInputStream; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.jpa.mail.model.JPAMailbox; +import org.apache.james.mailbox.model.Content; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.store.mail.model.MailboxMessage; +import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; +import org.apache.openjpa.persistence.Persistent; + +/** + * JPA implementation of {@link AbstractJPAMailboxMessage} which use openjpas {@link Persistent} type to + * be able to stream the message content without loading it into the memory at all. + * + * This is not supported for all DB's yet. See
    Additional JPA Mappings + * + * If your DB is not supported by this, use {@link JPAMailboxMessage} + * + * TODO: Fix me! + */ +@Entity(name = "MailboxMessage") +@Table(name = "JAMES_MAIL") +public class JPAStreamingMailboxMessage extends AbstractJPAMailboxMessage { + + @Persistent(optional = false, fetch = FetchType.LAZY) + @Column(name = "MAIL_BYTES", length = 1048576000, nullable = false) + private InputStream body; + + @Persistent(optional = false, fetch = FetchType.LAZY) + @Column(name = "HEADER_BYTES", length = 10485760, nullable = false) + private InputStream header; + + private final Content content; + + public JPAStreamingMailboxMessage(JPAMailbox mailbox, Date internalDate, int size, Flags flags, Content content, int bodyStartOctet, PropertyBuilder propertyBuilder) throws MailboxException { + super(mailbox, internalDate, flags, size, bodyStartOctet, propertyBuilder); + this.content = content; + + try { + this.header = new BoundedInputStream(content.getInputStream(), getBodyStartOctet()); + InputStream bodyStream = content.getInputStream(); + bodyStream.skip(getBodyStartOctet()); + this.body = bodyStream; + + } catch (IOException e) { + throw new MailboxException("Unable to parse message",e); + } + } + + /** + * Create a copy of the given message + */ + public JPAStreamingMailboxMessage(JPAMailbox mailbox, MessageUid uid, ModSeq modSeq, MailboxMessage message) throws MailboxException { + super(mailbox, uid, modSeq, message); + this.content = new Content() { + @Override + public InputStream getInputStream() throws IOException { + return message.getFullContent(); + } + + @Override + public long size() { + return message.getFullContentOctets(); + } + }; + try { + this.header = getHeaderContent(); + this.body = getBodyContent(); + } catch (IOException e) { + throw new MailboxException("Unable to parse message",e); + } + } + + @Override + public InputStream getBodyContent() throws IOException { + InputStream inputStream = content.getInputStream(); + inputStream.skip(getBodyStartOctet()); + return inputStream; + } + + @Override + public InputStream getHeaderContent() throws IOException { + int headerEnd = getBodyStartOctet() - 2; + if (headerEnd < 0) { + headerEnd = 0; + } + return new BoundedInputStream(content.getInputStream(), headerEnd); + } + + @Override + public MailboxMessage copy(Mailbox mailbox) throws MailboxException { + return new JPAStreamingMailboxMessage(JPAMailbox.from(mailbox), getUid(), getModSeq(), this); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMailboxManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMailboxManager.java new file mode 100644 index 00000000000..5346770f52a --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMailboxManager.java @@ -0,0 +1,94 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.openjpa; + +import java.time.Clock; +import java.util.EnumSet; + +import javax.inject.Inject; + +import org.apache.james.events.EventBus; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.SessionProvider; +import org.apache.james.mailbox.jpa.JPAMailboxSessionMapperFactory; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.store.JVMMailboxPathLocker; +import org.apache.james.mailbox.store.MailboxManagerConfiguration; +import org.apache.james.mailbox.store.PreDeletionHooks; +import org.apache.james.mailbox.store.StoreMailboxAnnotationManager; +import org.apache.james.mailbox.store.StoreMailboxManager; +import org.apache.james.mailbox.store.StoreMessageManager; +import org.apache.james.mailbox.store.StoreRightManager; +import org.apache.james.mailbox.store.mail.ThreadIdGuessingAlgorithm; +import org.apache.james.mailbox.store.mail.model.impl.MessageParser; +import org.apache.james.mailbox.store.quota.QuotaComponents; +import org.apache.james.mailbox.store.search.MessageSearchIndex; + +/** + * OpenJPA implementation of MailboxManager + * + */ +public class OpenJPAMailboxManager extends StoreMailboxManager { + public static final EnumSet MAILBOX_CAPABILITIES = EnumSet.of(MailboxCapabilities.UserFlag, + MailboxCapabilities.Namespace, + MailboxCapabilities.Move, + MailboxCapabilities.Annotation); + + @Inject + public OpenJPAMailboxManager(JPAMailboxSessionMapperFactory mapperFactory, + SessionProvider sessionProvider, + MessageParser messageParser, + MessageId.Factory messageIdFactory, + EventBus eventBus, + StoreMailboxAnnotationManager annotationManager, + StoreRightManager storeRightManager, + QuotaComponents quotaComponents, + MessageSearchIndex index, + ThreadIdGuessingAlgorithm threadIdGuessingAlgorithm, + Clock clock) { + super(mapperFactory, sessionProvider, new JVMMailboxPathLocker(), + messageParser, messageIdFactory, annotationManager, + eventBus, storeRightManager, quotaComponents, + index, MailboxManagerConfiguration.DEFAULT, PreDeletionHooks.NO_PRE_DELETION_HOOK, threadIdGuessingAlgorithm, clock); + } + + @Override + protected StoreMessageManager createMessageManager(Mailbox mailboxRow, MailboxSession session) { + return new OpenJPAMessageManager(getMapperFactory(), + getMessageSearchIndex(), + getEventBus(), + getLocker(), + mailboxRow, + getQuotaComponents().getQuotaManager(), + getQuotaComponents().getQuotaRootResolver(), + getMessageIdFactory(), + configuration.getBatchSizes(), + getStoreRightManager(), + getThreadIdGuessingAlgorithm(), + getClock()); + } + + @Override + public EnumSet getSupportedMailboxCapabilities() { + return MAILBOX_CAPABILITIES; + } + +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMessageFactory.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMessageFactory.java new file mode 100644 index 00000000000..79b08c492e3 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMessageFactory.java @@ -0,0 +1,67 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.openjpa; + +import java.util.Date; +import java.util.List; + +import javax.mail.Flags; + +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.jpa.mail.model.JPAMailbox; +import org.apache.james.mailbox.jpa.mail.model.openjpa.AbstractJPAMailboxMessage; +import org.apache.james.mailbox.jpa.mail.model.openjpa.JPAEncryptedMailboxMessage; +import org.apache.james.mailbox.jpa.mail.model.openjpa.JPAMailboxMessage; +import org.apache.james.mailbox.jpa.mail.model.openjpa.JPAStreamingMailboxMessage; +import org.apache.james.mailbox.model.Content; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MessageAttachmentMetadata; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.model.ThreadId; +import org.apache.james.mailbox.store.MessageFactory; +import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; + +public class OpenJPAMessageFactory implements MessageFactory { + private final AdvancedFeature feature; + + public OpenJPAMessageFactory(AdvancedFeature feature) { + this.feature = feature; + } + + public enum AdvancedFeature { + None, + Streaming, + Encryption + } + + @Override + public AbstractJPAMailboxMessage createMessage(MessageId messageId, ThreadId threadId, Mailbox mailbox, Date internalDate, Date saveDate, int size, int bodyStartOctet, Content content, Flags flags, PropertyBuilder propertyBuilder, List attachments) throws MailboxException { + switch (feature) { + case Streaming: + return new JPAStreamingMailboxMessage(JPAMailbox.from(mailbox), internalDate, size, flags, content, + bodyStartOctet, propertyBuilder); + case Encryption: + return new JPAEncryptedMailboxMessage(JPAMailbox.from(mailbox), internalDate, size, flags, content, + bodyStartOctet, propertyBuilder); + default: + return new JPAMailboxMessage(JPAMailbox.from(mailbox), internalDate, size, flags, content, bodyStartOctet, propertyBuilder); + } + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMessageManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMessageManager.java new file mode 100644 index 00000000000..7226fb046ac --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMessageManager.java @@ -0,0 +1,103 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.openjpa; + +import java.time.Clock; +import java.util.EnumSet; + +import javax.mail.Flags; + +import org.apache.james.events.EventBus; +import org.apache.james.mailbox.MailboxPathLocker; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxACL; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.model.UidValidity; +import org.apache.james.mailbox.quota.QuotaManager; +import org.apache.james.mailbox.quota.QuotaRootResolver; +import org.apache.james.mailbox.store.BatchSizes; +import org.apache.james.mailbox.store.MailboxSessionMapperFactory; +import org.apache.james.mailbox.store.MessageStorer; +import org.apache.james.mailbox.store.PreDeletionHooks; +import org.apache.james.mailbox.store.StoreMailboxManager; +import org.apache.james.mailbox.store.StoreMessageManager; +import org.apache.james.mailbox.store.StoreRightManager; +import org.apache.james.mailbox.store.mail.MessageMapper; +import org.apache.james.mailbox.store.mail.ThreadIdGuessingAlgorithm; +import org.apache.james.mailbox.store.search.MessageSearchIndex; + +import com.github.fge.lambdas.Throwing; + +import reactor.core.publisher.Mono; + +/** + * OpenJPA implementation of Mailbox + */ +public class OpenJPAMessageManager extends StoreMessageManager { + private final MailboxSessionMapperFactory mapperFactory; + private final StoreRightManager storeRightManager; + private final Mailbox mailbox; + + public OpenJPAMessageManager(MailboxSessionMapperFactory mapperFactory, + MessageSearchIndex index, EventBus eventBus, + MailboxPathLocker locker, Mailbox mailbox, + QuotaManager quotaManager, QuotaRootResolver quotaRootResolver, + MessageId.Factory messageIdFactory, BatchSizes batchSizes, + StoreRightManager storeRightManager, ThreadIdGuessingAlgorithm threadIdGuessingAlgorithm, + Clock clock) { + super(StoreMailboxManager.DEFAULT_NO_MESSAGE_CAPABILITIES, mapperFactory, index, eventBus, locker, mailbox, + quotaManager, quotaRootResolver, batchSizes, storeRightManager, PreDeletionHooks.NO_PRE_DELETION_HOOK, + new MessageStorer.WithoutAttachment(mapperFactory, messageIdFactory, new OpenJPAMessageFactory(OpenJPAMessageFactory.AdvancedFeature.None), threadIdGuessingAlgorithm, clock)); + this.storeRightManager = storeRightManager; + this.mapperFactory = mapperFactory; + this.mailbox = mailbox; + } + + /** + * Support user flags + */ + @Override + public Flags getPermanentFlags(MailboxSession session) { + Flags flags = super.getPermanentFlags(session); + flags.add(Flags.Flag.USER); + return flags; + } + + public Mono getMetaDataReactive(MailboxMetaData.RecentMode recentMode, MailboxSession mailboxSession, EnumSet items) throws MailboxException { + MailboxACL resolvedAcl = getResolvedAcl(mailboxSession); + if (!storeRightManager.hasRight(mailbox, MailboxACL.Right.Read, mailboxSession)) { + return Mono.just(MailboxMetaData.sensibleInformationFree(resolvedAcl, getMailboxEntity().getUidValidity(), isWriteable(mailboxSession))); + } + Flags permanentFlags = getPermanentFlags(mailboxSession); + UidValidity uidValidity = getMailboxEntity().getUidValidity(); + MessageMapper messageMapper = mapperFactory.getMessageMapper(mailboxSession); + + return messageMapper.executeReactive( + nextUid(messageMapper, items) + .flatMap(nextUid -> highestModSeq(messageMapper, items) + .flatMap(highestModSeq -> firstUnseen(messageMapper, items) + .flatMap(Throwing.function(firstUnseen -> recent(recentMode, mailboxSession) + .flatMap(recents -> mailboxCounters(messageMapper, items) + .map(counters -> new MailboxMetaData(recents, permanentFlags, uidValidity, nextUid, highestModSeq, counters.getCount(), + counters.getUnseen(), firstUnseen.orElse(null), isWriteable(mailboxSession), resolvedAcl)))))))); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/JPAPerUserMaxQuotaDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/JPAPerUserMaxQuotaDAO.java new file mode 100644 index 00000000000..8b28dbba698 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/JPAPerUserMaxQuotaDAO.java @@ -0,0 +1,238 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.quota; + +import java.util.Optional; +import java.util.function.Function; + +import javax.inject.Inject; +import javax.persistence.EntityManager; +import javax.persistence.EntityManagerFactory; + +import org.apache.james.backends.jpa.TransactionRunner; +import org.apache.james.core.Domain; +import org.apache.james.core.quota.QuotaCountLimit; +import org.apache.james.core.quota.QuotaLimitValue; +import org.apache.james.core.quota.QuotaSizeLimit; +import org.apache.james.mailbox.jpa.quota.model.MaxDomainMessageCount; +import org.apache.james.mailbox.jpa.quota.model.MaxDomainStorage; +import org.apache.james.mailbox.jpa.quota.model.MaxGlobalMessageCount; +import org.apache.james.mailbox.jpa.quota.model.MaxGlobalStorage; +import org.apache.james.mailbox.jpa.quota.model.MaxUserMessageCount; +import org.apache.james.mailbox.jpa.quota.model.MaxUserStorage; +import org.apache.james.mailbox.model.QuotaRoot; + +public class JPAPerUserMaxQuotaDAO { + + private static final long INFINITE = -1; + private final TransactionRunner transactionRunner; + + @Inject + public JPAPerUserMaxQuotaDAO(EntityManagerFactory entityManagerFactory) { + this.transactionRunner = new TransactionRunner(entityManagerFactory); + } + + public void setMaxStorage(QuotaRoot quotaRoot, Optional maxStorageQuota) { + transactionRunner.run( + entityManager -> { + MaxUserStorage storedValue = getMaxUserStorageEntity(entityManager, quotaRoot, maxStorageQuota); + entityManager.persist(storedValue); + }); + } + + private MaxUserStorage getMaxUserStorageEntity(EntityManager entityManager, QuotaRoot quotaRoot, Optional maxStorageQuota) { + MaxUserStorage storedValue = entityManager.find(MaxUserStorage.class, quotaRoot.getValue()); + Long value = quotaValueToLong(maxStorageQuota); + if (storedValue == null) { + return new MaxUserStorage(quotaRoot.getValue(), value); + } + storedValue.setValue(value); + return storedValue; + } + + public void setMaxMessage(QuotaRoot quotaRoot, Optional maxMessageCount) { + transactionRunner.run( + entityManager -> { + MaxUserMessageCount storedValue = getMaxUserMessageEntity(entityManager, quotaRoot, maxMessageCount); + entityManager.persist(storedValue); + }); + } + + private MaxUserMessageCount getMaxUserMessageEntity(EntityManager entityManager, QuotaRoot quotaRoot, Optional maxMessageQuota) { + MaxUserMessageCount storedValue = entityManager.find(MaxUserMessageCount.class, quotaRoot.getValue()); + Long value = quotaValueToLong(maxMessageQuota); + if (storedValue == null) { + return new MaxUserMessageCount(quotaRoot.getValue(), value); + } + storedValue.setValue(value); + return storedValue; + } + + public void setDomainMaxMessage(Domain domain, Optional count) { + transactionRunner.run( + entityManager -> { + MaxDomainMessageCount storedValue = getMaxDomainMessageEntity(entityManager, domain, count); + entityManager.persist(storedValue); + }); + } + + + public void setDomainMaxStorage(Domain domain, Optional size) { + transactionRunner.run( + entityManager -> { + MaxDomainStorage storedValue = getMaxDomainStorageEntity(entityManager, domain, size); + entityManager.persist(storedValue); + }); + } + + private MaxDomainMessageCount getMaxDomainMessageEntity(EntityManager entityManager, Domain domain, Optional maxMessageQuota) { + MaxDomainMessageCount storedValue = entityManager.find(MaxDomainMessageCount.class, domain.asString()); + Long value = quotaValueToLong(maxMessageQuota); + if (storedValue == null) { + return new MaxDomainMessageCount(domain, value); + } + storedValue.setValue(value); + return storedValue; + } + + private MaxDomainStorage getMaxDomainStorageEntity(EntityManager entityManager, Domain domain, Optional maxStorageQuota) { + MaxDomainStorage storedValue = entityManager.find(MaxDomainStorage.class, domain.asString()); + Long value = quotaValueToLong(maxStorageQuota); + if (storedValue == null) { + return new MaxDomainStorage(domain, value); + } + storedValue.setValue(value); + return storedValue; + } + + + public void setGlobalMaxStorage(Optional globalMaxStorage) { + transactionRunner.run( + entityManager -> { + MaxGlobalStorage globalMaxStorageEntity = getGlobalMaxStorageEntity(entityManager, globalMaxStorage); + entityManager.persist(globalMaxStorageEntity); + }); + } + + private MaxGlobalStorage getGlobalMaxStorageEntity(EntityManager entityManager, Optional maxSizeQuota) { + MaxGlobalStorage storedValue = entityManager.find(MaxGlobalStorage.class, MaxGlobalStorage.DEFAULT_KEY); + Long value = quotaValueToLong(maxSizeQuota); + if (storedValue == null) { + return new MaxGlobalStorage(value); + } + storedValue.setValue(value); + return storedValue; + } + + public void setGlobalMaxMessage(Optional globalMaxMessageCount) { + transactionRunner.run( + entityManager -> { + MaxGlobalMessageCount globalMaxMessageEntity = getGlobalMaxMessageEntity(entityManager, globalMaxMessageCount); + entityManager.persist(globalMaxMessageEntity); + }); + } + + private MaxGlobalMessageCount getGlobalMaxMessageEntity(EntityManager entityManager, Optional maxMessageQuota) { + MaxGlobalMessageCount storedValue = entityManager.find(MaxGlobalMessageCount.class, MaxGlobalMessageCount.DEFAULT_KEY); + Long value = quotaValueToLong(maxMessageQuota); + if (storedValue == null) { + return new MaxGlobalMessageCount(value); + } + storedValue.setValue(value); + return storedValue; + } + + public Optional getGlobalMaxStorage(EntityManager entityManager) { + MaxGlobalStorage storedValue = entityManager.find(MaxGlobalStorage.class, MaxGlobalStorage.DEFAULT_KEY); + if (storedValue == null) { + return Optional.empty(); + } + return longToQuotaSize(storedValue.getValue()); + } + + public Optional getGlobalMaxMessage(EntityManager entityManager) { + MaxGlobalMessageCount storedValue = entityManager.find(MaxGlobalMessageCount.class, MaxGlobalMessageCount.DEFAULT_KEY); + if (storedValue == null) { + return Optional.empty(); + } + return longToQuotaCount(storedValue.getValue()); + } + + public Optional getMaxStorage(EntityManager entityManager, QuotaRoot quotaRoot) { + MaxUserStorage storedValue = entityManager.find(MaxUserStorage.class, quotaRoot.getValue()); + if (storedValue == null) { + return Optional.empty(); + } + return longToQuotaSize(storedValue.getValue()); + } + + public Optional getMaxMessage(EntityManager entityManager, QuotaRoot quotaRoot) { + MaxUserMessageCount storedValue = entityManager.find(MaxUserMessageCount.class, quotaRoot.getValue()); + if (storedValue == null) { + return Optional.empty(); + } + return longToQuotaCount(storedValue.getValue()); + } + + public Optional getDomainMaxMessage(EntityManager entityManager, Domain domain) { + MaxDomainMessageCount storedValue = entityManager.find(MaxDomainMessageCount.class, domain.asString()); + if (storedValue == null) { + return Optional.empty(); + } + return longToQuotaCount(storedValue.getValue()); + } + + public Optional getDomainMaxStorage(EntityManager entityManager, Domain domain) { + MaxDomainStorage storedValue = entityManager.find(MaxDomainStorage.class, domain.asString()); + if (storedValue == null) { + return Optional.empty(); + } + return longToQuotaSize(storedValue.getValue()); + } + + + private Long quotaValueToLong(Optional> maxStorageQuota) { + return maxStorageQuota.map(value -> { + if (value.isUnlimited()) { + return INFINITE; + } + return value.asLong(); + }).orElse(null); + } + + private Optional longToQuotaSize(Long value) { + return longToQuotaValue(value, QuotaSizeLimit.unlimited(), QuotaSizeLimit::size); + } + + private Optional longToQuotaCount(Long value) { + return longToQuotaValue(value, QuotaCountLimit.unlimited(), QuotaCountLimit::count); + } + + private > Optional longToQuotaValue(Long value, T infiniteValue, Function quotaFactory) { + if (value == null) { + return Optional.empty(); + } + if (value == INFINITE) { + return Optional.of(infiniteValue); + } + return Optional.of(quotaFactory.apply(value)); + } + +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/JPAPerUserMaxQuotaManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/JPAPerUserMaxQuotaManager.java new file mode 100644 index 00000000000..31658c0c6a3 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/JPAPerUserMaxQuotaManager.java @@ -0,0 +1,292 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.quota; + +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Stream; + +import javax.inject.Inject; +import javax.persistence.EntityManager; +import javax.persistence.EntityManagerFactory; + +import org.apache.commons.lang3.tuple.Pair; +import org.apache.james.backends.jpa.EntityManagerUtils; +import org.apache.james.core.Domain; +import org.apache.james.core.quota.QuotaCountLimit; +import org.apache.james.core.quota.QuotaSizeLimit; +import org.apache.james.mailbox.model.Quota; +import org.apache.james.mailbox.model.QuotaRoot; +import org.apache.james.mailbox.quota.MaxQuotaManager; +import org.reactivestreams.Publisher; + +import com.github.fge.lambdas.Throwing; +import com.google.common.collect.ImmutableMap; + +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +public class JPAPerUserMaxQuotaManager implements MaxQuotaManager { + private final EntityManagerFactory entityManagerFactory; + private final JPAPerUserMaxQuotaDAO dao; + + @Inject + public JPAPerUserMaxQuotaManager(EntityManagerFactory entityManagerFactory, JPAPerUserMaxQuotaDAO dao) { + this.entityManagerFactory = entityManagerFactory; + this.dao = dao; + } + + @Override + public void setMaxStorage(QuotaRoot quotaRoot, QuotaSizeLimit maxStorageQuota) { + dao.setMaxStorage(quotaRoot, Optional.of(maxStorageQuota)); + } + + @Override + public Publisher setMaxStorageReactive(QuotaRoot quotaRoot, QuotaSizeLimit maxStorageQuota) { + return Mono.fromRunnable(() -> setMaxStorage(quotaRoot, maxStorageQuota)); + } + + @Override + public void setMaxMessage(QuotaRoot quotaRoot, QuotaCountLimit maxMessageCount) { + dao.setMaxMessage(quotaRoot, Optional.of(maxMessageCount)); + } + + @Override + public Publisher setMaxMessageReactive(QuotaRoot quotaRoot, QuotaCountLimit maxMessageCount) { + return Mono.fromRunnable(() -> setMaxMessage(quotaRoot, maxMessageCount)); + } + + @Override + public void setDomainMaxMessage(Domain domain, QuotaCountLimit count) { + dao.setDomainMaxMessage(domain, Optional.of(count)); + } + + @Override + public Publisher setDomainMaxMessageReactive(Domain domain, QuotaCountLimit count) { + return Mono.fromRunnable(() -> setDomainMaxMessage(domain, count)); + } + + @Override + public void setDomainMaxStorage(Domain domain, QuotaSizeLimit size) { + dao.setDomainMaxStorage(domain, Optional.of(size)); + } + + @Override + public Publisher setDomainMaxStorageReactive(Domain domain, QuotaSizeLimit size) { + return Mono.fromRunnable(() -> setDomainMaxStorage(domain, size)); + } + + @Override + public void removeDomainMaxMessage(Domain domain) { + dao.setDomainMaxMessage(domain, Optional.empty()); + } + + @Override + public Publisher removeDomainMaxMessageReactive(Domain domain) { + return Mono.fromRunnable(() -> removeDomainMaxMessage(domain)); + } + + @Override + public void removeDomainMaxStorage(Domain domain) { + dao.setDomainMaxStorage(domain, Optional.empty()); + } + + @Override + public Publisher removeDomainMaxStorageReactive(Domain domain) { + return Mono.fromRunnable(() -> removeDomainMaxStorage(domain)); + } + + @Override + public Optional getDomainMaxMessage(Domain domain) { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + try { + return dao.getDomainMaxMessage(entityManager, domain); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + @Override + public Publisher getDomainMaxMessageReactive(Domain domain) { + return Mono.fromSupplier(() -> getDomainMaxMessage(domain)) + .flatMap(Mono::justOrEmpty); + } + + @Override + public Optional getDomainMaxStorage(Domain domain) { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + try { + return dao.getDomainMaxStorage(entityManager, domain); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + @Override + public Publisher getDomainMaxStorageReactive(Domain domain) { + return Mono.fromSupplier(() -> getDomainMaxStorage(domain)) + .flatMap(Mono::justOrEmpty); + } + + @Override + public void removeMaxMessage(QuotaRoot quotaRoot) { + dao.setMaxMessage(quotaRoot, Optional.empty()); + } + + @Override + public Publisher removeMaxMessageReactive(QuotaRoot quotaRoot) { + return Mono.fromRunnable(() -> removeMaxMessage(quotaRoot)); + } + + @Override + public void setGlobalMaxStorage(QuotaSizeLimit globalMaxStorage) { + dao.setGlobalMaxStorage(Optional.of(globalMaxStorage)); + } + + @Override + public Publisher setGlobalMaxStorageReactive(QuotaSizeLimit globalMaxStorage) { + return Mono.fromRunnable(() -> setGlobalMaxStorage(globalMaxStorage)); + } + + @Override + public void removeGlobalMaxMessage() { + dao.setGlobalMaxMessage(Optional.empty()); + } + + @Override + public Publisher removeGlobalMaxMessageReactive() { + return Mono.fromRunnable(this::removeGlobalMaxMessage); + } + + @Override + public void setGlobalMaxMessage(QuotaCountLimit globalMaxMessageCount) { + dao.setGlobalMaxMessage(Optional.of(globalMaxMessageCount)); + } + + @Override + public Publisher setGlobalMaxMessageReactive(QuotaCountLimit globalMaxMessageCount) { + return Mono.fromRunnable(() -> setGlobalMaxMessage(globalMaxMessageCount)); + } + + @Override + public Optional getGlobalMaxStorage() { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + try { + return dao.getGlobalMaxStorage(entityManager); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + @Override + public Publisher getGlobalMaxStorageReactive() { + return Mono.fromSupplier(this::getGlobalMaxStorage) + .flatMap(Mono::justOrEmpty); + } + + @Override + public Optional getGlobalMaxMessage() { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + try { + return dao.getGlobalMaxMessage(entityManager); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + @Override + public Publisher getGlobalMaxMessageReactive() { + return Mono.fromSupplier(this::getGlobalMaxMessage) + .flatMap(Mono::justOrEmpty); + } + + @Override + public Publisher quotaDetailsReactive(QuotaRoot quotaRoot) { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + return Mono.zip( + Mono.fromCallable(() -> listMaxMessagesDetails(quotaRoot, entityManager)), + Mono.fromCallable(() -> listMaxStorageDetails(quotaRoot, entityManager))) + .map(tuple -> new QuotaDetails(tuple.getT1(), tuple.getT2())) + .subscribeOn(Schedulers.boundedElastic()) + .doFinally(any -> EntityManagerUtils.safelyClose(entityManager)); + } + + @Override + public Map listMaxMessagesDetails(QuotaRoot quotaRoot) { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + try { + return listMaxMessagesDetails(quotaRoot, entityManager); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + private ImmutableMap listMaxMessagesDetails(QuotaRoot quotaRoot, EntityManager entityManager) { + Function> domainQuotaFunction = Throwing.function(domain -> dao.getDomainMaxMessage(entityManager, domain)); + return Stream.of( + Pair.of(Quota.Scope.User, dao.getMaxMessage(entityManager, quotaRoot)), + Pair.of(Quota.Scope.Domain, quotaRoot.getDomain().flatMap(domainQuotaFunction)), + Pair.of(Quota.Scope.Global, dao.getGlobalMaxMessage(entityManager))) + .filter(pair -> pair.getValue().isPresent()) + .collect(ImmutableMap.toImmutableMap(Pair::getKey, value -> value.getValue().get())); + } + + @Override + public Map listMaxStorageDetails(QuotaRoot quotaRoot) { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + try { + return listMaxStorageDetails(quotaRoot, entityManager); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + private ImmutableMap listMaxStorageDetails(QuotaRoot quotaRoot, EntityManager entityManager) { + Function> domainQuotaFunction = Throwing.function(domain -> dao.getDomainMaxStorage(entityManager, domain)); + return Stream.of( + Pair.of(Quota.Scope.User, dao.getMaxStorage(entityManager, quotaRoot)), + Pair.of(Quota.Scope.Domain, quotaRoot.getDomain().flatMap(domainQuotaFunction)), + Pair.of(Quota.Scope.Global, dao.getGlobalMaxStorage(entityManager))) + .filter(pair -> pair.getValue().isPresent()) + .collect(ImmutableMap.toImmutableMap(Pair::getKey, value -> value.getValue().get())); + } + + @Override + public void removeMaxStorage(QuotaRoot quotaRoot) { + dao.setMaxStorage(quotaRoot, Optional.empty()); + } + + @Override + public Publisher removeMaxStorageReactive(QuotaRoot quotaRoot) { + return Mono.fromRunnable(() -> removeMaxStorage(quotaRoot)); + } + + @Override + public void removeGlobalMaxStorage() { + dao.setGlobalMaxStorage(Optional.empty()); + } + + @Override + public Publisher removeGlobalMaxStorageReactive() { + return Mono.fromRunnable(this::removeGlobalMaxStorage); + } + +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/JpaCurrentQuotaManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/JpaCurrentQuotaManager.java new file mode 100644 index 00000000000..2f5c5a980d0 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/JpaCurrentQuotaManager.java @@ -0,0 +1,131 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.quota; + +import java.util.Optional; + +import javax.inject.Inject; +import javax.persistence.EntityManager; +import javax.persistence.EntityManagerFactory; + +import org.apache.james.backends.jpa.EntityManagerUtils; +import org.apache.james.backends.jpa.TransactionRunner; +import org.apache.james.core.quota.QuotaCountUsage; +import org.apache.james.core.quota.QuotaSizeUsage; +import org.apache.james.mailbox.jpa.quota.model.JpaCurrentQuota; +import org.apache.james.mailbox.model.CurrentQuotas; +import org.apache.james.mailbox.model.QuotaOperation; +import org.apache.james.mailbox.model.QuotaRoot; +import org.apache.james.mailbox.quota.CurrentQuotaManager; + +import reactor.core.publisher.Mono; + +public class JpaCurrentQuotaManager implements CurrentQuotaManager { + + public static final long NO_MESSAGES = 0L; + public static final long NO_STORED_BYTES = 0L; + + private final EntityManagerFactory entityManagerFactory; + private final TransactionRunner transactionRunner; + + @Inject + public JpaCurrentQuotaManager(EntityManagerFactory entityManagerFactory) { + this.entityManagerFactory = entityManagerFactory; + this.transactionRunner = new TransactionRunner(entityManagerFactory); + } + + @Override + public Mono getCurrentMessageCount(QuotaRoot quotaRoot) { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + + return Mono.fromCallable(() -> Optional.ofNullable(retrieveUserQuota(entityManager, quotaRoot)) + .map(JpaCurrentQuota::getMessageCount) + .orElse(QuotaCountUsage.count(NO_STORED_BYTES))) + .doFinally(any -> EntityManagerUtils.safelyClose(entityManager)); + } + + @Override + public Mono getCurrentStorage(QuotaRoot quotaRoot) { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + + return Mono.fromCallable(() -> Optional.ofNullable(retrieveUserQuota(entityManager, quotaRoot)) + .map(JpaCurrentQuota::getSize) + .orElse(QuotaSizeUsage.size(NO_STORED_BYTES))) + .doFinally(any -> EntityManagerUtils.safelyClose(entityManager)); + } + + public Mono getCurrentQuotas(QuotaRoot quotaRoot) { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + return Mono.fromCallable(() -> Optional.ofNullable(retrieveUserQuota(entityManager, quotaRoot)) + .map(jpaCurrentQuota -> new CurrentQuotas(jpaCurrentQuota.getMessageCount(), jpaCurrentQuota.getSize())) + .orElse(CurrentQuotas.emptyQuotas())) + .doFinally(any -> EntityManagerUtils.safelyClose(entityManager)); + } + + @Override + public Mono increase(QuotaOperation quotaOperation) { + return Mono.fromRunnable(() -> + transactionRunner.run( + entityManager -> { + QuotaRoot quotaRoot = quotaOperation.quotaRoot(); + + JpaCurrentQuota jpaCurrentQuota = Optional.ofNullable(retrieveUserQuota(entityManager, quotaRoot)) + .orElse(new JpaCurrentQuota(quotaRoot.getValue(), NO_MESSAGES, NO_STORED_BYTES)); + + entityManager.merge(new JpaCurrentQuota(quotaRoot.getValue(), + jpaCurrentQuota.getMessageCount().asLong() + quotaOperation.count().asLong(), + jpaCurrentQuota.getSize().asLong() + quotaOperation.size().asLong())); + })); + } + + @Override + public Mono decrease(QuotaOperation quotaOperation) { + return Mono.fromRunnable(() -> + transactionRunner.run( + entityManager -> { + QuotaRoot quotaRoot = quotaOperation.quotaRoot(); + + JpaCurrentQuota jpaCurrentQuota = Optional.ofNullable(retrieveUserQuota(entityManager, quotaRoot)) + .orElse(new JpaCurrentQuota(quotaRoot.getValue(), NO_MESSAGES, NO_STORED_BYTES)); + + entityManager.merge(new JpaCurrentQuota(quotaRoot.getValue(), + jpaCurrentQuota.getMessageCount().asLong() - quotaOperation.count().asLong(), + jpaCurrentQuota.getSize().asLong() - quotaOperation.size().asLong())); + })); + } + + @Override + public Mono setCurrentQuotas(QuotaOperation quotaOperation) { + return Mono.fromCallable(() -> getCurrentQuotas(quotaOperation.quotaRoot())) + .flatMap(storedQuotas -> Mono.fromRunnable(() -> + transactionRunner.run( + entityManager -> { + if (!storedQuotas.equals(CurrentQuotas.from(quotaOperation))) { + entityManager.merge(new JpaCurrentQuota(quotaOperation.quotaRoot().getValue(), + quotaOperation.count().asLong(), + quotaOperation.size().asLong())); + } + }))); + } + + private JpaCurrentQuota retrieveUserQuota(EntityManager entityManager, QuotaRoot quotaRoot) { + return entityManager.find(JpaCurrentQuota.class, quotaRoot.getValue()); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/JpaCurrentQuota.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/JpaCurrentQuota.java new file mode 100644 index 00000000000..f058ba0ce90 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/JpaCurrentQuota.java @@ -0,0 +1,69 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.quota.model; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; + +import org.apache.james.core.quota.QuotaCountUsage; +import org.apache.james.core.quota.QuotaSizeUsage; + +@Entity(name = "CurrentQuota") +@Table(name = "JAMES_QUOTA_CURRENTQUOTA") +public class JpaCurrentQuota { + + @Id + @Column(name = "CURRENTQUOTA_QUOTAROOT") + private String quotaRoot; + + @Column(name = "CURRENTQUOTA_MESSAGECOUNT") + private long messageCount; + + @Column(name = "CURRENTQUOTA_SIZE") + private long size; + + public JpaCurrentQuota() { + } + + public JpaCurrentQuota(String quotaRoot, long messageCount, long size) { + this.quotaRoot = quotaRoot; + this.messageCount = messageCount; + this.size = size; + } + + public QuotaCountUsage getMessageCount() { + return QuotaCountUsage.count(messageCount); + } + + public QuotaSizeUsage getSize() { + return QuotaSizeUsage.size(size); + } + + @Override + public String toString() { + return "JpaCurrentQuota{" + + "quotaRoot='" + quotaRoot + '\'' + + ", messageCount=" + messageCount + + ", size=" + size + + '}'; + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxDomainMessageCount.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxDomainMessageCount.java new file mode 100644 index 00000000000..9787d6756eb --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxDomainMessageCount.java @@ -0,0 +1,54 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.quota.model; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; + +import org.apache.james.core.Domain; + +@Entity(name = "MaxDomainMessageCount") +@Table(name = "JAMES_MAX_DOMAIN_MESSAGE_COUNT") +public class MaxDomainMessageCount { + @Id + @Column(name = "DOMAIN") + private String domain; + + @Column(name = "VALUE", nullable = true) + private Long value; + + public MaxDomainMessageCount(Domain domain, Long value) { + this.domain = domain.asString(); + this.value = value; + } + + public MaxDomainMessageCount() { + } + + public Long getValue() { + return value; + } + + public void setValue(Long value) { + this.value = value; + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxDomainStorage.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxDomainStorage.java new file mode 100644 index 00000000000..575f070ecb8 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxDomainStorage.java @@ -0,0 +1,55 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.quota.model; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; + +import org.apache.james.core.Domain; + +@Entity(name = "MaxDomainStorage") +@Table(name = "JAMES_MAX_DOMAIN_STORAGE") +public class MaxDomainStorage { + + @Id + @Column(name = "DOMAIN") + private String domain; + + @Column(name = "VALUE", nullable = true) + private Long value; + + public MaxDomainStorage(Domain domain, Long value) { + this.domain = domain.asString(); + this.value = value; + } + + public MaxDomainStorage() { + } + + public Long getValue() { + return value; + } + + public void setValue(Long value) { + this.value = value; + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxGlobalMessageCount.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxGlobalMessageCount.java new file mode 100644 index 00000000000..04bc8eec1e1 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxGlobalMessageCount.java @@ -0,0 +1,54 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.quota.model; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; + +@Entity(name = "MaxGlobalMessageCount") +@Table(name = "JAMES_MAX_GLOBAL_MESSAGE_COUNT") +public class MaxGlobalMessageCount { + public static final String DEFAULT_KEY = "default_key"; + + @Id + @Column(name = "QUOTAROOT_ID") + private String quotaRoot = DEFAULT_KEY; + + @Column(name = "VALUE", nullable = true) + private Long value; + + public MaxGlobalMessageCount(Long value) { + this.quotaRoot = DEFAULT_KEY; + this.value = value; + } + + public MaxGlobalMessageCount() { + } + + public Long getValue() { + return value; + } + + public void setValue(Long value) { + this.value = value; + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxGlobalStorage.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxGlobalStorage.java new file mode 100644 index 00000000000..7f99110d865 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxGlobalStorage.java @@ -0,0 +1,54 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.quota.model; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; + +@Entity(name = "MaxGlobalStorage") +@Table(name = "JAMES_MAX_Global_STORAGE") +public class MaxGlobalStorage { + public static final String DEFAULT_KEY = "default_key"; + + @Id + @Column(name = "QUOTAROOT_ID") + private String quotaRoot = DEFAULT_KEY; + + @Column(name = "VALUE", nullable = true) + private Long value; + + public MaxGlobalStorage(Long value) { + this.quotaRoot = DEFAULT_KEY; + this.value = value; + } + + public MaxGlobalStorage() { + } + + public Long getValue() { + return value; + } + + public void setValue(Long value) { + this.value = value; + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxUserMessageCount.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxUserMessageCount.java new file mode 100644 index 00000000000..71056e9aa1f --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxUserMessageCount.java @@ -0,0 +1,52 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.quota.model; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; + +@Entity(name = "MaxUserMessageCount") +@Table(name = "JAMES_MAX_USER_MESSAGE_COUNT") +public class MaxUserMessageCount { + @Id + @Column(name = "QUOTAROOT_ID") + private String quotaRoot; + + @Column(name = "VALUE", nullable = true) + private Long value; + + public MaxUserMessageCount(String quotaRoot, Long value) { + this.quotaRoot = quotaRoot; + this.value = value; + } + + public MaxUserMessageCount() { + } + + public Long getValue() { + return value; + } + + public void setValue(Long value) { + this.value = value; + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxUserStorage.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxUserStorage.java new file mode 100644 index 00000000000..3e01be8f61e --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxUserStorage.java @@ -0,0 +1,53 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.quota.model; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.Table; + +@Entity(name = "MaxUserStorage") +@Table(name = "JAMES_MAX_USER_STORAGE") +public class MaxUserStorage { + + @Id + @Column(name = "QUOTAROOT_ID") + private String quotaRoot; + + @Column(name = "VALUE", nullable = true) + private Long value; + + public MaxUserStorage(String quotaRoot, Long value) { + this.quotaRoot = quotaRoot; + this.value = value; + } + + public MaxUserStorage() { + } + + public Long getValue() { + return value; + } + + public void setValue(Long value) { + this.value = value; + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/JPASubscriptionMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/JPASubscriptionMapper.java new file mode 100644 index 00000000000..d32dd268ca5 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/JPASubscriptionMapper.java @@ -0,0 +1,135 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.mailbox.jpa.user; + +import static org.apache.james.mailbox.jpa.user.model.JPASubscription.FIND_MAILBOX_SUBSCRIPTION_FOR_USER; +import static org.apache.james.mailbox.jpa.user.model.JPASubscription.FIND_SUBSCRIPTIONS_FOR_USER; + +import java.util.List; +import java.util.Optional; + +import javax.persistence.EntityManager; +import javax.persistence.EntityManagerFactory; +import javax.persistence.EntityTransaction; +import javax.persistence.NoResultException; +import javax.persistence.PersistenceException; + +import org.apache.james.core.Username; +import org.apache.james.mailbox.exception.SubscriptionException; +import org.apache.james.mailbox.jpa.JPATransactionalMapper; +import org.apache.james.mailbox.jpa.user.model.JPASubscription; +import org.apache.james.mailbox.store.user.SubscriptionMapper; +import org.apache.james.mailbox.store.user.model.Subscription; + +import com.google.common.collect.ImmutableList; + +/** + * JPA implementation of a {@link SubscriptionMapper}. This class is not thread-safe! + */ +public class JPASubscriptionMapper extends JPATransactionalMapper implements SubscriptionMapper { + + public JPASubscriptionMapper(EntityManagerFactory entityManagerFactory) { + super(entityManagerFactory); + } + + @Override + public void save(Subscription subscription) throws SubscriptionException { + EntityManager entityManager = getEntityManager(); + EntityTransaction transaction = entityManager.getTransaction(); + boolean localTransaction = !transaction.isActive(); + if (localTransaction) { + transaction.begin(); + } + try { + if (!exists(entityManager, subscription)) { + entityManager.persist(new JPASubscription(subscription)); + } + if (localTransaction) { + if (transaction.isActive()) { + transaction.commit(); + } + } + } catch (PersistenceException e) { + if (transaction.isActive()) { + transaction.rollback(); + } + throw new SubscriptionException(e); + } + } + + @Override + public List findSubscriptionsForUser(Username user) throws SubscriptionException { + try { + return getEntityManager().createNamedQuery(FIND_SUBSCRIPTIONS_FOR_USER, JPASubscription.class) + .setParameter("userParam", user.asString()) + .getResultList() + .stream() + .map(JPASubscription::toSubscription) + .collect(ImmutableList.toImmutableList()); + } catch (PersistenceException e) { + throw new SubscriptionException(e); + } + } + + @Override + public void delete(Subscription subscription) throws SubscriptionException { + EntityManager entityManager = getEntityManager(); + EntityTransaction transaction = entityManager.getTransaction(); + boolean localTransaction = !transaction.isActive(); + if (localTransaction) { + transaction.begin(); + } + try { + findJpaSubscription(entityManager, subscription) + .ifPresent(entityManager::remove); + if (localTransaction) { + if (transaction.isActive()) { + transaction.commit(); + } + } + } catch (PersistenceException e) { + if (transaction.isActive()) { + transaction.rollback(); + } + throw new SubscriptionException(e); + } + } + + private Optional findJpaSubscription(EntityManager entityManager, Subscription subscription) { + return entityManager.createNamedQuery(FIND_MAILBOX_SUBSCRIPTION_FOR_USER, JPASubscription.class) + .setParameter("userParam", subscription.getUser().asString()) + .setParameter("mailboxParam", subscription.getMailbox()) + .getResultList() + .stream() + .findFirst(); + } + + private boolean exists(EntityManager entityManager, Subscription subscription) throws SubscriptionException { + try { + return !entityManager.createNamedQuery(FIND_MAILBOX_SUBSCRIPTION_FOR_USER, JPASubscription.class) + .setParameter("userParam", subscription.getUser().asString()) + .setParameter("mailboxParam", subscription.getMailbox()) + .getResultList().isEmpty(); + } catch (NoResultException e) { + return false; + } catch (PersistenceException e) { + throw new SubscriptionException(e); + } + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/model/JPASubscription.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/model/JPASubscription.java new file mode 100644 index 00000000000..951ca15c576 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/model/JPASubscription.java @@ -0,0 +1,136 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.mailbox.jpa.user.model; + +import java.util.Objects; + +import javax.persistence.Basic; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import javax.persistence.NamedQueries; +import javax.persistence.NamedQuery; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; + +import org.apache.james.core.Username; +import org.apache.james.mailbox.store.user.model.Subscription; + +/** + * A subscription to a mailbox by a user. + */ +@Entity(name = "Subscription") +@Table( + name = "JAMES_SUBSCRIPTION", + uniqueConstraints = + @UniqueConstraint( + columnNames = { + "USER_NAME", + "MAILBOX_NAME"}) +) +@NamedQueries({ + @NamedQuery(name = JPASubscription.FIND_MAILBOX_SUBSCRIPTION_FOR_USER, + query = "SELECT subscription FROM Subscription subscription WHERE subscription.username = :userParam AND subscription.mailbox = :mailboxParam"), + @NamedQuery(name = JPASubscription.FIND_SUBSCRIPTIONS_FOR_USER, + query = "SELECT subscription FROM Subscription subscription WHERE subscription.username = :userParam"), + @NamedQuery(name = JPASubscription.DELETE_SUBSCRIPTION, + query = "DELETE subscription FROM Subscription subscription WHERE subscription.username = :userParam AND subscription.mailbox = :mailboxParam") +}) +public class JPASubscription { + public static final String DELETE_SUBSCRIPTION = "deleteSubscription"; + public static final String FIND_SUBSCRIPTIONS_FOR_USER = "findSubscriptionsForUser"; + public static final String FIND_MAILBOX_SUBSCRIPTION_FOR_USER = "findFindMailboxSubscriptionForUser"; + + private static final String TO_STRING_SEPARATOR = " "; + + /** Primary key */ + @GeneratedValue + @Id + @Column(name = "SUBSCRIPTION_ID") + private long id; + + /** Name of the subscribed user */ + @Basic(optional = false) + @Column(name = "USER_NAME", nullable = false, length = 100) + private String username; + + /** Subscribed mailbox */ + @Basic(optional = false) + @Column(name = "MAILBOX_NAME", nullable = false, length = 100) + private String mailbox; + + /** + * Used by JPA + */ + @Deprecated + public JPASubscription() { + + } + + /** + * Constructs a user subscription. + */ + public JPASubscription(Subscription subscription) { + super(); + this.username = subscription.getUser().asString(); + this.mailbox = subscription.getMailbox(); + } + + public String getMailbox() { + return mailbox; + } + + public Username getUser() { + return Username.of(username); + } + + public Subscription toSubscription() { + return new Subscription(Username.of(username), mailbox); + } + + @Override + public final boolean equals(Object o) { + if (o instanceof JPASubscription) { + JPASubscription that = (JPASubscription) o; + + return Objects.equals(this.id, that.id); + } + return false; + } + + @Override + public final int hashCode() { + return Objects.hash(id); + } + + /** + * Renders output suitable for debugging. + * + * @return output suitable for debugging + */ + public String toString() { + return "Subscription ( " + + "id = " + this.id + TO_STRING_SEPARATOR + + "user = " + this.username + TO_STRING_SEPARATOR + + "mailbox = " + this.mailbox + TO_STRING_SEPARATOR + + " )"; + } + +} diff --git a/mailbox/postgres/src/main/resources/META-INF/spring/mailbox-jpa.xml b/mailbox/postgres/src/main/resources/META-INF/spring/mailbox-jpa.xml new file mode 100644 index 00000000000..30b9d4a6a95 --- /dev/null +++ b/mailbox/postgres/src/main/resources/META-INF/spring/mailbox-jpa.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mailbox/postgres/src/main/resources/james-database.properties b/mailbox/postgres/src/main/resources/james-database.properties new file mode 100644 index 00000000000..852f8f29890 --- /dev/null +++ b/mailbox/postgres/src/main/resources/james-database.properties @@ -0,0 +1,51 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# This template file can be used as example for James Server configuration +# DO NOT USE IT AS SUCH AND ADAPT IT TO YOUR NEEDS + +# See http://james.apache.org/server/3/config.html for usage + +# Use derby as default +database.driverClassName=org.apache.derby.jdbc.EmbeddedDriver +database.url=jdbc:derby:../var/store/derby;create=true +database.username=app +database.password=app + +# Supported adapters are: +# DB2, DERBY, H2, HSQL, INFORMIX, MYSQL, ORACLE, POSTGRESQL, SQL_SERVER, SYBASE +vendorAdapter.database=DERBY + +# Use streaming for Blobs +# This is only supported on a limited set of databases atm. You should check if its supported by your DB before enable +# it. +# +# See: +# http://openjpa.apache.org/builds/latest/docs/manual/ref_guide_mapping_jpa.html #7.11. LOB Streaming +# +openjpa.streaming=false + +# Validate the data source before using it +# datasource.testOnBorrow=true +# datasource.validationQueryTimeoutSec=2 +# This is different per database. See https://stackoverflow.com/questions/10684244/dbcp-validationquery-for-different-databases#10684260 +# datasource.validationQuery=select 1 + +# Attachment storage +# *WARNING*: Is not made to store large binary content (no more than 1 GB of data) +# Optional, Allowed values are: true, false, defaults to false +# attachmentStorage.enabled=false \ No newline at end of file diff --git a/mailbox/postgres/src/reporting-site/site.xml b/mailbox/postgres/src/reporting-site/site.xml new file mode 100644 index 00000000000..d9191644908 --- /dev/null +++ b/mailbox/postgres/src/reporting-site/site.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPAMailboxFixture.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPAMailboxFixture.java new file mode 100644 index 00000000000..25a96d93ca2 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPAMailboxFixture.java @@ -0,0 +1,85 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa; + +import java.util.List; + +import org.apache.james.mailbox.jpa.mail.model.JPAAttachment; +import org.apache.james.mailbox.jpa.mail.model.JPAMailbox; +import org.apache.james.mailbox.jpa.mail.model.JPAMailboxAnnotation; +import org.apache.james.mailbox.jpa.mail.model.JPAProperty; +import org.apache.james.mailbox.jpa.mail.model.JPAUserFlag; +import org.apache.james.mailbox.jpa.mail.model.openjpa.AbstractJPAMailboxMessage; +import org.apache.james.mailbox.jpa.mail.model.openjpa.JPAMailboxMessage; +import org.apache.james.mailbox.jpa.mail.model.openjpa.JPAMailboxMessageWithAttachmentStorage; +import org.apache.james.mailbox.jpa.quota.model.JpaCurrentQuota; +import org.apache.james.mailbox.jpa.quota.model.MaxDomainMessageCount; +import org.apache.james.mailbox.jpa.quota.model.MaxDomainStorage; +import org.apache.james.mailbox.jpa.quota.model.MaxGlobalMessageCount; +import org.apache.james.mailbox.jpa.quota.model.MaxGlobalStorage; +import org.apache.james.mailbox.jpa.quota.model.MaxUserMessageCount; +import org.apache.james.mailbox.jpa.quota.model.MaxUserStorage; +import org.apache.james.mailbox.jpa.user.model.JPASubscription; + +import com.google.common.collect.ImmutableList; + +public interface JPAMailboxFixture { + + List> MAILBOX_PERSISTANCE_CLASSES = ImmutableList.of( + JPAMailbox.class, + AbstractJPAMailboxMessage.class, + JPAMailboxMessage.class, + JPAProperty.class, + JPAUserFlag.class, + JPAMailboxAnnotation.class, + JPASubscription.class, + JPAAttachment.class, + JPAMailboxMessageWithAttachmentStorage.class + ); + + List> QUOTA_PERSISTANCE_CLASSES = ImmutableList.of( + MaxGlobalMessageCount.class, + MaxGlobalStorage.class, + MaxDomainStorage.class, + MaxDomainMessageCount.class, + MaxUserMessageCount.class, + MaxUserStorage.class, + JpaCurrentQuota.class + ); + + List MAILBOX_TABLE_NAMES = ImmutableList.of( + "JAMES_MAIL_USERFLAG", + "JAMES_MAIL_PROPERTY", + "JAMES_MAILBOX_ANNOTATION", + "JAMES_MAILBOX", + "JAMES_MAIL", + "JAMES_SUBSCRIPTION", + "JAMES_ATTACHMENT"); + + List QUOTA_TABLES_NAMES = ImmutableList.of( + "JAMES_MAX_GLOBAL_MESSAGE_COUNT", + "JAMES_MAX_GLOBAL_STORAGE", + "JAMES_MAX_USER_MESSAGE_COUNT", + "JAMES_MAX_USER_STORAGE", + "JAMES_MAX_DOMAIN_MESSAGE_COUNT", + "JAMES_MAX_DOMAIN_STORAGE", + "JAMES_QUOTA_CURRENTQUOTA" + ); +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPAMailboxManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPAMailboxManagerTest.java new file mode 100644 index 00000000000..b31ce314336 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPAMailboxManagerTest.java @@ -0,0 +1,80 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.mailbox.jpa; + +import java.util.Optional; + +import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.events.EventBus; +import org.apache.james.mailbox.MailboxManagerTest; +import org.apache.james.mailbox.SubscriptionManager; +import org.apache.james.mailbox.jpa.openjpa.OpenJPAMailboxManager; +import org.apache.james.mailbox.store.StoreSubscriptionManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class JPAMailboxManagerTest extends MailboxManagerTest { + + @Disabled("JPAMailboxManager is using DefaultMessageId which doesn't support full feature of a messageId, which is an essential" + + " element of the Vault") + @Nested + class HookTests { + } + + static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); + Optional openJPAMailboxManager = Optional.empty(); + + @Override + protected OpenJPAMailboxManager provideMailboxManager() { + if (!openJPAMailboxManager.isPresent()) { + openJPAMailboxManager = Optional.of(JpaMailboxManagerProvider.provideMailboxManager(JPA_TEST_CLUSTER)); + } + return openJPAMailboxManager.get(); + } + + @Override + protected SubscriptionManager provideSubscriptionManager() { + return new StoreSubscriptionManager(provideMailboxManager().getMapperFactory(), provideMailboxManager().getMapperFactory(), provideMailboxManager().getEventBus()); + } + + @AfterEach + void tearDownJpa() { + JPA_TEST_CLUSTER.clear(JPAMailboxFixture.MAILBOX_TABLE_NAMES); + } + + @Disabled("MAILBOX-353 Creating concurrently mailboxes with the same parents with JPA") + @Test + @Override + public void creatingConcurrentlyMailboxesWithSameParentShouldNotFail() { + + } + + @Nested + @Disabled("JPA does not support saveDate.") + class SaveDateTests { + + } + + @Override + protected EventBus retrieveEventBus(OpenJPAMailboxManager mailboxManager) { + return mailboxManager.getEventBus(); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPASubscriptionManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPASubscriptionManagerTest.java new file mode 100644 index 00000000000..fdc777d31f6 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPASubscriptionManagerTest.java @@ -0,0 +1,72 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.mailbox.jpa; + +import javax.persistence.EntityManagerFactory; + +import org.apache.james.backends.jpa.JPAConfiguration; +import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.events.EventBusTestFixture; +import org.apache.james.events.InVMEventBus; +import org.apache.james.events.MemoryEventDeadLetters; +import org.apache.james.events.delivery.InVmEventDelivery; +import org.apache.james.mailbox.SubscriptionManager; +import org.apache.james.mailbox.SubscriptionManagerContract; +import org.apache.james.mailbox.jpa.mail.JPAModSeqProvider; +import org.apache.james.mailbox.jpa.mail.JPAUidProvider; +import org.apache.james.mailbox.store.StoreSubscriptionManager; +import org.apache.james.metrics.tests.RecordingMetricFactory; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +class JPASubscriptionManagerTest implements SubscriptionManagerContract { + + static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); + + SubscriptionManager subscriptionManager; + + @Override + public SubscriptionManager getSubscriptionManager() { + return subscriptionManager; + } + + @BeforeEach + void setUp() { + EntityManagerFactory entityManagerFactory = JPA_TEST_CLUSTER.getEntityManagerFactory(); + + JPAConfiguration jpaConfiguration = JPAConfiguration.builder() + .driverName("driverName") + .driverURL("driverUrl") + .build(); + + JPAMailboxSessionMapperFactory mapperFactory = new JPAMailboxSessionMapperFactory(entityManagerFactory, + new JPAUidProvider(entityManagerFactory), + new JPAModSeqProvider(entityManagerFactory), + jpaConfiguration); + InVMEventBus eventBus = new InVMEventBus(new InVmEventDelivery(new RecordingMetricFactory()), EventBusTestFixture.RETRY_BACKOFF_CONFIGURATION, new MemoryEventDeadLetters()); + subscriptionManager = new StoreSubscriptionManager(mapperFactory, mapperFactory, eventBus); + } + + @AfterEach + void close() { + JPA_TEST_CLUSTER.clear(JPAMailboxFixture.MAILBOX_TABLE_NAMES); + } + +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerProvider.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerProvider.java new file mode 100644 index 00000000000..770f17dd7e1 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerProvider.java @@ -0,0 +1,87 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa; + +import java.time.Instant; + +import javax.persistence.EntityManagerFactory; + +import org.apache.james.backends.jpa.JPAConfiguration; +import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.events.EventBusTestFixture; +import org.apache.james.events.InVMEventBus; +import org.apache.james.events.MemoryEventDeadLetters; +import org.apache.james.events.delivery.InVmEventDelivery; +import org.apache.james.mailbox.Authenticator; +import org.apache.james.mailbox.Authorizator; +import org.apache.james.mailbox.acl.MailboxACLResolver; +import org.apache.james.mailbox.acl.UnionMailboxACLResolver; +import org.apache.james.mailbox.jpa.mail.JPAModSeqProvider; +import org.apache.james.mailbox.jpa.mail.JPAUidProvider; +import org.apache.james.mailbox.jpa.openjpa.OpenJPAMailboxManager; +import org.apache.james.mailbox.store.SessionProviderImpl; +import org.apache.james.mailbox.store.StoreMailboxAnnotationManager; +import org.apache.james.mailbox.store.StoreRightManager; +import org.apache.james.mailbox.store.extractor.DefaultTextExtractor; +import org.apache.james.mailbox.store.mail.NaiveThreadIdGuessingAlgorithm; +import org.apache.james.mailbox.store.mail.model.DefaultMessageId; +import org.apache.james.mailbox.store.mail.model.impl.MessageParser; +import org.apache.james.mailbox.store.quota.QuotaComponents; +import org.apache.james.mailbox.store.search.MessageSearchIndex; +import org.apache.james.mailbox.store.search.SimpleMessageSearchIndex; +import org.apache.james.metrics.tests.RecordingMetricFactory; +import org.apache.james.utils.UpdatableTickingClock; + +public class JpaMailboxManagerProvider { + + private static final int LIMIT_ANNOTATIONS = 3; + private static final int LIMIT_ANNOTATION_SIZE = 30; + + public static OpenJPAMailboxManager provideMailboxManager(JpaTestCluster jpaTestCluster) { + EntityManagerFactory entityManagerFactory = jpaTestCluster.getEntityManagerFactory(); + + JPAConfiguration jpaConfiguration = JPAConfiguration.builder() + .driverName("driverName") + .driverURL("driverUrl") + .attachmentStorage(true) + .build(); + + JPAMailboxSessionMapperFactory mf = new JPAMailboxSessionMapperFactory(entityManagerFactory, new JPAUidProvider(entityManagerFactory), new JPAModSeqProvider(entityManagerFactory), jpaConfiguration); + + MailboxACLResolver aclResolver = new UnionMailboxACLResolver(); + MessageParser messageParser = new MessageParser(); + + Authenticator noAuthenticator = null; + Authorizator noAuthorizator = null; + + InVMEventBus eventBus = new InVMEventBus(new InVmEventDelivery(new RecordingMetricFactory()), EventBusTestFixture.RETRY_BACKOFF_CONFIGURATION, new MemoryEventDeadLetters()); + StoreRightManager storeRightManager = new StoreRightManager(mf, aclResolver, eventBus); + StoreMailboxAnnotationManager annotationManager = new StoreMailboxAnnotationManager(mf, storeRightManager, + LIMIT_ANNOTATIONS, LIMIT_ANNOTATION_SIZE); + SessionProviderImpl sessionProvider = new SessionProviderImpl(noAuthenticator, noAuthorizator); + QuotaComponents quotaComponents = QuotaComponents.disabled(sessionProvider, mf); + MessageSearchIndex index = new SimpleMessageSearchIndex(mf, mf, new DefaultTextExtractor(), new JPAAttachmentContentLoader()); + + return new OpenJPAMailboxManager(mf, sessionProvider, + messageParser, new DefaultMessageId.Factory(), + eventBus, annotationManager, + storeRightManager, quotaComponents, index, new NaiveThreadIdGuessingAlgorithm(), new UpdatableTickingClock(Instant.now())); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerStressTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerStressTest.java new file mode 100644 index 00000000000..69176686d02 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerStressTest.java @@ -0,0 +1,57 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa; + +import java.util.Optional; + +import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.events.EventBus; +import org.apache.james.mailbox.MailboxManagerStressContract; +import org.apache.james.mailbox.jpa.openjpa.OpenJPAMailboxManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +class JpaMailboxManagerStressTest implements MailboxManagerStressContract { + + static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); + Optional openJPAMailboxManager = Optional.empty(); + + @Override + public OpenJPAMailboxManager getManager() { + return openJPAMailboxManager.get(); + } + + @Override + public EventBus retrieveEventBus() { + return getManager().getEventBus(); + } + + @BeforeEach + void setUp() { + if (!openJPAMailboxManager.isPresent()) { + openJPAMailboxManager = Optional.of(JpaMailboxManagerProvider.provideMailboxManager(JPA_TEST_CLUSTER)); + } + } + + @AfterEach + void tearDown() { + JPA_TEST_CLUSTER.clear(JPAMailboxFixture.MAILBOX_TABLE_NAMES); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JPAAttachmentMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JPAAttachmentMapperTest.java new file mode 100644 index 00000000000..d4dc4282a5a --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JPAAttachmentMapperTest.java @@ -0,0 +1,102 @@ +/************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + **************************************************************/ + + + +package org.apache.james.mailbox.jpa.mail; + +import java.nio.charset.StandardCharsets; + +import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.mailbox.jpa.JPAMailboxFixture; +import org.apache.james.mailbox.model.AttachmentMetadata; +import org.apache.james.mailbox.model.ContentType; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.model.ParsedAttachment; +import org.apache.james.mailbox.store.mail.AttachmentMapper; +import org.apache.james.mailbox.store.mail.model.AttachmentMapperTest; +import org.apache.james.mailbox.store.mail.model.DefaultMessageId; + +import com.google.common.collect.ImmutableList; +import com.google.common.io.ByteSource; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.tuple; + +class JPAAttachmentMapperTest extends AttachmentMapperTest { + + private static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); + + @AfterEach + void cleanUp() { + JPA_TEST_CLUSTER.clear(JPAMailboxFixture.MAILBOX_TABLE_NAMES); + } + + @Override + protected AttachmentMapper createAttachmentMapper() { + return new TransactionalAttachmentMapper(new JPAAttachmentMapper(JPA_TEST_CLUSTER.getEntityManagerFactory())); + } + + @Override + protected MessageId generateMessageId() { + return new DefaultMessageId.Factory().generate(); + } + + @Test + @Override + public void getAttachmentsShouldReturnTheAttachmentsWhenSome() throws Exception { + //Given + ContentType content1 = ContentType.of("content"); + byte[] bytes1 = "payload" .getBytes(StandardCharsets.UTF_8); + ContentType content2 = ContentType.of("content"); + byte[] bytes2 = "payload" .getBytes(StandardCharsets.UTF_8); + + MessageId messageId1 = generateMessageId(); + AttachmentMetadata stored1 = attachmentMapper.storeAttachments(ImmutableList.of(ParsedAttachment.builder() + .contentType(content1) + .content(ByteSource.wrap(bytes1)) + .noName() + .noCid() + .inline(false)), messageId1).get(0) + .getAttachment(); + AttachmentMetadata stored2 = attachmentMapper.storeAttachments(ImmutableList.of(ParsedAttachment.builder() + .contentType(content2) + .content(ByteSource.wrap(bytes2)) + .noName() + .noCid() + .inline(false)), messageId1).get(0) + .getAttachment(); + + // JPA does not support MessageId + assertThat(attachmentMapper.getAttachments(ImmutableList.of(stored1.getAttachmentId(), stored2.getAttachmentId()))) + .extracting( + AttachmentMetadata::getAttachmentId, + AttachmentMetadata::getSize, + AttachmentMetadata::getType + ) + .contains( + tuple(stored1.getAttachmentId(), stored1.getSize(), stored1.getType()), + tuple(stored2.getAttachmentId(), stored2.getSize(), stored2.getType()) + ); + } + +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JPAMapperProvider.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JPAMapperProvider.java new file mode 100644 index 00000000000..c5e054b3bd2 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JPAMapperProvider.java @@ -0,0 +1,122 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.mail; + +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +import javax.persistence.EntityManagerFactory; + +import org.apache.commons.lang3.NotImplementedException; +import org.apache.james.backends.jpa.JPAConfiguration; +import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.jpa.JPAId; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.store.mail.AttachmentMapper; +import org.apache.james.mailbox.store.mail.MailboxMapper; +import org.apache.james.mailbox.store.mail.MessageIdMapper; +import org.apache.james.mailbox.store.mail.MessageMapper; +import org.apache.james.mailbox.store.mail.model.DefaultMessageId; +import org.apache.james.mailbox.store.mail.model.MapperProvider; + +import com.google.common.collect.ImmutableList; + +public class JPAMapperProvider implements MapperProvider { + + private final JpaTestCluster jpaTestCluster; + + public JPAMapperProvider(JpaTestCluster jpaTestCluster) { + this.jpaTestCluster = jpaTestCluster; + } + + @Override + public MailboxMapper createMailboxMapper() { + return new TransactionalMailboxMapper(new JPAMailboxMapper(jpaTestCluster.getEntityManagerFactory())); + } + + @Override + public MessageMapper createMessageMapper() { + EntityManagerFactory entityManagerFactory = jpaTestCluster.getEntityManagerFactory(); + + JPAConfiguration jpaConfiguration = JPAConfiguration.builder() + .driverName("driverName") + .driverURL("driverUrl") + .attachmentStorage(true) + .build(); + + JPAMessageMapper messageMapper = new JPAMessageMapper(new JPAUidProvider(entityManagerFactory), + new JPAModSeqProvider(entityManagerFactory), + entityManagerFactory, + jpaConfiguration); + + return new TransactionalMessageMapper(messageMapper); + } + + @Override + public AttachmentMapper createAttachmentMapper() throws MailboxException { + return new TransactionalAttachmentMapper(new JPAAttachmentMapper(jpaTestCluster.getEntityManagerFactory())); + } + + @Override + public MailboxId generateId() { + return JPAId.of(Math.abs(ThreadLocalRandom.current().nextInt())); + } + + @Override + public MessageId generateMessageId() { + return new DefaultMessageId.Factory().generate(); + } + + @Override + public boolean supportPartialAttachmentFetch() { + return false; + } + + @Override + public List getSupportedCapabilities() { + return ImmutableList.of(Capabilities.ANNOTATION, Capabilities.MAILBOX, Capabilities.MESSAGE, Capabilities.MOVE, Capabilities.ATTACHMENT); + } + + @Override + public MessageIdMapper createMessageIdMapper() throws MailboxException { + throw new NotImplementedException("not implemented"); + } + + @Override + public MessageUid generateMessageUid() { + throw new NotImplementedException("not implemented"); + } + + @Override + public ModSeq generateModSeq(Mailbox mailbox) throws MailboxException { + throw new NotImplementedException("not implemented"); + } + + @Override + public ModSeq highestModSeq(Mailbox mailbox) throws MailboxException { + throw new NotImplementedException("not implemented"); + } + +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JPAMessageWithAttachmentMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JPAMessageWithAttachmentMapperTest.java new file mode 100644 index 00000000000..7383b55b711 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JPAMessageWithAttachmentMapperTest.java @@ -0,0 +1,132 @@ +/************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.mail; + +import java.io.IOException; +import java.util.Iterator; +import java.util.List; + +import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.jpa.JPAMailboxFixture; +import org.apache.james.mailbox.model.AttachmentMetadata; +import org.apache.james.mailbox.model.MessageAttachmentMetadata; +import org.apache.james.mailbox.model.MessageRange; +import org.apache.james.mailbox.store.mail.MessageMapper; +import org.apache.james.mailbox.store.mail.model.MailboxMessage; +import org.apache.james.mailbox.store.mail.model.MapperProvider; +import org.apache.james.mailbox.store.mail.model.MessageAssert; +import org.apache.james.mailbox.store.mail.model.MessageWithAttachmentMapperTest; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.tuple; + +class JPAMessageWithAttachmentMapperTest extends MessageWithAttachmentMapperTest { + + static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); + + @Override + protected MapperProvider createMapperProvider() { + return new JPAMapperProvider(JPA_TEST_CLUSTER); + } + + @AfterEach + void cleanUp() { + JPA_TEST_CLUSTER.clear(JPAMailboxFixture.MAILBOX_TABLE_NAMES); + } + + @Test + @Override + protected void messagesRetrievedUsingFetchTypeFullShouldHaveAttachmentsLoadedWhenOneAttachment() throws MailboxException { + saveMessages(); + MessageMapper.FetchType fetchType = MessageMapper.FetchType.FULL; + Iterator retrievedMessageIterator = messageMapper.findInMailbox(attachmentsMailbox, MessageRange.one(messageWith1Attachment.getUid()), fetchType, LIMIT); + + AttachmentMetadata attachment = messageWith1Attachment.getAttachments().get(0).getAttachment(); + MessageAttachmentMetadata attachmentMetadata = messageWith1Attachment.getAttachments().get(0); + List messageAttachments = retrievedMessageIterator.next().getAttachments(); + + // JPA does not support MessageId + assertThat(messageAttachments) + .extracting(MessageAttachmentMetadata::getAttachment) + .extracting("attachmentId", "size", "type") + .containsExactlyInAnyOrder( + tuple(attachment.getAttachmentId(), attachment.getSize(), attachment.getType()) + ); + assertThat(messageAttachments) + .extracting( + MessageAttachmentMetadata::getAttachmentId, + MessageAttachmentMetadata::getName, + MessageAttachmentMetadata::getCid, + MessageAttachmentMetadata::isInline + ) + .containsExactlyInAnyOrder( + tuple(attachmentMetadata.getAttachmentId(), attachmentMetadata.getName(), attachmentMetadata.getCid(), attachmentMetadata.isInline()) + ); + } + + @Test + @Override + protected void messagesRetrievedUsingFetchTypeFullShouldHaveAttachmentsLoadedWhenTwoAttachments() throws MailboxException { + saveMessages(); + MessageMapper.FetchType fetchType = MessageMapper.FetchType.FULL; + Iterator retrievedMessageIterator = messageMapper.findInMailbox(attachmentsMailbox, MessageRange.one(messageWith2Attachments.getUid()), fetchType, LIMIT); + + AttachmentMetadata attachment1 = messageWith2Attachments.getAttachments().get(0).getAttachment(); + AttachmentMetadata attachment2 = messageWith2Attachments.getAttachments().get(1).getAttachment(); + MessageAttachmentMetadata attachmentMetadata1 = messageWith2Attachments.getAttachments().get(0); + MessageAttachmentMetadata attachmentMetadata2 = messageWith2Attachments.getAttachments().get(1); + List messageAttachments = retrievedMessageIterator.next().getAttachments(); + + // JPA does not support MessageId + assertThat(messageAttachments) + .extracting(MessageAttachmentMetadata::getAttachment) + .extracting("attachmentId", "size", "type") + .containsExactlyInAnyOrder( + tuple(attachment1.getAttachmentId(), attachment1.getSize(), attachment1.getType()), + tuple(attachment2.getAttachmentId(), attachment2.getSize(), attachment2.getType()) + ); + assertThat(messageAttachments) + .extracting( + MessageAttachmentMetadata::getAttachmentId, + MessageAttachmentMetadata::getName, + MessageAttachmentMetadata::getCid, + MessageAttachmentMetadata::isInline + ) + .containsExactlyInAnyOrder( + tuple(attachmentMetadata1.getAttachmentId(), attachmentMetadata1.getName(), attachmentMetadata1.getCid(), attachmentMetadata1.isInline()), + tuple(attachmentMetadata2.getAttachmentId(), attachmentMetadata2.getName(), attachmentMetadata2.getCid(), attachmentMetadata2.isInline()) + ); + } + + @Test + @Override + protected void messagesCanBeRetrievedInMailboxWithRangeTypeOne() throws MailboxException, IOException { + saveMessages(); + MessageMapper.FetchType fetchType = MessageMapper.FetchType.FULL; + + // JPA does not support MessageId + MessageAssert.assertThat(messageMapper.findInMailbox(attachmentsMailbox, MessageRange.one(messageWith1Attachment.getUid()), fetchType, LIMIT).next()) + .isEqualToWithoutAttachment(messageWith1Attachment, fetchType); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaAnnotationMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaAnnotationMapperTest.java new file mode 100644 index 00000000000..d2826ff3952 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaAnnotationMapperTest.java @@ -0,0 +1,52 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.mail; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.mailbox.jpa.JPAId; +import org.apache.james.mailbox.jpa.JPAMailboxFixture; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.store.mail.AnnotationMapper; +import org.apache.james.mailbox.store.mail.model.AnnotationMapperTest; +import org.junit.jupiter.api.AfterEach; + +class JpaAnnotationMapperTest extends AnnotationMapperTest { + + static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); + + final AtomicInteger counter = new AtomicInteger(); + + @AfterEach + void tearDown() { + JPA_TEST_CLUSTER.clear(JPAMailboxFixture.MAILBOX_TABLE_NAMES); + } + + @Override + protected AnnotationMapper createAnnotationMapper() { + return new TransactionalAnnotationMapper(new JPAAnnotationMapper(JPA_TEST_CLUSTER.getEntityManagerFactory())); + } + + @Override + protected MailboxId generateMailboxId() { + return JPAId.of(counter.incrementAndGet()); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaMailboxMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaMailboxMapperTest.java new file mode 100644 index 00000000000..32aec06b28e --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaMailboxMapperTest.java @@ -0,0 +1,90 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.mail; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.atomic.AtomicInteger; + +import javax.persistence.EntityManager; + +import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.mailbox.jpa.JPAId; +import org.apache.james.mailbox.jpa.JPAMailboxFixture; +import org.apache.james.mailbox.jpa.mail.model.JPAMailbox; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.store.mail.MailboxMapper; +import org.apache.james.mailbox.store.mail.model.MailboxMapperTest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class JpaMailboxMapperTest extends MailboxMapperTest { + + static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); + + final AtomicInteger counter = new AtomicInteger(); + + @Override + protected MailboxMapper createMailboxMapper() { + return new TransactionalMailboxMapper(new JPAMailboxMapper(JPA_TEST_CLUSTER.getEntityManagerFactory())); + } + + @Override + protected MailboxId generateId() { + return JPAId.of(counter.incrementAndGet()); + } + + @AfterEach + void cleanUp() { + JPA_TEST_CLUSTER.clear(JPAMailboxFixture.MAILBOX_TABLE_NAMES); + } + + @Test + void invalidUidValidityShouldBeSanitized() throws Exception { + EntityManager entityManager = JPA_TEST_CLUSTER.getEntityManagerFactory().createEntityManager(); + + entityManager.getTransaction().begin(); + JPAMailbox jpaMailbox = new JPAMailbox(benwaInboxPath, -1L);// set an invalid uid validity + jpaMailbox.setUidValidity(-1L); + entityManager.persist(jpaMailbox); + entityManager.getTransaction().commit(); + + Mailbox readMailbox = mailboxMapper.findMailboxByPath(benwaInboxPath).block(); + + assertThat(readMailbox.getUidValidity().isValid()).isTrue(); + } + + @Test + void uidValiditySanitizingShouldPersistTheSanitizedUidValidity() throws Exception { + EntityManager entityManager = JPA_TEST_CLUSTER.getEntityManagerFactory().createEntityManager(); + + entityManager.getTransaction().begin(); + JPAMailbox jpaMailbox = new JPAMailbox(benwaInboxPath, -1L);// set an invalid uid validity + jpaMailbox.setUidValidity(-1L); + entityManager.persist(jpaMailbox); + entityManager.getTransaction().commit(); + + Mailbox readMailbox1 = mailboxMapper.findMailboxByPath(benwaInboxPath).block(); + Mailbox readMailbox2 = mailboxMapper.findMailboxByPath(benwaInboxPath).block(); + + assertThat(readMailbox1.getUidValidity()).isEqualTo(readMailbox2.getUidValidity()); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaMessageMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaMessageMapperTest.java new file mode 100644 index 00000000000..6a9c7055dd3 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaMessageMapperTest.java @@ -0,0 +1,156 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.mail; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Optional; + +import javax.mail.Flags; + +import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.mailbox.FlagsBuilder; +import org.apache.james.mailbox.MessageManager; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.jpa.JPAMailboxFixture; +import org.apache.james.mailbox.model.UpdatedFlags; +import org.apache.james.mailbox.store.FlagsUpdateCalculator; +import org.apache.james.mailbox.store.mail.model.MapperProvider; +import org.apache.james.mailbox.store.mail.model.MessageMapperTest; +import org.apache.james.utils.UpdatableTickingClock; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class JpaMessageMapperTest extends MessageMapperTest { + + static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); + + @Override + protected MapperProvider createMapperProvider() { + return new JPAMapperProvider(JPA_TEST_CLUSTER); + } + + @Override + protected UpdatableTickingClock updatableTickingClock() { + return null; + } + + @AfterEach + void cleanUp() { + JPA_TEST_CLUSTER.clear(JPAMailboxFixture.MAILBOX_TABLE_NAMES); + } + + @Test + @Override + public void flagsAdditionShouldReturnAnUpdatedFlagHighlightingTheAddition() throws MailboxException { + saveMessages(); + messageMapper.updateFlags(benwaInboxMailbox, message1.getUid(), new FlagsUpdateCalculator(new Flags(Flags.Flag.FLAGGED), MessageManager.FlagsUpdateMode.REPLACE)); + ModSeq modSeq = messageMapper.getHighestModSeq(benwaInboxMailbox); + + // JPA does not support MessageId + assertThat(messageMapper.updateFlags(benwaInboxMailbox, message1.getUid(), new FlagsUpdateCalculator(new Flags(Flags.Flag.SEEN), MessageManager.FlagsUpdateMode.ADD))) + .contains(UpdatedFlags.builder() + .uid(message1.getUid()) + .modSeq(modSeq.next()) + .oldFlags(new Flags(Flags.Flag.FLAGGED)) + .newFlags(new FlagsBuilder().add(Flags.Flag.SEEN, Flags.Flag.FLAGGED).build()) + .build()); + } + + @Test + @Override + public void flagsReplacementShouldReturnAnUpdatedFlagHighlightingTheReplacement() throws MailboxException { + saveMessages(); + ModSeq modSeq = messageMapper.getHighestModSeq(benwaInboxMailbox); + Optional updatedFlags = messageMapper.updateFlags(benwaInboxMailbox, message1.getUid(), + new FlagsUpdateCalculator(new Flags(Flags.Flag.FLAGGED), MessageManager.FlagsUpdateMode.REPLACE)); + + // JPA does not support MessageId + assertThat(updatedFlags) + .contains(UpdatedFlags.builder() + .uid(message1.getUid()) + .modSeq(modSeq.next()) + .oldFlags(new Flags()) + .newFlags(new Flags(Flags.Flag.FLAGGED)) + .build()); + } + + @Test + @Override + public void flagsRemovalShouldReturnAnUpdatedFlagHighlightingTheRemoval() throws MailboxException { + saveMessages(); + messageMapper.updateFlags(benwaInboxMailbox, message1.getUid(), new FlagsUpdateCalculator(new FlagsBuilder().add(Flags.Flag.FLAGGED, Flags.Flag.SEEN).build(), MessageManager.FlagsUpdateMode.REPLACE)); + ModSeq modSeq = messageMapper.getHighestModSeq(benwaInboxMailbox); + + // JPA does not support MessageId + assertThat(messageMapper.updateFlags(benwaInboxMailbox, message1.getUid(), new FlagsUpdateCalculator(new Flags(Flags.Flag.SEEN), MessageManager.FlagsUpdateMode.REMOVE))) + .contains( + UpdatedFlags.builder() + .uid(message1.getUid()) + .modSeq(modSeq.next()) + .oldFlags(new FlagsBuilder().add(Flags.Flag.SEEN, Flags.Flag.FLAGGED).build()) + .newFlags(new Flags(Flags.Flag.FLAGGED)) + .build()); + } + + @Test + @Override + public void userFlagsUpdateShouldReturnCorrectUpdatedFlags() throws MailboxException { + saveMessages(); + ModSeq modSeq = messageMapper.getHighestModSeq(benwaInboxMailbox); + + // JPA does not support MessageId + assertThat(messageMapper.updateFlags(benwaInboxMailbox, message1.getUid(), new FlagsUpdateCalculator(new Flags(USER_FLAG), MessageManager.FlagsUpdateMode.ADD))) + .contains( + UpdatedFlags.builder() + .uid(message1.getUid()) + .modSeq(modSeq.next()) + .oldFlags(new Flags()) + .newFlags(new Flags(USER_FLAG)) + .build()); + } + + @Test + @Override + public void userFlagsUpdateShouldReturnCorrectUpdatedFlagsWhenNoop() throws MailboxException { + saveMessages(); + + // JPA does not support MessageId + assertThat( + messageMapper.updateFlags(benwaInboxMailbox,message1.getUid(), + new FlagsUpdateCalculator(new Flags(USER_FLAG), MessageManager.FlagsUpdateMode.REMOVE))) + .contains( + UpdatedFlags.builder() + .uid(message1.getUid()) + .modSeq(message1.getModSeq()) + .oldFlags(new Flags()) + .newFlags(new Flags()) + .build()); + } + + @Nested + @Disabled("JPA does not support saveDate.") + class SaveDateTests { + + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaMessageMoveTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaMessageMoveTest.java new file mode 100644 index 00000000000..de8a1d30280 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaMessageMoveTest.java @@ -0,0 +1,42 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.mail; + +import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.mailbox.jpa.JPAMailboxFixture; +import org.apache.james.mailbox.store.mail.model.MapperProvider; +import org.apache.james.mailbox.store.mail.model.MessageMoveTest; +import org.junit.jupiter.api.AfterEach; + +class JpaMessageMoveTest extends MessageMoveTest { + + static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); + + @Override + protected MapperProvider createMapperProvider() { + return new JPAMapperProvider(JPA_TEST_CLUSTER); + } + + @AfterEach + void cleanUp() { + JPA_TEST_CLUSTER.clear(JPAMailboxFixture.MAILBOX_TABLE_NAMES); + } + +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/MessageUtilsTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/MessageUtilsTest.java new file mode 100644 index 00000000000..ca310e77503 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/MessageUtilsTest.java @@ -0,0 +1,105 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.mail; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Date; + +import javax.mail.Flags; + +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.model.ByteContent; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.model.ThreadId; +import org.apache.james.mailbox.store.mail.ModSeqProvider; +import org.apache.james.mailbox.store.mail.UidProvider; +import org.apache.james.mailbox.store.mail.model.DefaultMessageId; +import org.apache.james.mailbox.store.mail.model.MailboxMessage; +import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; +import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +class MessageUtilsTest { + static final MessageUid MESSAGE_UID = MessageUid.of(1); + static final MessageId MESSAGE_ID = new DefaultMessageId(); + static final ThreadId THREAD_ID = ThreadId.fromBaseMessageId(MESSAGE_ID); + static final int BODY_START = 16; + static final String CONTENT = "anycontent"; + + @Mock ModSeqProvider modSeqProvider; + @Mock UidProvider uidProvider; + @Mock Mailbox mailbox; + + MessageUtils messageUtils; + MailboxMessage message; + + @BeforeEach + void setUp() { + MockitoAnnotations.initMocks(this); + messageUtils = new MessageUtils(uidProvider, modSeqProvider); + message = new SimpleMailboxMessage(MESSAGE_ID, THREAD_ID, new Date(), CONTENT.length(), BODY_START, + new ByteContent(CONTENT.getBytes()), new Flags(), new PropertyBuilder().build(), mailbox.getMailboxId()); + } + + @Test + void newInstanceShouldFailWhenNullUidProvider() { + assertThatThrownBy(() -> new MessageUtils(null, modSeqProvider)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void newInstanceShouldFailWhenNullModSeqProvider() { + assertThatThrownBy(() -> new MessageUtils(uidProvider, null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void nextModSeqShouldCallModSeqProvider() throws Exception { + messageUtils.nextModSeq(mailbox); + verify(modSeqProvider).nextModSeq(eq(mailbox)); + } + + @Test + void nextUidShouldCallUidProvider() throws Exception { + messageUtils.nextUid(mailbox); + verify(uidProvider).nextUid(eq(mailbox)); + } + + @Test + void enrichMesageShouldEnrichUidAndModSeq() throws Exception { + when(uidProvider.nextUid(eq(mailbox))).thenReturn(MESSAGE_UID); + when(modSeqProvider.nextModSeq(eq(mailbox))).thenReturn(ModSeq.of(11)); + + messageUtils.enrichMessage(mailbox, message); + + assertThat(message.getUid()).isEqualTo(MESSAGE_UID); + assertThat(message.getModSeq()).isEqualTo(ModSeq.of(11)); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalAnnotationMapper.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalAnnotationMapper.java new file mode 100644 index 00000000000..7a0ff31d272 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalAnnotationMapper.java @@ -0,0 +1,86 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.mail; + +import java.util.List; +import java.util.Set; + +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.model.MailboxAnnotation; +import org.apache.james.mailbox.model.MailboxAnnotationKey; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.store.mail.AnnotationMapper; +import org.apache.james.mailbox.store.transaction.Mapper; + +public class TransactionalAnnotationMapper implements AnnotationMapper { + private final JPAAnnotationMapper wrapped; + + public TransactionalAnnotationMapper(JPAAnnotationMapper wrapped) { + this.wrapped = wrapped; + } + + @Override + public List getAllAnnotations(MailboxId mailboxId) { + return wrapped.getAllAnnotations(mailboxId); + } + + @Override + public List getAnnotationsByKeys(MailboxId mailboxId, Set keys) { + return wrapped.getAnnotationsByKeys(mailboxId, keys); + } + + @Override + public List getAnnotationsByKeysWithOneDepth(MailboxId mailboxId, Set keys) { + return wrapped.getAnnotationsByKeysWithOneDepth(mailboxId, keys); + } + + @Override + public List getAnnotationsByKeysWithAllDepth(MailboxId mailboxId, Set keys) { + return wrapped.getAnnotationsByKeysWithAllDepth(mailboxId, keys); + } + + @Override + public void deleteAnnotation(final MailboxId mailboxId, final MailboxAnnotationKey key) { + try { + wrapped.execute(Mapper.toTransaction(() -> wrapped.deleteAnnotation(mailboxId, key))); + } catch (MailboxException e) { + throw new RuntimeException(e); + } + } + + @Override + public void insertAnnotation(final MailboxId mailboxId, final MailboxAnnotation mailboxAnnotation) { + try { + wrapped.execute(Mapper.toTransaction(() -> wrapped.insertAnnotation(mailboxId, mailboxAnnotation))); + } catch (MailboxException e) { + throw new RuntimeException(e); + } + } + + @Override + public boolean exist(MailboxId mailboxId, MailboxAnnotation mailboxAnnotation) { + return wrapped.exist(mailboxId, mailboxAnnotation); + } + + @Override + public int countAnnotations(MailboxId mailboxId) { + return wrapped.countAnnotations(mailboxId); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalAttachmentMapper.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalAttachmentMapper.java new file mode 100644 index 00000000000..ecdd47f8c3f --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalAttachmentMapper.java @@ -0,0 +1,78 @@ +/*************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.mail; + +import java.io.InputStream; +import java.util.Collection; +import java.util.List; + +import org.apache.james.mailbox.exception.AttachmentNotFoundException; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.model.AttachmentId; +import org.apache.james.mailbox.model.AttachmentMetadata; +import org.apache.james.mailbox.model.MessageAttachmentMetadata; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.model.ParsedAttachment; +import org.apache.james.mailbox.store.mail.AttachmentMapper; + +import reactor.core.publisher.Mono; + +public class TransactionalAttachmentMapper implements AttachmentMapper { + private final JPAAttachmentMapper attachmentMapper; + + public TransactionalAttachmentMapper(JPAAttachmentMapper attachmentMapper) { + this.attachmentMapper = attachmentMapper; + } + + @Override + public InputStream loadAttachmentContent(AttachmentId attachmentId) { + return attachmentMapper.loadAttachmentContent(attachmentId); + } + + @Override + public Mono loadAttachmentContentReactive(AttachmentId attachmentId) { + return attachmentMapper.executeReactive(attachmentMapper.loadAttachmentContentReactive(attachmentId)); + } + + @Override + public AttachmentMetadata getAttachment(AttachmentId attachmentId) throws AttachmentNotFoundException { + return attachmentMapper.getAttachment(attachmentId); + } + + @Override + public Mono getAttachmentReactive(AttachmentId attachmentId) { + return attachmentMapper.executeReactive(attachmentMapper.getAttachmentReactive(attachmentId)); + } + + @Override + public List getAttachments(Collection attachmentIds) { + return attachmentMapper.getAttachments(attachmentIds); + } + + @Override + public List storeAttachments(Collection attachments, MessageId ownerMessageId) throws MailboxException { + return attachmentMapper.execute(() -> attachmentMapper.storeAttachments(attachments, ownerMessageId)); + } + + @Override + public Mono> storeAttachmentsReactive(Collection attachments, MessageId ownerMessageId) { + return attachmentMapper.executeReactive(attachmentMapper.storeAttachmentsReactive(attachments, ownerMessageId)); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalMailboxMapper.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalMailboxMapper.java new file mode 100644 index 00000000000..eef06dedf91 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalMailboxMapper.java @@ -0,0 +1,98 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.mail; + +import org.apache.james.core.Username; +import org.apache.james.mailbox.acl.ACLDiff; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxACL; +import org.apache.james.mailbox.model.MailboxACL.Right; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.UidValidity; +import org.apache.james.mailbox.model.search.MailboxQuery; +import org.apache.james.mailbox.store.mail.MailboxMapper; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class TransactionalMailboxMapper implements MailboxMapper { + private final JPAMailboxMapper wrapped; + + public TransactionalMailboxMapper(JPAMailboxMapper wrapped) { + this.wrapped = wrapped; + } + + @Override + public Mono create(MailboxPath mailboxPath, UidValidity uidValidity) { + return wrapped.executeReactive(wrapped.create(mailboxPath, uidValidity)); + } + + @Override + public Mono rename(Mailbox mailbox) { + return wrapped.executeReactive(wrapped.rename(mailbox)); + } + + @Override + public Mono delete(Mailbox mailbox) { + return wrapped.executeReactive(wrapped.delete(mailbox)); + } + + @Override + public Mono findMailboxByPath(MailboxPath mailboxPath) { + return wrapped.findMailboxByPath(mailboxPath); + } + + @Override + public Mono findMailboxById(MailboxId mailboxId) { + return wrapped.findMailboxById(mailboxId); + } + + @Override + public Flux findMailboxWithPathLike(MailboxQuery.UserBound query) { + return wrapped.findMailboxWithPathLike(query); + } + + @Override + public Mono hasChildren(Mailbox mailbox, char delimiter) { + return wrapped.hasChildren(mailbox, delimiter); + } + + @Override + public Mono updateACL(Mailbox mailbox, MailboxACL.ACLCommand mailboxACLCommand) { + return wrapped.updateACL(mailbox, mailboxACLCommand); + } + + @Override + public Mono setACL(Mailbox mailbox, MailboxACL mailboxACL) { + return wrapped.setACL(mailbox, mailboxACL); + } + + @Override + public Flux list() { + return wrapped.list(); + } + + @Override + public Flux findNonPersonalMailboxes(Username userName, Right right) { + return wrapped.findNonPersonalMailboxes(userName, right); + } + +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalMessageMapper.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalMessageMapper.java new file mode 100644 index 00000000000..ad7e9e56e6d --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalMessageMapper.java @@ -0,0 +1,146 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.mail; + +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import javax.mail.Flags; + +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxCounters; +import org.apache.james.mailbox.model.MessageMetaData; +import org.apache.james.mailbox.model.MessageRange; +import org.apache.james.mailbox.model.UpdatedFlags; +import org.apache.james.mailbox.store.FlagsUpdateCalculator; +import org.apache.james.mailbox.store.mail.MessageMapper; +import org.apache.james.mailbox.store.mail.model.MailboxMessage; +import org.apache.james.mailbox.store.transaction.Mapper; + +import reactor.core.publisher.Flux; + +public class TransactionalMessageMapper implements MessageMapper { + private final JPAMessageMapper messageMapper; + + public TransactionalMessageMapper(JPAMessageMapper messageMapper) { + this.messageMapper = messageMapper; + } + + @Override + public MailboxCounters getMailboxCounters(Mailbox mailbox) throws MailboxException { + return MailboxCounters.builder() + .mailboxId(mailbox.getMailboxId()) + .count(countMessagesInMailbox(mailbox)) + .unseen(countUnseenMessagesInMailbox(mailbox)) + .build(); + } + + @Override + public Flux listAllMessageUids(Mailbox mailbox) { + return messageMapper.listAllMessageUids(mailbox); + } + + @Override + public Iterator findInMailbox(Mailbox mailbox, MessageRange set, FetchType type, int limit) + throws MailboxException { + return messageMapper.findInMailbox(mailbox, set, type, limit); + } + + @Override + public List retrieveMessagesMarkedForDeletion(Mailbox mailbox, MessageRange messageRange) throws MailboxException { + return messageMapper.execute( + () -> messageMapper.retrieveMessagesMarkedForDeletion(mailbox, messageRange)); + } + + @Override + public Map deleteMessages(Mailbox mailbox, List uids) throws MailboxException { + return messageMapper.execute( + () -> messageMapper.deleteMessages(mailbox, uids)); + } + + @Override + public long countMessagesInMailbox(Mailbox mailbox) throws MailboxException { + return messageMapper.countMessagesInMailbox(mailbox); + } + + private long countUnseenMessagesInMailbox(Mailbox mailbox) throws MailboxException { + return messageMapper.countUnseenMessagesInMailbox(mailbox); + } + + @Override + public void delete(final Mailbox mailbox, final MailboxMessage message) throws MailboxException { + messageMapper.execute(Mapper.toTransaction(() -> messageMapper.delete(mailbox, message))); + } + + @Override + public MessageUid findFirstUnseenMessageUid(Mailbox mailbox) throws MailboxException { + return messageMapper.findFirstUnseenMessageUid(mailbox); + } + + @Override + public List findRecentMessageUidsInMailbox(Mailbox mailbox) throws MailboxException { + return messageMapper.findRecentMessageUidsInMailbox(mailbox); + } + + @Override + public MessageMetaData add(final Mailbox mailbox, final MailboxMessage message) throws MailboxException { + return messageMapper.execute( + () -> messageMapper.add(mailbox, message)); + } + + @Override + public Iterator updateFlags(final Mailbox mailbox, final FlagsUpdateCalculator flagsUpdateCalculator, + final MessageRange set) throws MailboxException { + return messageMapper.execute( + () -> messageMapper.updateFlags(mailbox, flagsUpdateCalculator, set)); + } + + @Override + public MessageMetaData copy(final Mailbox mailbox, final MailboxMessage original) throws MailboxException { + return messageMapper.execute( + () -> messageMapper.copy(mailbox, original)); + } + + @Override + public MessageMetaData move(Mailbox mailbox, MailboxMessage original) throws MailboxException { + return messageMapper.execute( + () -> messageMapper.move(mailbox, original)); + } + + @Override + public Optional getLastUid(Mailbox mailbox) throws MailboxException { + return messageMapper.getLastUid(mailbox); + } + + @Override + public ModSeq getHighestModSeq(Mailbox mailbox) throws MailboxException { + return messageMapper.getHighestModSeq(mailbox); + } + + @Override + public Flags getApplicableFlag(Mailbox mailbox) throws MailboxException { + return messageMapper.getApplicableFlag(mailbox); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAMailboxMessageTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAMailboxMessageTest.java new file mode 100644 index 00000000000..31d59a411c7 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAMailboxMessageTest.java @@ -0,0 +1,56 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.mailbox.jpa.mail.model.openjpa; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; + +import org.apache.commons.io.IOUtils; +import org.junit.jupiter.api.Test; + +class JPAMailboxMessageTest { + + private static final byte[] EMPTY = new byte[] {}; + /** + * Even though there should never be a null body, it does happen. See JAMES-2384 + */ + @Test + void getFullContentShouldReturnOriginalContentWhenBodyFieldIsNull() throws Exception { + + // Prepare the message + byte[] content = "Subject: the null message".getBytes(StandardCharsets.UTF_8); + JPAMailboxMessage message = new JPAMailboxMessage(content, null); + + // Get and check + assertThat(IOUtils.toByteArray(message.getFullContent())).containsExactly(content); + + } + + @Test + void getAnyMessagePartThatIsNullShouldYieldEmptyArray() throws Exception { + + // Prepare the message + JPAMailboxMessage message = new JPAMailboxMessage(null, null); + assertThat(IOUtils.toByteArray(message.getHeaderContent())).containsExactly(EMPTY); + assertThat(IOUtils.toByteArray(message.getBodyContent())).containsExactly(EMPTY); + assertThat(IOUtils.toByteArray(message.getFullContent())).containsExactly(EMPTY); + } + +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/task/JPARecomputeCurrentQuotasServiceTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/task/JPARecomputeCurrentQuotasServiceTest.java new file mode 100644 index 00000000000..38fad55face --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/task/JPARecomputeCurrentQuotasServiceTest.java @@ -0,0 +1,148 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.mail.task; + +import javax.persistence.EntityManagerFactory; + +import org.apache.commons.configuration2.BaseHierarchicalConfiguration; +import org.apache.james.backends.jpa.JPAConfiguration; +import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.domainlist.api.DomainList; +import org.apache.james.domainlist.jpa.model.JPADomain; +import org.apache.james.mailbox.MailboxManager; +import org.apache.james.mailbox.SessionProvider; +import org.apache.james.mailbox.jpa.JPAMailboxFixture; +import org.apache.james.mailbox.jpa.JPAMailboxSessionMapperFactory; +import org.apache.james.mailbox.jpa.JpaMailboxManagerProvider; +import org.apache.james.mailbox.jpa.mail.JPAModSeqProvider; +import org.apache.james.mailbox.jpa.mail.JPAUidProvider; +import org.apache.james.mailbox.jpa.quota.JpaCurrentQuotaManager; +import org.apache.james.mailbox.quota.CurrentQuotaManager; +import org.apache.james.mailbox.quota.UserQuotaRootResolver; +import org.apache.james.mailbox.quota.task.RecomputeCurrentQuotasService; +import org.apache.james.mailbox.quota.task.RecomputeCurrentQuotasServiceContract; +import org.apache.james.mailbox.quota.task.RecomputeMailboxCurrentQuotasService; +import org.apache.james.mailbox.store.StoreMailboxManager; +import org.apache.james.mailbox.store.quota.CurrentQuotaCalculator; +import org.apache.james.mailbox.store.quota.DefaultUserQuotaRootResolver; +import org.apache.james.user.api.UsersRepository; +import org.apache.james.user.jpa.JPAUsersRepository; +import org.apache.james.user.jpa.model.JPAUser; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; + +class JPARecomputeCurrentQuotasServiceTest implements RecomputeCurrentQuotasServiceContract { + + static final DomainList NO_DOMAIN_LIST = null; + + static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(ImmutableList.>builder() + .addAll(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES) + .addAll(JPAMailboxFixture.QUOTA_PERSISTANCE_CLASSES) + .add(JPAUser.class) + .add(JPADomain.class) + .build()); + + JPAUsersRepository usersRepository; + StoreMailboxManager mailboxManager; + SessionProvider sessionProvider; + CurrentQuotaManager currentQuotaManager; + UserQuotaRootResolver userQuotaRootResolver; + RecomputeCurrentQuotasService testee; + + @BeforeEach + void setUp() throws Exception { + EntityManagerFactory entityManagerFactory = JPA_TEST_CLUSTER.getEntityManagerFactory(); + + JPAConfiguration jpaConfiguration = JPAConfiguration.builder() + .driverName("driverName") + .driverURL("driverUrl") + .build(); + + JPAMailboxSessionMapperFactory mapperFactory = new JPAMailboxSessionMapperFactory(entityManagerFactory, + new JPAUidProvider(entityManagerFactory), + new JPAModSeqProvider(entityManagerFactory), + jpaConfiguration); + + usersRepository = new JPAUsersRepository(NO_DOMAIN_LIST); + usersRepository.setEntityManagerFactory(JPA_TEST_CLUSTER.getEntityManagerFactory()); + BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); + configuration.addProperty("enableVirtualHosting", "false"); + usersRepository.configure(configuration); + + mailboxManager = JpaMailboxManagerProvider.provideMailboxManager(JPA_TEST_CLUSTER); + sessionProvider = mailboxManager.getSessionProvider(); + currentQuotaManager = new JpaCurrentQuotaManager(entityManagerFactory); + + userQuotaRootResolver = new DefaultUserQuotaRootResolver(sessionProvider, mapperFactory); + + CurrentQuotaCalculator currentQuotaCalculator = new CurrentQuotaCalculator(mapperFactory, userQuotaRootResolver); + + testee = new RecomputeCurrentQuotasService(usersRepository, + ImmutableSet.of(new RecomputeMailboxCurrentQuotasService(currentQuotaManager, + currentQuotaCalculator, + userQuotaRootResolver, + sessionProvider, + mailboxManager), + RECOMPUTE_JMAP_UPLOAD_CURRENT_QUOTAS_SERVICE)); + } + + @AfterEach + void tearDownJpa() { + JPA_TEST_CLUSTER.clear(ImmutableList.builder() + .addAll(JPAMailboxFixture.MAILBOX_TABLE_NAMES) + .addAll(JPAMailboxFixture.QUOTA_TABLES_NAMES) + .add("JAMES_USER") + .add("JAMES_DOMAIN") + .build()); + } + + @Override + public UsersRepository usersRepository() { + return usersRepository; + } + + @Override + public SessionProvider sessionProvider() { + return sessionProvider; + } + + @Override + public MailboxManager mailboxManager() { + return mailboxManager; + } + + @Override + public CurrentQuotaManager currentQuotaManager() { + return currentQuotaManager; + } + + @Override + public UserQuotaRootResolver userQuotaRootResolver() { + return userQuotaRootResolver; + } + + @Override + public RecomputeCurrentQuotasService testee() { + return testee; + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/quota/JPACurrentQuotaManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/quota/JPACurrentQuotaManagerTest.java new file mode 100644 index 00000000000..18975136c77 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/quota/JPACurrentQuotaManagerTest.java @@ -0,0 +1,42 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.quota; + +import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.mailbox.jpa.JPAMailboxFixture; +import org.apache.james.mailbox.quota.CurrentQuotaManager; +import org.apache.james.mailbox.store.quota.CurrentQuotaManagerContract; +import org.junit.jupiter.api.AfterEach; + +class JPACurrentQuotaManagerTest implements CurrentQuotaManagerContract { + + static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.QUOTA_PERSISTANCE_CLASSES); + + @Override + public CurrentQuotaManager testee() { + return new JpaCurrentQuotaManager(JPA_TEST_CLUSTER.getEntityManagerFactory()); + } + + @AfterEach + void tearDown() { + JPA_TEST_CLUSTER.clear(JPAMailboxFixture.QUOTA_TABLES_NAMES); + } + +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/quota/JPAPerUserMaxQuotaTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/quota/JPAPerUserMaxQuotaTest.java new file mode 100644 index 00000000000..8cb8f8be851 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/quota/JPAPerUserMaxQuotaTest.java @@ -0,0 +1,41 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.quota; + +import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.mailbox.jpa.JPAMailboxFixture; +import org.apache.james.mailbox.quota.MaxQuotaManager; +import org.apache.james.mailbox.store.quota.GenericMaxQuotaManagerTest; +import org.junit.jupiter.api.AfterEach; + +class JPAPerUserMaxQuotaTest extends GenericMaxQuotaManagerTest { + + static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.QUOTA_PERSISTANCE_CLASSES); + + @Override + protected MaxQuotaManager provideMaxQuotaManager() { + return new JPAPerUserMaxQuotaManager(JPA_TEST_CLUSTER.getEntityManagerFactory(), new JPAPerUserMaxQuotaDAO(JPA_TEST_CLUSTER.getEntityManagerFactory())); + } + + @AfterEach + void cleanUp() { + JPA_TEST_CLUSTER.clear(JPAMailboxFixture.QUOTA_TABLES_NAMES); + } +} diff --git a/mailbox/postgres/src/test/resources/persistence.xml b/mailbox/postgres/src/test/resources/persistence.xml new file mode 100644 index 00000000000..ae8f4361d0d --- /dev/null +++ b/mailbox/postgres/src/test/resources/persistence.xml @@ -0,0 +1,53 @@ + + + + + + + org.apache.james.mailbox.jpa.mail.model.JPAMailbox + org.apache.james.mailbox.jpa.mail.model.JPAUserFlag + org.apache.james.mailbox.jpa.mail.model.openjpa.AbstractJPAMailboxMessage + org.apache.james.mailbox.jpa.mail.model.openjpa.JPAMailboxMessage + org.apache.james.mailbox.jpa.mail.model.JPAAttachment + org.apache.james.mailbox.jpa.mail.model.JPAProperty + org.apache.james.mailbox.jpa.user.model.JPASubscription + org.apache.james.mailbox.jpa.quota.model.MaxDomainMessageCount + org.apache.james.mailbox.jpa.quota.model.MaxDomainStorage + org.apache.james.mailbox.jpa.quota.model.MaxGlobalMessageCount + org.apache.james.mailbox.jpa.quota.model.MaxGlobalStorage + org.apache.james.mailbox.jpa.quota.model.MaxUserMessageCount + org.apache.james.mailbox.jpa.quota.model.MaxUserStorage + org.apache.james.mailbox.jpa.quota.model.JpaCurrentQuota + org.apache.james.mailbox.jpa.mail.model.JPAMailboxAnnotation + org.apache.james.mailbox.jpa.mail.model.JPAMailboxAnnotationId + org.apache.james.mailbox.jpa.mail.model.openjpa.AbstractJPAMailboxMessage$MailboxIdUidKey + + + + + + + + + + diff --git a/pom.xml b/pom.xml index d63ed615e88..b777b4293fa 100644 --- a/pom.xml +++ b/pom.xml @@ -700,6 +700,17 @@ ${project.version} test-jar + + ${james.groupId} + apache-james-backends-postgres + ${project.version} + + + ${james.groupId} + apache-james-backends-postgres + ${project.version} + test-jar + ${james.groupId} apache-james-backends-pulsar From 8b82b4b81ec9693418a91a5657b4583f057f4348 Mon Sep 17 00:00:00 2001 From: vttran Date: Mon, 30 Oct 2023 11:45:56 +0700 Subject: [PATCH 022/341] JAMES-2586 - Postgres - Init postgres app server - artifactId: james-server-postgres-app - Copy from apps/jpa-app -> apps/postgres-app --- backends-common/postgres/pom.xml | 1 + pom.xml | 11 + server/apps/postgres-app/README.adoc | 145 +++ server/apps/postgres-app/docker-compose.yml | 29 + .../docker-configuration/webadmin.properties | 54 + server/apps/postgres-app/pom.xml | 445 +++++++ .../sample-configuration/dnsservice.xml | 27 + .../sample-configuration/domainlist.xml | 27 + .../extensions.properties | 10 + .../healthcheck.properties | 33 + .../sample-configuration/imapserver.xml | 83 ++ .../james-database-postgres.properties | 49 + .../james-database.properties | 53 + .../sample-configuration/jmx.properties | 26 + .../sample-configuration/jvm.properties | 53 + .../sample-configuration/jwt_publickey | 9 + .../sample-configuration/listeners.xml | 24 + .../sample-configuration/lmtpserver.xml | 43 + .../sample-configuration/logback.xml | 39 + .../sample-configuration/mailetcontainer.xml | 145 +++ .../mailrepositorystore.xml | 37 + .../managesieveserver.xml | 65 + .../sample-configuration/pop3server.xml | 50 + .../recipientrewritetable.xml | 28 + .../sample-configuration/smtpserver.xml | 159 +++ .../sample-configuration/usersrepository.xml | 28 + .../sample-configuration/webadmin.properties | 49 + server/apps/postgres-app/src/assemble/app.xml | 86 ++ .../src/assemble/extensions-jars.txt | 5 + .../src/assemble/license-for-binary.txt | 1139 +++++++++++++++++ .../src/main/extensions-jars/README.md | 5 + .../postgres-app/src/main/glowroot/admin.json | 5 + .../src/main/glowroot/plugins/imap.json | 19 + .../src/main/glowroot/plugins/jmap.json | 19 + .../glowroot/plugins/mailboxListener.json | 19 + .../src/main/glowroot/plugins/pop3.json | 19 + .../src/main/glowroot/plugins/smtp.json | 19 + .../src/main/glowroot/plugins/spooler.json | 45 + .../src/main/glowroot/plugins/task.json | 19 + .../james/PostgresJamesConfiguration.java | 127 ++ .../apache/james/PostgresJamesServerMain.java | 119 ++ .../main/resources/META-INF/persistence.xml | 64 + .../main/resources/defaultMailetContainer.xml | 87 ++ .../postgres-app/src/main/scripts/james-cli | 3 + .../org/apache/james/JPAJamesServerTest.java | 98 ++ ...uthenticatedDatabaseSqlValidationTest.java | 39 + ...seAuthenticaticationSqlValidationTest.java | 38 + .../JPAJamesServerWithSqlValidationTest.java | 30 + .../james/JPAWithLDAPJamesServerTest.java | 57 + .../james/JamesCapabilitiesServerTest.java | 59 + .../james/JamesServerConcreteContract.java | 52 + .../src/test/resources/dnsservice.xml | 25 + .../src/test/resources/domainlist.xml | 24 + .../resources/fakemailrepositorystore.xml | 31 + .../src/test/resources/imapserver.xml | 57 + .../postgres-app/src/test/resources/keystore | Bin 0 -> 2245 bytes .../src/test/resources/lmtpserver.xml | 42 + .../src/test/resources/mailetcontainer.xml | 123 ++ .../test/resources/mailrepositorystore.xml | 31 + .../src/test/resources/managesieveserver.xml | 66 + .../src/test/resources/pop3server.xml | 43 + .../src/test/resources/smtpserver.xml | 111 ++ server/pom.xml | 1 + 63 files changed, 4448 insertions(+) create mode 100644 server/apps/postgres-app/README.adoc create mode 100644 server/apps/postgres-app/docker-compose.yml create mode 100644 server/apps/postgres-app/docker-configuration/webadmin.properties create mode 100644 server/apps/postgres-app/pom.xml create mode 100644 server/apps/postgres-app/sample-configuration/dnsservice.xml create mode 100644 server/apps/postgres-app/sample-configuration/domainlist.xml create mode 100644 server/apps/postgres-app/sample-configuration/extensions.properties create mode 100644 server/apps/postgres-app/sample-configuration/healthcheck.properties create mode 100644 server/apps/postgres-app/sample-configuration/imapserver.xml create mode 100644 server/apps/postgres-app/sample-configuration/james-database-postgres.properties create mode 100644 server/apps/postgres-app/sample-configuration/james-database.properties create mode 100644 server/apps/postgres-app/sample-configuration/jmx.properties create mode 100644 server/apps/postgres-app/sample-configuration/jvm.properties create mode 100644 server/apps/postgres-app/sample-configuration/jwt_publickey create mode 100644 server/apps/postgres-app/sample-configuration/listeners.xml create mode 100644 server/apps/postgres-app/sample-configuration/lmtpserver.xml create mode 100644 server/apps/postgres-app/sample-configuration/logback.xml create mode 100644 server/apps/postgres-app/sample-configuration/mailetcontainer.xml create mode 100644 server/apps/postgres-app/sample-configuration/mailrepositorystore.xml create mode 100644 server/apps/postgres-app/sample-configuration/managesieveserver.xml create mode 100644 server/apps/postgres-app/sample-configuration/pop3server.xml create mode 100644 server/apps/postgres-app/sample-configuration/recipientrewritetable.xml create mode 100644 server/apps/postgres-app/sample-configuration/smtpserver.xml create mode 100644 server/apps/postgres-app/sample-configuration/usersrepository.xml create mode 100644 server/apps/postgres-app/sample-configuration/webadmin.properties create mode 100644 server/apps/postgres-app/src/assemble/app.xml create mode 100644 server/apps/postgres-app/src/assemble/extensions-jars.txt create mode 100644 server/apps/postgres-app/src/assemble/license-for-binary.txt create mode 100644 server/apps/postgres-app/src/main/extensions-jars/README.md create mode 100644 server/apps/postgres-app/src/main/glowroot/admin.json create mode 100644 server/apps/postgres-app/src/main/glowroot/plugins/imap.json create mode 100644 server/apps/postgres-app/src/main/glowroot/plugins/jmap.json create mode 100644 server/apps/postgres-app/src/main/glowroot/plugins/mailboxListener.json create mode 100644 server/apps/postgres-app/src/main/glowroot/plugins/pop3.json create mode 100644 server/apps/postgres-app/src/main/glowroot/plugins/smtp.json create mode 100644 server/apps/postgres-app/src/main/glowroot/plugins/spooler.json create mode 100644 server/apps/postgres-app/src/main/glowroot/plugins/task.json create mode 100644 server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java create mode 100644 server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java create mode 100644 server/apps/postgres-app/src/main/resources/META-INF/persistence.xml create mode 100644 server/apps/postgres-app/src/main/resources/defaultMailetContainer.xml create mode 100755 server/apps/postgres-app/src/main/scripts/james-cli create mode 100644 server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerTest.java create mode 100644 server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerWithAuthenticatedDatabaseSqlValidationTest.java create mode 100644 server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java create mode 100644 server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerWithSqlValidationTest.java create mode 100644 server/apps/postgres-app/src/test/java/org/apache/james/JPAWithLDAPJamesServerTest.java create mode 100644 server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java create mode 100644 server/apps/postgres-app/src/test/java/org/apache/james/JamesServerConcreteContract.java create mode 100644 server/apps/postgres-app/src/test/resources/dnsservice.xml create mode 100644 server/apps/postgres-app/src/test/resources/domainlist.xml create mode 100644 server/apps/postgres-app/src/test/resources/fakemailrepositorystore.xml create mode 100644 server/apps/postgres-app/src/test/resources/imapserver.xml create mode 100644 server/apps/postgres-app/src/test/resources/keystore create mode 100644 server/apps/postgres-app/src/test/resources/lmtpserver.xml create mode 100644 server/apps/postgres-app/src/test/resources/mailetcontainer.xml create mode 100644 server/apps/postgres-app/src/test/resources/mailrepositorystore.xml create mode 100644 server/apps/postgres-app/src/test/resources/managesieveserver.xml create mode 100644 server/apps/postgres-app/src/test/resources/pop3server.xml create mode 100644 server/apps/postgres-app/src/test/resources/smtpserver.xml diff --git a/backends-common/postgres/pom.xml b/backends-common/postgres/pom.xml index 3ac583cc14a..90574336a71 100644 --- a/backends-common/postgres/pom.xml +++ b/backends-common/postgres/pom.xml @@ -61,6 +61,7 @@ org.postgresql postgresql ${postgresql.driver.version} + test org.postgresql diff --git a/pom.xml b/pom.xml index b777b4293fa..86cac7b04bd 100644 --- a/pom.xml +++ b/pom.xml @@ -842,6 +842,17 @@ ${project.version} test-jar + + ${james.groupId} + apache-james-mailbox-postgres + ${project.version} + + + ${james.groupId} + apache-james-mailbox-postgres + ${project.version} + test-jar + ${james.groupId} apache-james-mailbox-quota-mailing diff --git a/server/apps/postgres-app/README.adoc b/server/apps/postgres-app/README.adoc new file mode 100644 index 00000000000..f37fda4f837 --- /dev/null +++ b/server/apps/postgres-app/README.adoc @@ -0,0 +1,145 @@ += Guice-Postgres Server How-to + +// TODO: rewrite this doc by using Postgres instead of JPA +This server target single node James deployments. By default, the derby database is used. + +== Requirements + + * Java 11 SDK + +== Running + +To run james, you have to create a directory containing required configuration files. + +James requires the configuration to be in a subfolder of working directory that is called +**conf**. A [sample directory](https://github.com/apache/james-project/tree/master/server/container/guice/jpa-guice/sample-configuration) +is provided with some default values you may need to replace. You will need to update its content to match your needs. + +You also need to generate a keystore with the following command: + +[source] +---- +$ keytool -genkey -alias james -keyalg RSA -keystore conf/keystore +---- + +Once everything is set up, you just have to run the jar with: + +[source] +---- +$ java -javaagent:james-server-postgres-app.lib/openjpa-3.1.2.jar \ + -Dworking.directory=. \ + -Djdk.tls.ephemeralDHKeySize=2048 \ + -Dlogback.configurationFile=conf/logback.xml \ + -jar james-server-postgres-app.jar +---- + +Note that binding ports below 1024 requires administrative rights. + +== Docker distribution + +To import the image locally: + +[source] +---- +docker image load -i target/jib-image.tar +---- + +Then run it: + +[source] +---- +docker run apache/james:jpa-latest +---- + +Use the [JAVA_TOOL_OPTIONS environment option](https://github.com/GoogleContainerTools/jib/blob/master/docs/faq.md#jvm-flags) +to pass extra JVM flags. For instance: + +[source] +---- +docker run -e "JAVA_TOOL_OPTIONS=-Xmx500m -Xms500m" apache/james:jpa-latest +---- + +For security reasons you are required to generate your own keystore, that you can mount into the container via a volume: + +[source] +---- +keytool -genkey -alias james -keyalg RSA -keystore keystore +docker run -v $PWD/keystore:/root/conf/keystore apache/james:jpa-latest +---- + +In the case of quick start James without manually creating a keystore (e.g. for development), just input the command argument `--generate-keystore` when running, +James will auto-generate keystore file with the default setting that is declared in `jmap.properties` (tls.keystoreURL, tls.secret) + +[source] +---- +docker run --network james apache/james:jpa-latest --generate-keystore +---- + +[Glowroot APM](https://glowroot.org/) is packaged as part of the docker distribution to easily enable valuable performances insights. +Disabled by default, its java agent can easily be enabled: + + +[source] +---- +docker run -e "JAVA_TOOL_OPTIONS=-javaagent:/root/glowroot.jar" apache/james:jpa-latest +---- + +The [CLI](https://james.apache.org/server/manage-cli.html) can easily be used: + + +[source] +---- +docker exec CONTAINER-ID james-cli ListDomains +---- + +Note that you can create a domain via an environment variable. This domain will be created upon James start: + +[source] +---- +--environment DOMAIN=domain.tld +---- + + +=== Using alternative JDBC drivers + +==== Using alternative JDBC drivers with the ZIP package + +We will need to add the driver JAR on the classpath. + +This can be done with the following command: + +.... +java \ + -javaagent:james-server-postgres-app.lib/openjpa-3.2.0.jar \ + -Dworking.directory=. \ + -Djdk.tls.ephemeralDHKeySize=2048 \ + -Dlogback.configurationFile=conf/logback.xml \ + -cp "james-server-postgres-app.jar:james-server-postgres-app.lib/*:jdbc-driver.jar" \ + org.apache.james.JPAJamesServerMain +.... + +With `jdbc-driver.jar` being the JAR file of your driver, placed in the current directory. + +==== Using alternative JDBC drivers with docker + +In `james-database.properties`, one can specify any JDBC driver on the class path. + +With docker, such drivers can be added to the classpath by placing the driver JAR in a volume +and mounting it within `/root/libs` directory. + +We do ship a [docker-compose](https://github.com/apache/james-project/blob/master/server/apps/jpa-smtp-app/docker-compose.yml) +file demonstrating James JPA app usage with MariaDB. In order to run it: + +.... +# 1. Download the driver: +wget https://repo1.maven.org/maven2/org/mariadb/jdbc/mariadb-java-client/2.7.2/mariadb-java-client-2.7.2.jar + +# 2. Generate the keystore with the default password `james72laBalle`: +keytool -genkey -alias james -keyalg RSA -keystore keystore + +# 3. Start MariaDB +docker-compose up -d mariadb + +# 4. Start James +docker-compose up james +.... \ No newline at end of file diff --git a/server/apps/postgres-app/docker-compose.yml b/server/apps/postgres-app/docker-compose.yml new file mode 100644 index 00000000000..c1c3124dc2a --- /dev/null +++ b/server/apps/postgres-app/docker-compose.yml @@ -0,0 +1,29 @@ +version: '3' + +# In order to start James Postgres app on top of mariaDB: +# 1. Download the driver: `wget https://jdbc.postgresql.org/download/postgresql-42.5.4.jar` +# 2. Generate the keystore with the default password `james72laBalle`: `keytool -genkey -alias james -keyalg RSA -keystore keystore` +# 3. Start Postgres: `docker-compose up -d postgres` +# 4. Start James: `docker-compose up james` + +services: + + james: + depends_on: + - postgres + image: apache/james:postgres-latest + container_name: james + hostname: james.local + volumes: + - $PWD/postgresql-42.5.4.jar:/root/libs/postgresql-42.5.4.jar + - $PWD/sample-configuration/james-database-postgres.properties:/root/conf/james-database.properties + - $PWD/src/test/resources/keystore:/root/conf/keystore + + postgres: + image: postgres:16.0 + ports: + - 5432:5432 + environment: + - POSTGRES_DB=james + - POSTGRES_USER=james + - POSTGRES_PASSWORD=secret1 \ No newline at end of file diff --git a/server/apps/postgres-app/docker-configuration/webadmin.properties b/server/apps/postgres-app/docker-configuration/webadmin.properties new file mode 100644 index 00000000000..5d72d99b744 --- /dev/null +++ b/server/apps/postgres-app/docker-configuration/webadmin.properties @@ -0,0 +1,54 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# This template file can be used as example for James Server configuration +# DO NOT USE IT AS SUCH AND ADAPT IT TO YOUR NEEDS + +# Read https://james.apache.org/server/config-webadmin.html for further details + +enabled=true +port=8000 +host=0.0.0.0 + +# Defaults to false +https.enabled=false + +# Compulsory when enabling HTTPS +#https.keystore=/path/to/keystore +#https.password=password + +# Optional when enabling HTTPS (self signed) +#https.trust.keystore +#https.trust.password + +# Defaults to false +#jwt.enabled=true +# +## If you wish to use OAuth authentication, you should provide a valid JWT public key. +## The following entry specify the link to the URL of the public key file, +## which should be a PEM format file. +## +#jwt.publickeypem.url=file://conf/jwt_publickey + +# Defaults to false +#cors.enable=true +#cors.origin + +# List of fully qualified class names that should be exposed over webadmin +# in addition to your product default routes. Routes needs to be located +# within the classpath or in the ./extensions-jars folder. +#extensions.routes= \ No newline at end of file diff --git a/server/apps/postgres-app/pom.xml b/server/apps/postgres-app/pom.xml new file mode 100644 index 00000000000..7cc20dd92e8 --- /dev/null +++ b/server/apps/postgres-app/pom.xml @@ -0,0 +1,445 @@ + + + + 4.0.0 + + org.apache.james + james-server + 3.9.0-SNAPSHOT + ../../pom.xml + + + james-server-postgres-app + jar + Apache James :: Server :: Postgres - Application + + + + + + ${james.groupId} + james-server-guice + ${project.version} + pom + import + + + + + + + ${james.groupId} + apache-james-mailbox-postgres + test-jar + test + + + ${james.groupId} + apache-james-mailbox-quota-search-scanning + + + ${james.groupId} + james-server-cli + runtime + + + ${james.groupId} + james-server-data-ldap + test-jar + test + + + ${james.groupId} + james-server-data-postgres + + + ${james.groupId} + james-server-guice-common + + + ${james.groupId} + james-server-guice-common + test-jar + test + + + ${james.groupId} + james-server-guice-data-ldap + + + ${james.groupId} + james-server-guice-data-ldap + test-jar + test + + + ${james.groupId} + james-server-guice-imap + + + ${james.groupId} + james-server-guice-jmx + + + ${james.groupId} + james-server-guice-lmtp + + + ${james.groupId} + james-server-guice-mailbox + + + ${james.groupId} + james-server-guice-mailbox-postgres + + + ${james.groupId} + james-server-guice-managedsieve + + + ${james.groupId} + james-server-guice-pop + + + ${james.groupId} + james-server-guice-sieve-jpa + + + ${james.groupId} + james-server-guice-smtp + + + ${james.groupId} + james-server-guice-webadmin + + + ${james.groupId} + james-server-guice-webadmin-data + + + ${james.groupId} + james-server-guice-webadmin-mailbox + + + ${james.groupId} + james-server-guice-webadmin-mailqueue + + + ${james.groupId} + james-server-guice-webadmin-mailrepository + + + ${james.groupId} + james-server-mailbox-adapter + + + ${james.groupId} + james-server-mailets + + + ${james.groupId} + james-server-postgres-common-guice + + + ${james.groupId} + james-server-postgres-common-guice + test-jar + test + + + ${james.groupId} + james-server-testing + test + + + ${james.groupId} + queue-activemq-guice + + + ${james.groupId} + testing-base + test + + + ch.qos.logback + logback-classic + + + ch.qos.logback.contrib + logback-jackson + + + ch.qos.logback.contrib + logback-json-classic + + + com.linagora + logback-elasticsearch-appender + + + io.rest-assured + rest-assured + test + + + org.apache.derby + derby + + + org.awaitility + awaitility + + + org.mockito + mockito-core + test + + + + + + + com.googlecode.maven-download-plugin + download-maven-plugin + + + install-glowroot + + wget + + package + + https://github.com/glowroot/glowroot/releases/download/v0.14.0/glowroot-0.14.0-dist.zip + true + ${project.build.directory} + 16073f10204751cd71d3b4ea93be2649 + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-glowroot-resources + + copy-resources + + package + + ${basedir}/target/glowroot + + + src/main/glowroot + true + + + + + + + + com.google.cloud.tools + jib-maven-plugin + + + eclipse-temurin:11-jre-jammy + + + apache/james + + postgres-latest + + + + org.apache.james.PostgresJamesServerMain + + 80 + + 143 + + 993 + + 25 + + 465 + + 587 + + 4000 + + 8000 + + + /root + + -Dlogback.configurationFile=/root/conf/logback.xml + -Dworking.directory=/root/ + + -Djdk.tls.ephemeralDHKeySize=2048 + -Dextra.props=/root/conf/jvm.properties + + USE_CURRENT_TIMESTAMP + + /logs + /root/conf + /root/extensions-jars + /root/glowroot/plugins + /root/glowroot/data + + /root/var + + /var/store + + + + + + sample-configuration + /root/conf + + + docker-configuration + /root/conf + + + src/main/scripts + /usr/bin + + + target/glowroot + /root + + + src/main/extensions-jars + /root/extensions-jars + + + + + /usr/bin/james-cli + 755 + + + + + + + + + buildTar + + package + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + false + + 1C + -Djava.library.path= + -javaagent:"${settings.localRepository}"/org/jacoco/org.jacoco.agent/${jacoco-maven-plugin.version}/org.jacoco.agent-${jacoco-maven-plugin.version}-runtime.jar=destfile=${basedir}/target/jacoco.exec + -Xms512m -Xmx1024m -Dopenjpa.Multithreaded=true + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-dependencies + + copy-dependencies + + package + + compile + runtime + ${project.build.directory}/${project.artifactId}.lib + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + default-jar + + jar + + + ${project.artifactId} + + + true + ${project.artifactId}.lib/ + org.apache.james.PostgresJamesServerMain + false + + + Apache James Postgres server Application + ${project.version} + The Apache Software Foundation + Apache James Postgres server Application + ${project.version} + The Apache Software Foundation + org.apache + https://james.apache.org/server + + + + + + test-jar + + test-jar + + + + + + maven-assembly-plugin + + src/assemble/ + gnu + false + james-server-postgres-app + + + + make-assembly + + single + + package + + + + + + + diff --git a/server/apps/postgres-app/sample-configuration/dnsservice.xml b/server/apps/postgres-app/sample-configuration/dnsservice.xml new file mode 100644 index 00000000000..863de0e2afc --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/dnsservice.xml @@ -0,0 +1,27 @@ + + + + + + + true + false + 50000 + diff --git a/server/apps/postgres-app/sample-configuration/domainlist.xml b/server/apps/postgres-app/sample-configuration/domainlist.xml new file mode 100644 index 00000000000..605439fbd0e --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/domainlist.xml @@ -0,0 +1,27 @@ + + + + + + + false + false + localhost + diff --git a/server/apps/postgres-app/sample-configuration/extensions.properties b/server/apps/postgres-app/sample-configuration/extensions.properties new file mode 100644 index 00000000000..2a2c23e7cb0 --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/extensions.properties @@ -0,0 +1,10 @@ +# This files enables customization of users extensions injections with guice. +# A user can drop some jar-with-dependencies within the ./extensions-jars folder and +# reference classes of these jars in some of James extension mechanisms. + +# This includes mailets, matchers, mailboxListeners, preDeletionHooks, protocolHandlers, webAdmin routes + +# Upon injections, the user can reference additional guice modules, that are going to be used only upon extensions instantiation. + +#List of coma separated (',') fully qualified class names of additional guice modules to be used to instantiate extensions +#guice.extension.module= \ No newline at end of file diff --git a/server/apps/postgres-app/sample-configuration/healthcheck.properties b/server/apps/postgres-app/sample-configuration/healthcheck.properties new file mode 100644 index 00000000000..c796fee60b7 --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/healthcheck.properties @@ -0,0 +1,33 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# This template file can be used as example for James Server configuration +# DO NOT USE IT AS SUCH AND ADAPT IT TO YOUR NEEDS + +# Configuration file for Periodical Health Checks + +# Read https://james.apache.org/server/config-healthcheck.html for further details + +# Optional. Period between two PeriodicalHealthChecks. +# Units supported are (ms - millisecond, s - second, m - minute, h - hour, d - day). Default unit is millisecond. +# Default duration is 60 seconds. +# Duration must be greater or at least equals to 10 seconds. +# healthcheck.period=60s + +# List of fully qualified HealthCheck class names in addition to James' default healthchecks. +# Healthchecks need to be located within the classpath or in the ./extensions-jars folder. +# additional.healthchecks= diff --git a/server/apps/postgres-app/sample-configuration/imapserver.xml b/server/apps/postgres-app/sample-configuration/imapserver.xml new file mode 100644 index 00000000000..0d38de0d734 --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/imapserver.xml @@ -0,0 +1,83 @@ + + + + + + + + + + imapserver + 0.0.0.0:143 + 200 + + + file://conf/keystore + PKCS12 + james72laBalle + org.bouncycastle.jce.provider.BouncyCastleProvider + + + + + + + + 0 + 0 + 120 + SECONDS + true + true + + true + + + + imapserver-ssl + 0.0.0.0:993 + 200 + + + file://conf/keystore + PKCS12 + james72laBalle + org.bouncycastle.jce.provider.BouncyCastleProvider + + + + + + + + 0 + 0 + 120 + SECONDS + true + + true + + + diff --git a/server/apps/postgres-app/sample-configuration/james-database-postgres.properties b/server/apps/postgres-app/sample-configuration/james-database-postgres.properties new file mode 100644 index 00000000000..49d818a5cc2 --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/james-database-postgres.properties @@ -0,0 +1,49 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# This template file can be used as example for James Server configuration +# DO NOT USE IT AS SUCH AND ADAPT IT TO YOUR NEEDS + +# Read https://james.apache.org/server/config-system.html#james-database.properties for further details + +# Use derby as default +database.driverClassName=org.postgresql.Driver +database.url=jdbc:postgresql://postgres/james +database.username=james +database.password=secret1 + +# Use streaming for Blobs +# This is only supported on a limited set of databases atm. You should check if its supported by your DB before enable +# it. +# +# See: +# http://openjpa.apache.org/builds/latest/docs/manual/ref_guide_mapping_jpa.html #7.11. LOB Streaming +# +openjpa.streaming=false + +# Validate the data source before using it +# datasource.testOnBorrow=true +# datasource.validationQueryTimeoutSec=2 +# This is different per database. See https://stackoverflow.com/questions/10684244/dbcp-validationquery-for-different-databases#10684260 +# datasource.validationQuery=select 1 +# The maximum number of active connections that can be allocated from this pool at the same time, or negative for no limit. +# datasource.maxTotal=8 + +# Attachment storage +# *WARNING*: Is not made to store large binary content (no more than 1 GB of data) +# Optional, Allowed values are: true, false, defaults to false +# attachmentStorage.enabled=false diff --git a/server/apps/postgres-app/sample-configuration/james-database.properties b/server/apps/postgres-app/sample-configuration/james-database.properties new file mode 100644 index 00000000000..6aecddbbdd2 --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/james-database.properties @@ -0,0 +1,53 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# This template file can be used as example for James Server configuration +# DO NOT USE IT AS SUCH AND ADAPT IT TO YOUR NEEDS + +# Read https://james.apache.org/server/config-system.html#james-database.properties for further details + +# Use derby as default +database.driverClassName=org.apache.derby.jdbc.EmbeddedDriver +database.url=jdbc:derby:../var/store/derby;create=true +database.username=app +database.password=app + +# Supported adapters are: +# DB2, DERBY, H2, HSQL, INFORMIX, MYSQL, ORACLE, POSTGRESQL, SQL_SERVER, SYBASE +vendorAdapter.database=DERBY + +# Use streaming for Blobs +# This is only supported on a limited set of databases atm. You should check if its supported by your DB before enable +# it. +# +# See: +# http://openjpa.apache.org/builds/latest/docs/manual/ref_guide_mapping_jpa.html #7.11. LOB Streaming +# +openjpa.streaming=false + +# Validate the data source before using it +# datasource.testOnBorrow=true +# datasource.validationQueryTimeoutSec=2 +# This is different per database. See https://stackoverflow.com/questions/10684244/dbcp-validationquery-for-different-databases#10684260 +# datasource.validationQuery=select 1 +# The maximum number of active connections that can be allocated from this pool at the same time, or negative for no limit. +# datasource.maxTotal=8 + +# Attachment storage +# *WARNING*: Is not made to store large binary content (no more than 1 GB of data) +# Optional, Allowed values are: true, false, defaults to false +# attachmentStorage.enabled=false diff --git a/server/apps/postgres-app/sample-configuration/jmx.properties b/server/apps/postgres-app/sample-configuration/jmx.properties new file mode 100644 index 00000000000..e56235f9b4a --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/jmx.properties @@ -0,0 +1,26 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# + +# This template file can be used as example for James Server configuration +# DO NOT USE IT AS SUCH AND ADAPT IT TO YOUR NEEDS + +# Read https://james.apache.org/server/config-system.html#jmx.properties for further details + +jmx.enabled=true +jmx.address=127.0.0.1 +jmx.port=9999 diff --git a/server/apps/postgres-app/sample-configuration/jvm.properties b/server/apps/postgres-app/sample-configuration/jvm.properties new file mode 100644 index 00000000000..73b964c9b40 --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/jvm.properties @@ -0,0 +1,53 @@ +# ============================================= Extra JVM System Properties =========================================== +# To avoid clutter on the command line, any properties in this file will be added as system properties on server start. + +# Example: If you need an option -Dmy.property=whatever, you can instead add it here as +# my.property=whatever + +# (Optional). String (size, integer + size units, example: `12 KIB`, supported units are bytes KIB MIB GIB TIB). Defaults to 100KIB. +# This governs the threshold MimeMessageInputStreamSource relies on for storing MimeMessage content on disk. +# Below, data is stored in memory. Above data is stored on disk. +# Lower values will lead to longer processing time but will minimize heap memory usage. Modern SSD hardware +# should however support a high throughput. Higher values will lead to faster single mail processing at the cost +# of higher heap usage. +#james.message.memory.threshold=12K + +# Optional. Boolean. Defaults to false. Recommended value is false. +# Should MimeMessageWrapper use a copy of the message in memory? Or should bigger message exceeding james.message.memory.threshold +# be copied to temporary files? +#james.message.usememorycopy=false + +# Mode level of resource leak detection. It is used to detect a resource not be disposed of before it's garbage-collected. +# Example `MimeMessageInputStreamSource` +# Optional. Allowed values are: none, simple, advanced, testing +# - none: Disables resource leak detection. +# - simple: Enables output a simplistic error log if a leak is encountered and would free the resources (default). +# - advanced: Enables output an advanced error log implying the place of allocation of the underlying object and would free resources. +# - testing: Enables output an advanced error log implying the place of allocation of the underlying object and rethrow an error, that action is being taken by the development team. +#james.lifecycle.leak.detection.mode=simple + +# Should we add the host in the MDC logging context for incoming IMAP, SMTP, POP3? Doing so, a DNS resolution +# is attempted for each incoming connection, which can be costly. Remote IP is always added to the logging context. +# Optional. Boolean. Defaults to true. +#james.protocols.mdc.hostname=true + +# Manage netty leak detection level see https://netty.io/wiki/reference-counted-objects.html#leak-detection-levels +# io.netty.leakDetection.level=SIMPLE + +# Should James exit on Startup error? Boolean, defaults to true. This prevents partial startup. +# james.exit.on.startup.error=true + +# Fails explicitly on missing configuration file rather that taking implicit values. Defautls to false. +# james.fail.on.missing.configuration=true + +# JMX, when enable causes RMI to plan System.gc every hour. Set this instead to once every 1000h. +sun.rmi.dgc.server.gcInterval=3600000000 +sun.rmi.dgc.client.gcInterval=3600000000 + +# Automatically generate a JMX password upon start. CLI is able to retrieve this password. +james.jmx.credential.generation=true + +# Disable Remote Code Execution feature from JMX +# CF https://github.com/AdoptOpenJDK/openjdk-jdk11/blob/19fb8f93c59dfd791f62d41f332db9e306bc1422/src/java.management/share/classes/com/sun/jmx/remote/security/MBeanServerAccessController.java#L646 +jmx.remote.x.mlet.allow.getMBeansFromURL=false +openjpa.Multithreaded=true \ No newline at end of file diff --git a/server/apps/postgres-app/sample-configuration/jwt_publickey b/server/apps/postgres-app/sample-configuration/jwt_publickey new file mode 100644 index 00000000000..53914e0533a --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/jwt_publickey @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtlChO/nlVP27MpdkG0Bh +16XrMRf6M4NeyGa7j5+1UKm42IKUf3lM28oe82MqIIRyvskPc11NuzSor8HmvH8H +lhDs5DyJtx2qp35AT0zCqfwlaDnlDc/QDlZv1CoRZGpQk1Inyh6SbZwYpxxwh0fi ++d/4RpE3LBVo8wgOaXPylOlHxsDizfkL8QwXItyakBfMO6jWQRrj7/9WDhGf4Hi+ +GQur1tPGZDl9mvCoRHjFrD5M/yypIPlfMGWFVEvV5jClNMLAQ9bYFuOc7H1fEWw6 +U1LZUUbJW9/CH45YXz82CYqkrfbnQxqRb2iVbVjs/sHopHd1NTiCfUtwvcYJiBVj +kwIDAQAB +-----END PUBLIC KEY----- diff --git a/server/apps/postgres-app/sample-configuration/listeners.xml b/server/apps/postgres-app/sample-configuration/listeners.xml new file mode 100644 index 00000000000..ffe9605c6d8 --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/listeners.xml @@ -0,0 +1,24 @@ + + + + + + + \ No newline at end of file diff --git a/server/apps/postgres-app/sample-configuration/lmtpserver.xml b/server/apps/postgres-app/sample-configuration/lmtpserver.xml new file mode 100644 index 00000000000..723da3fb262 --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/lmtpserver.xml @@ -0,0 +1,43 @@ + + + + + + + + + lmtpserver + + 127.0.0.1:24 + 200 + 1200 + + 0 + + 0 + + + 0 + + + + + + diff --git a/server/apps/postgres-app/sample-configuration/logback.xml b/server/apps/postgres-app/sample-configuration/logback.xml new file mode 100644 index 00000000000..85c261041bb --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/logback.xml @@ -0,0 +1,39 @@ + + + + + true + + + + + %d{HH:mm:ss.SSS} %highlight([%-5level]) %logger{15} - %msg%n%rEx + false + + + + + /logs/james.log + + /logs/james.%i.log.tar.gz + 1 + 3 + + + + 100MB + + + + %d{HH:mm:ss.SSS} [%-5level] %logger{15} - %msg%n%rEx + false + + + + + + + + + + diff --git a/server/apps/postgres-app/sample-configuration/mailetcontainer.xml b/server/apps/postgres-app/sample-configuration/mailetcontainer.xml new file mode 100644 index 00000000000..acc048b8a98 --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/mailetcontainer.xml @@ -0,0 +1,145 @@ + + + + + + + + + + + postmaster + + + + 20 + file://var/mail/error/ + + + + + + + + transport + + + + + + mailetContainerErrors + + + ignore + + + file://var/mail/error/ + propagate + + + + + + + + + + + + bcc + ignore + + + rrt-error + + + + + + local-address-error + 550 - Requested action not taken: no such user here + + + + relay + + + + + + outgoing + 5000, 100000, 500000 + 3 + 0 + 10 + true + bounces + + + + + + mailetContainerLocalAddressError + + + none + + + file://var/mail/address-error/ + + + + + + mailetContainerRelayDenied + + + none + + + file://var/mail/relay-denied/ + Warning: You are sending an e-mail to a remote server. You must be authenticated to perform such an operation + + + + + + bounces + + + false + + + + + + file://var/mail/rrt-error/ + true + + + + + + + + + + diff --git a/server/apps/postgres-app/sample-configuration/mailrepositorystore.xml b/server/apps/postgres-app/sample-configuration/mailrepositorystore.xml new file mode 100644 index 00000000000..1e04a5f7ef2 --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/mailrepositorystore.xml @@ -0,0 +1,37 @@ + + + + + + + + file + + + + + + file + + + + + + diff --git a/server/apps/postgres-app/sample-configuration/managesieveserver.xml b/server/apps/postgres-app/sample-configuration/managesieveserver.xml new file mode 100644 index 00000000000..7b0b85a6eee --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/managesieveserver.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + managesieveserver + + 0.0.0.0:4190 + + 200 + + + + file://conf/keystore + james72laBalle + org.bouncycastle.jce.provider.BouncyCastleProvider + + SunX509 + + + + 360 + + + 0 + + + 0 + 0 + true + + + + + + diff --git a/server/apps/postgres-app/sample-configuration/pop3server.xml b/server/apps/postgres-app/sample-configuration/pop3server.xml new file mode 100644 index 00000000000..465efe9cbfc --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/pop3server.xml @@ -0,0 +1,50 @@ + + + + + + + + pop3server + 0.0.0.0:110 + 200 + + + file://conf/keystore + PKCS12 + james72laBalle + org.bouncycastle.jce.provider.BouncyCastleProvider + + + + + + + + 1200 + 0 + 0 + + + + + diff --git a/server/apps/postgres-app/sample-configuration/recipientrewritetable.xml b/server/apps/postgres-app/sample-configuration/recipientrewritetable.xml new file mode 100644 index 00000000000..1a512c60351 --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/recipientrewritetable.xml @@ -0,0 +1,28 @@ + + + + + + + + true + 10 + + diff --git a/server/apps/postgres-app/sample-configuration/smtpserver.xml b/server/apps/postgres-app/sample-configuration/smtpserver.xml new file mode 100644 index 00000000000..94ed2e5b6ac --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/smtpserver.xml @@ -0,0 +1,159 @@ + + + + + + + + + smtpserver-global + 0.0.0.0:25 + 200 + + + file://conf/keystore + PKCS12 + james72laBalle + org.bouncycastle.jce.provider.BouncyCastleProvider + SunX509 + + + + + + + + 360 + 0 + 0 + + never + false + true + + 127.0.0.0/8 + true + 0 + true + Apache JAMES awesome SMTP Server + + + + + + + smtpserver-TLS + 0.0.0.0:465 + 200 + + + file://conf/keystore + PKCS12 + james72laBalle + org.bouncycastle.jce.provider.BouncyCastleProvider + SunX509 + + + + + + + + 360 + 0 + 0 + + forUnauthorizedAddresses + true + true + + + + 127.0.0.0/8 + true + 0 + true + Apache JAMES awesome SMTP Server + + + + + + + smtpserver-authenticated + 0.0.0.0:587 + 200 + + + file://conf/keystore + PKCS12 + james72laBalle + org.bouncycastle.jce.provider.BouncyCastleProvider + SunX509 + + + + + + + + 360 + 0 + 0 + + forUnauthorizedAddresses + true + true + + + + 127.0.0.0/8 + true + 0 + true + Apache JAMES awesome SMTP Server + + + + + + + + diff --git a/server/apps/postgres-app/sample-configuration/usersrepository.xml b/server/apps/postgres-app/sample-configuration/usersrepository.xml new file mode 100644 index 00000000000..a5390d7140d --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/usersrepository.xml @@ -0,0 +1,28 @@ + + + + + + + PBKDF2-SHA512 + true + true + + diff --git a/server/apps/postgres-app/sample-configuration/webadmin.properties b/server/apps/postgres-app/sample-configuration/webadmin.properties new file mode 100644 index 00000000000..5dc74740c55 --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/webadmin.properties @@ -0,0 +1,49 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# This template file can be used as example for James Server configuration +# DO NOT USE IT AS SUCH AND ADAPT IT TO YOUR NEEDS + +# Read https://james.apache.org/server/config-webadmin.html for further details + +enabled=true +port=8000 +# Use host=0.0.0.0 to listen on all addresses +host=localhost + +# Defaults to false +https.enabled=false + +# Compulsory when enabling HTTPS +#https.keystore=/path/to/keystore +#https.password=password + +# Optional when enabling HTTPS (self signed) +#https.trust.keystore +#https.trust.password + +# Defaults to false +#jwt.enabled=true + +# Defaults to false +#cors.enable=true +#cors.origin + +# List of fully qualified class names that should be exposed over webadmin +# in addition to your product default routes. Routes needs to be located +# within the classpath or in the ./extensions-jars folder. +#extensions.routes= \ No newline at end of file diff --git a/server/apps/postgres-app/src/assemble/app.xml b/server/apps/postgres-app/src/assemble/app.xml new file mode 100644 index 00000000000..79ecba5d298 --- /dev/null +++ b/server/apps/postgres-app/src/assemble/app.xml @@ -0,0 +1,86 @@ + + + + app + + + zip + + + + + + . + 0755 + / + + README* + + + + + sample-configuration + 0755 + conf + + 0600 + + + + target/james-server-jpa-app.lib + /james-server-jpa-app.lib + 0755 + 0600 + + *.jar + + + + + + src/assemble/license-for-binary.txt + / + 0644 + LICENSE + crlf + + + README.adoc + / + 0644 + crlf + + + src/assemble/extensions-jars.txt + /extensions-jars + 0644 + crlf + README.md + + + target/james-server-postgres-app.jar + / + 0755 + james-server-postgres-app.jar + + + diff --git a/server/apps/postgres-app/src/assemble/extensions-jars.txt b/server/apps/postgres-app/src/assemble/extensions-jars.txt new file mode 100644 index 00000000000..2cea7599812 --- /dev/null +++ b/server/apps/postgres-app/src/assemble/extensions-jars.txt @@ -0,0 +1,5 @@ +# Adding Jars to JAMES + +The jar in this folder will be added to JAMES classpath when mounted under /root/extensions-jars inside the running container. + +You can use it to add you customs Mailets/Matchers. diff --git a/server/apps/postgres-app/src/assemble/license-for-binary.txt b/server/apps/postgres-app/src/assemble/license-for-binary.txt new file mode 100644 index 00000000000..682a01fab77 --- /dev/null +++ b/server/apps/postgres-app/src/assemble/license-for-binary.txt @@ -0,0 +1,1139 @@ + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + +This distribution contains third party resources. +Within the bin directory + licensed under the Tanuki Software License (as follows) + + + Copyright (c) 1999, 2006 Tanuki Software, Inc. + + Permission is hereby granted, free of charge, to any person + obtaining a copy of the Java Service Wrapper and associated + documentation files (the "Software"), to deal in the Software + without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sub-license, + and/or sell copies of the Software, and to permit persons to + whom the Software is furnished to do so, subject to the + following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE. + + + Portions of the Software have been derived from source code + developed by Silver Egg Technology under the following license: + + Copyright (c) 2001 Silver Egg Technology + + Permission is hereby granted, free of charge, to any person + obtaining a copy of this software and associated documentation + files (the "Software"), to deal in the Software without + restriction, including without limitation the rights to use, + copy, modify, merge, publish, distribute, sub-license, and/or + sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE. + + from Tanuki Software http://www.tanukisoftware.com/ + james + james.bat + wrapper + wrapper-linux-ppc-64 + wrapper-linux-x86-32 + wrapper-linux-x86-64 + wrapper-macosx-ppc-32 + wrapper-macosx-universal-32 + wrapper-solaris-sparc-32 + wrapper-solaris-sparc-64 + wrapper-solaris-x86-32 + wrapper-windows-x86-32.exe + +Within the conf directory + licensed under the Tanuki Software License (as follows) + + + Copyright (c) 1999, 2006 Tanuki Software, Inc. + + Permission is hereby granted, free of charge, to any person + obtaining a copy of the Java Service Wrapper and associated + documentation files (the "Software"), to deal in the Software + without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sub-license, + and/or sell copies of the Software, and to permit persons to + whom the Software is furnished to do so, subject to the + following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE. + + + Portions of the Software have been derived from source code + developed by Silver Egg Technology under the following license: + + Copyright (c) 2001 Silver Egg Technology + + Permission is hereby granted, free of charge, to any person + obtaining a copy of this software and associated documentation + files (the "Software"), to deal in the Software without + restriction, including without limitation the rights to use, + copy, modify, merge, publish, distribute, sub-license, and/or + sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE. + + from Tanuki Software http://www.tanukisoftware.com/ + wrapper.conf + +Within the lib directory + placed in the public domain + by Doug Lea + concurrent-1.3.4.jar + by Drew Noakes + metadata-extractor-2.4.0-beta-1.jar + by The AOP Alliance http://aopalliance.sourceforge.net/ + aopalliance-1.0.jar + + licensed under the Apache License, Version 2 http://www.apache.org/licenses/LICENSE-2.0.txt (as above) + from Boilerpipe http://code.google.com/p/boilerpipe/ + boilerpipe-1.1.0.jar + from FuseSource http://www.fusesource.org + commons-management-1.0.jar + from JBoss, a division of Red Hat, Inc. http://www.jboss.org + netty-3.2.4.Final.jar + from John Cowan http://home.ccil.org/~cowan/XML/tagsoup/ + tagsoup-1.2.jar + from Oracle http://www.oracle.com + rome-0.9.jar + from The JASYPT team http://www.jasypt.org + jasypt-1.6.jar + from The Spring Framework Project http://www.springframework.org + spring-aop-3.1.RELEASE.jar + spring-asm-3.1.RELEASE.jar + spring-beans-3.1.RELEASE.jar + spring-context-3.1.RELEASE.jar + spring-core-3.1.RELEASE.jar + spring-expression-3.1.RELEASE.jar + spring-jdbc-3.1.RELEASE.jar + spring-jms-3.1.RELEASE.jar + spring-orm-3.1.RELEASE.jar + spring-tx-3.1.RELEASE.jar + spring-web-3.1.RELEASE.jar + + licensed under the BSD (3-clause) http://www.opensource.org/licenses/BSD-3-Clause (as follows) + + ASM: a very small and fast Java bytecode manipulation framework + Copyright (c) 2000-2007 INRIA, France Telecom + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + 3. Neither the name of the copyright holders nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF + THE POSSIBILITY OF SUCH DAMAGE. + + from OW2 http://www.ow2.org/ + asm-3.1.jar + + licensed under the BSD (2-clause) http://www.opensource.org/licenses/BSD-2-Clause (as follows) + + dnsjava is placed under the BSD license. Several files are also under + additional licenses; see the individual files for details. + + Copyright (c) 1998-2011, Brian Wellington. + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + + from Brian Wellington + dnsjava-2.1.8.jar + + licensed under the BSD (3-clause) http://www.opensource.org/licenses/BSD-3-Clause (as follows) + + Copyright (c) 2002-2007, A. Abram White + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the name of 'serp' nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + from The Serp Project http://serp.sourceforge.net/ + serp-1.13.1.jar + + licensed under the Bouncy Castle Licence http://www.bouncycastle.org/licence.html (as follows) + + Copyright (c) 2000 - 2011 The Legion Of The Bouncy Castle (http://www.bouncycastle.org) + + Permission is hereby granted, free of charge, to any person obtaining a copy of this software + and associated documentation files (the "Software"), to deal in the Software without restriction, + including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + and/or sell copies of the Software, and to permit persons to whom the Software is furnished to + do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all copies or substantial + portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS + OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + from The Legion of the Bouncy Castle http://www.bouncycastle.org/ + bcmail-jdk15-1.45.jar + bcprov-jdk15-1.45.jar + + licensed under the COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.0 http://www.opensource.org/licenses/CDDL-1.0 (as follows) + + + COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.0 + + 1. Definitions. + + 1.1. "Contributor" means each individual or entity that + creates or contributes to the creation of Modifications. + + 1.2. "Contributor Version" means the combination of the + Original Software, prior Modifications used by a + Contributor (if any), and the Modifications made by that + particular Contributor. + + 1.3. "Covered Software" means (a) the Original Software, or + (b) Modifications, or (c) the combination of files + containing Original Software with files containing + Modifications, in each case including portions thereof. + + 1.4. "Executable" means the Covered Software in any form + other than Source Code. + + 1.5. "Initial Developer" means the individual or entity + that first makes Original Software available under this + License. + + 1.6. "Larger Work" means a work which combines Covered + Software or portions thereof with code not governed by the + terms of this License. + + 1.7. "License" means this document. + + 1.8. "Licensable" means having the right to grant, to the + maximum extent possible, whether at the time of the initial + grant or subsequently acquired, any and all of the rights + conveyed herein. + + 1.9. "Modifications" means the Source Code and Executable + form of any of the following: + + A. Any file that results from an addition to, + deletion from or modification of the contents of a + file containing Original Software or previous + Modifications; + + B. Any new file that contains any part of the + Original Software or previous Modification; or + + C. Any new file that is contributed or otherwise made + available under the terms of this License. + + 1.10. "Original Software" means the Source Code and + Executable form of computer software code that is + originally released under this License. + + 1.11. "Patent Claims" means any patent claim(s), now owned + or hereafter acquired, including without limitation, + method, process, and apparatus claims, in any patent + Licensable by grantor. + + 1.12. "Source Code" means (a) the common form of computer + software code in which modifications are made and (b) + associated documentation included in or with such code. + + 1.13. "You" (or "Your") means an individual or a legal + entity exercising rights under, and complying with all of + the terms of, this License. For legal entities, "You" + includes any entity which controls, is controlled by, or is + under common control with You. For purposes of this + definition, "control" means (a) the power, direct or + indirect, to cause the direction or management of such + entity, whether by contract or otherwise, or (b) ownership + of more than fifty percent (50%) of the outstanding shares + or beneficial ownership of such entity. + + 2. License Grants. + + 2.1. The Initial Developer Grant. + + Conditioned upon Your compliance with Section 3.1 below and + subject to third party intellectual property claims, the + Initial Developer hereby grants You a world-wide, + royalty-free, non-exclusive license: + + (a) under intellectual property rights (other than + patent or trademark) Licensable by Initial Developer, + to use, reproduce, modify, display, perform, + sublicense and distribute the Original Software (or + portions thereof), with or without Modifications, + and/or as part of a Larger Work; and + + (b) under Patent Claims infringed by the making, + using or selling of Original Software, to make, have + made, use, practice, sell, and offer for sale, and/or + otherwise dispose of the Original Software (or + portions thereof). + + (c) The licenses granted in Sections 2.1(a) and (b) + are effective on the date Initial Developer first + distributes or otherwise makes the Original Software + available to a third party under the terms of this + License. + + (d) Notwithstanding Section 2.1(b) above, no patent + license is granted: (1) for code that You delete from + the Original Software, or (2) for infringements + caused by: (i) the modification of the Original + Software, or (ii) the combination of the Original + Software with other software or devices. + + 2.2. Contributor Grant. + + Conditioned upon Your compliance with Section 3.1 below and + subject to third party intellectual property claims, each + Contributor hereby grants You a world-wide, royalty-free, + non-exclusive license: + + (a) under intellectual property rights (other than + patent or trademark) Licensable by Contributor to + use, reproduce, modify, display, perform, sublicense + and distribute the Modifications created by such + Contributor (or portions thereof), either on an + unmodified basis, with other Modifications, as + Covered Software and/or as part of a Larger Work; and + + (b) under Patent Claims infringed by the making, + using, or selling of Modifications made by that + Contributor either alone and/or in combination with + its Contributor Version (or portions of such + combination), to make, use, sell, offer for sale, + have made, and/or otherwise dispose of: (1) + Modifications made by that Contributor (or portions + thereof); and (2) the combination of Modifications + made by that Contributor with its Contributor Version + (or portions of such combination). + + (c) The licenses granted in Sections 2.2(a) and + 2.2(b) are effective on the date Contributor first + distributes or otherwise makes the Modifications + available to a third party. + + (d) Notwithstanding Section 2.2(b) above, no patent + license is granted: (1) for any code that Contributor + has deleted from the Contributor Version; (2) for + infringements caused by: (i) third party + modifications of Contributor Version, or (ii) the + combination of Modifications made by that Contributor + with other software (except as part of the + Contributor Version) or other devices; or (3) under + Patent Claims infringed by Covered Software in the + absence of Modifications made by that Contributor. + + 3. Distribution Obligations. + + 3.1. Availability of Source Code. + + Any Covered Software that You distribute or otherwise make + available in Executable form must also be made available in + Source Code form and that Source Code form must be + distributed only under the terms of this License. You must + include a copy of this License with every copy of the + Source Code form of the Covered Software You distribute or + otherwise make available. You must inform recipients of any + such Covered Software in Executable form as to how they can + obtain such Covered Software in Source Code form in a + reasonable manner on or through a medium customarily used + for software exchange. + + 3.2. Modifications. + + The Modifications that You create or to which You + contribute are governed by the terms of this License. You + represent that You believe Your Modifications are Your + original creation(s) and/or You have sufficient rights to + grant the rights conveyed by this License. + + 3.3. Required Notices. + + You must include a notice in each of Your Modifications + that identifies You as the Contributor of the Modification. + You may not remove or alter any copyright, patent or + trademark notices contained within the Covered Software, or + any notices of licensing or any descriptive text giving + attribution to any Contributor or the Initial Developer. + + 3.4. Application of Additional Terms. + + You may not offer or impose any terms on any Covered + Software in Source Code form that alters or restricts the + applicable version of this License or the recipients' + rights hereunder. You may choose to offer, and to charge a + fee for, warranty, support, indemnity or liability + obligations to one or more recipients of Covered Software. + However, you may do so only on Your own behalf, and not on + behalf of the Initial Developer or any Contributor. You + must make it absolutely clear that any such warranty, + support, indemnity or liability obligation is offered by + You alone, and You hereby agree to indemnify the Initial + Developer and every Contributor for any liability incurred + by the Initial Developer or such Contributor as a result of + warranty, support, indemnity or liability terms You offer. + + 3.5. Distribution of Executable Versions. + + You may distribute the Executable form of the Covered + Software under the terms of this License or under the terms + of a license of Your choice, which may contain terms + different from this License, provided that You are in + compliance with the terms of this License and that the + license for the Executable form does not attempt to limit + or alter the recipient's rights in the Source Code form + from the rights set forth in this License. If You + distribute the Covered Software in Executable form under a + different license, You must make it absolutely clear that + any terms which differ from this License are offered by You + alone, not by the Initial Developer or Contributor. You + hereby agree to indemnify the Initial Developer and every + Contributor for any liability incurred by the Initial + Developer or such Contributor as a result of any such terms + You offer. + + 3.6. Larger Works. + + You may create a Larger Work by combining Covered Software + with other code not governed by the terms of this License + and distribute the Larger Work as a single product. In such + a case, You must make sure the requirements of this License + are fulfilled for the Covered Software. + + 4. Versions of the License. + + 4.1. New Versions. + + Sun Microsystems, Inc. is the initial license steward and + may publish revised and/or new versions of this License + from time to time. Each version will be given a + distinguishing version number. Except as provided in + Section 4.3, no one other than the license steward has the + right to modify this License. + + 4.2. Effect of New Versions. + + You may always continue to use, distribute or otherwise + make the Covered Software available under the terms of the + version of the License under which You originally received + the Covered Software. If the Initial Developer includes a + notice in the Original Software prohibiting it from being + distributed or otherwise made available under any + subsequent version of the License, You must distribute and + make the Covered Software available under the terms of the + version of the License under which You originally received + the Covered Software. Otherwise, You may also choose to + use, distribute or otherwise make the Covered Software + available under the terms of any subsequent version of the + License published by the license steward. + + 4.3. Modified Versions. + + When You are an Initial Developer and You want to create a + new license for Your Original Software, You may create and + use a modified version of this License if You: (a) rename + the license and remove any references to the name of the + license steward (except to note that the license differs + from this License); and (b) otherwise make it clear that + the license contains terms which differ from this License. + + 5. DISCLAIMER OF WARRANTY. + + COVERED SOFTWARE IS PROVIDED UNDER THIS LICENSE ON AN "AS IS" + BASIS, WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, + INCLUDING, WITHOUT LIMITATION, WARRANTIES THAT THE COVERED + SOFTWARE IS FREE OF DEFECTS, MERCHANTABLE, FIT FOR A PARTICULAR + PURPOSE OR NON-INFRINGING. THE ENTIRE RISK AS TO THE QUALITY AND + PERFORMANCE OF THE COVERED SOFTWARE IS WITH YOU. SHOULD ANY + COVERED SOFTWARE PROVE DEFECTIVE IN ANY RESPECT, YOU (NOT THE + INITIAL DEVELOPER OR ANY OTHER CONTRIBUTOR) ASSUME THE COST OF + ANY NECESSARY SERVICING, REPAIR OR CORRECTION. THIS DISCLAIMER OF + WARRANTY CONSTITUTES AN ESSENTIAL PART OF THIS LICENSE. NO USE OF + ANY COVERED SOFTWARE IS AUTHORIZED HEREUNDER EXCEPT UNDER THIS + DISCLAIMER. + + 6. TERMINATION. + + 6.1. This License and the rights granted hereunder will + terminate automatically if You fail to comply with terms + herein and fail to cure such breach within 30 days of + becoming aware of the breach. Provisions which, by their + nature, must remain in effect beyond the termination of + this License shall survive. + + 6.2. If You assert a patent infringement claim (excluding + declaratory judgment actions) against Initial Developer or + a Contributor (the Initial Developer or Contributor against + whom You assert such claim is referred to as "Participant") + alleging that the Participant Software (meaning the + Contributor Version where the Participant is a Contributor + or the Original Software where the Participant is the + Initial Developer) directly or indirectly infringes any + patent, then any and all rights granted directly or + indirectly to You by such Participant, the Initial + Developer (if the Initial Developer is not the Participant) + and all Contributors under Sections 2.1 and/or 2.2 of this + License shall, upon 60 days notice from Participant + terminate prospectively and automatically at the expiration + of such 60 day notice period, unless if within such 60 day + period You withdraw Your claim with respect to the + Participant Software against such Participant either + unilaterally or pursuant to a written agreement with + Participant. + + 6.3. In the event of termination under Sections 6.1 or 6.2 + above, all end user licenses that have been validly granted + by You or any distributor hereunder prior to termination + (excluding licenses granted to You by any distributor) + shall survive termination. + + 7. LIMITATION OF LIABILITY. + + UNDER NO CIRCUMSTANCES AND UNDER NO LEGAL THEORY, WHETHER TORT + (INCLUDING NEGLIGENCE), CONTRACT, OR OTHERWISE, SHALL YOU, THE + INITIAL DEVELOPER, ANY OTHER CONTRIBUTOR, OR ANY DISTRIBUTOR OF + COVERED SOFTWARE, OR ANY SUPPLIER OF ANY OF SUCH PARTIES, BE + LIABLE TO ANY PERSON FOR ANY INDIRECT, SPECIAL, INCIDENTAL, OR + CONSEQUENTIAL DAMAGES OF ANY CHARACTER INCLUDING, WITHOUT + LIMITATION, DAMAGES FOR LOST PROFITS, LOSS OF GOODWILL, WORK + STOPPAGE, COMPUTER FAILURE OR MALFUNCTION, OR ANY AND ALL OTHER + COMMERCIAL DAMAGES OR LOSSES, EVEN IF SUCH PARTY SHALL HAVE BEEN + INFORMED OF THE POSSIBILITY OF SUCH DAMAGES. THIS LIMITATION OF + LIABILITY SHALL NOT APPLY TO LIABILITY FOR DEATH OR PERSONAL + INJURY RESULTING FROM SUCH PARTY'S NEGLIGENCE TO THE EXTENT + APPLICABLE LAW PROHIBITS SUCH LIMITATION. SOME JURISDICTIONS DO + NOT ALLOW THE EXCLUSION OR LIMITATION OF INCIDENTAL OR + CONSEQUENTIAL DAMAGES, SO THIS EXCLUSION AND LIMITATION MAY NOT + APPLY TO YOU. + + 8. U.S. GOVERNMENT END USERS. + + The Covered Software is a "commercial item," as that term is + defined in 48 C.F.R. 2.101 (Oct. 1995), consisting of "commercial + computer software" (as that term is defined at 48 C.F.R. ? + 252.227-7014(a)(1)) and "commercial computer software + documentation" as such terms are used in 48 C.F.R. 12.212 (Sept. + 1995). Consistent with 48 C.F.R. 12.212 and 48 C.F.R. 227.7202-1 + through 227.7202-4 (June 1995), all U.S. Government End Users + acquire Covered Software with only those rights set forth herein. + This U.S. Government Rights clause is in lieu of, and supersedes, + any other FAR, DFAR, or other clause or provision that addresses + Government rights in computer software under this License. + + 9. MISCELLANEOUS. + + This License represents the complete agreement concerning subject + matter hereof. If any provision of this License is held to be + unenforceable, such provision shall be reformed only to the + extent necessary to make it enforceable. This License shall be + governed by the law of the jurisdiction specified in a notice + contained within the Original Software (except to the extent + applicable law, if any, provides otherwise), excluding such + jurisdiction's conflict-of-law provisions. Any litigation + relating to this License shall be subject to the jurisdiction of + the courts located in the jurisdiction and venue specified in a + notice contained within the Original Software, with the losing + party responsible for costs, including, without limitation, court + costs and reasonable attorneys' fees and expenses. The + application of the United Nations Convention on Contracts for the + International Sale of Goods is expressly excluded. Any law or + regulation which provides that the language of a contract shall + be construed against the drafter shall not apply to this License. + You agree that You alone are responsible for compliance with the + United States export administration regulations (and the export + control laws and regulation of any other countries) when You use, + distribute or otherwise make available any Covered Software. + + 10. RESPONSIBILITY FOR CLAIMS. + + As between Initial Developer and the Contributors, each party is + responsible for claims and damages arising, directly or + indirectly, out of its utilization of rights under this License + and You agree to work with Initial Developer and Contributors to + distribute such responsibility on an equitable basis. Nothing + herein is intended or shall be deemed to constitute any admission + of liability. + + from Oracle http://www.oracle.com + mail-1.4.4.jar + + licensed under the Day Specification License with Addendum http://www.day.com/content/dam/day/downloads/jsr283/LICENSE.txt (as follows) + + + Day Management AG ("Licensor") is willing to license this specification to you ONLY UPON + THE CONDITION THAT YOU ACCEPT ALL OF THE TERMS CONTAINED IN THIS LICENSE AGREEMENT + ("Agreement"). Please read the terms and conditions of this Agreement carefully. + + Content Repository for JavaTM Technology API Specification ("Specification") + Version: 2.0 + Status: FCS + Release: 10 August 2009 + + Copyright 2009 Day Management AG + Barf?sserplatz 6, 4001 Basel, Switzerland. + All rights reserved. + + NOTICE; LIMITED LICENSE GRANTS + + 1. License for Purposes of Evaluation and Developing Applications. Licensor hereby grants + you a fully-paid, non-exclusive, non-transferable, worldwide, limited license (without the + right to sublicense), under Licensor's applicable intellectual property rights to view, + download, use and reproduce the Specification only for the purpose of internal evaluation. + This includes developing applications intended to run on an implementation of the + Specification provided that such applications do not themselves implement any portion(s) + of the Specification. + + 2. License for the Distribution of Compliant Implementations. Licensor also grants you a + perpetual, non-exclusive, non-transferable, worldwide, fully paid-up, royalty free, limited + license (without the right to sublicense) under any applicable copyrights or, subject to + the provisions of subsection 4 below, patent rights it may have covering the Specification + to create and/or distribute an Independent Implementation of the Specification that: + + (a) fully implements the Specification including all its required interfaces and + functionality; + (b) does not modify, subset, superset or otherwise extend the Licensor Name Space, + or include any public or protected packages, classes, Java interfaces, fields + or methods within the Licensor Name Space other than those required/authorized + by the Specification or Specifications being implemented; and + (c) passes the Technology Compatibility Kit (including satisfying the requirements + of the applicable TCK Users Guide) for such Specification ("Compliant Implementation"). + In addition, the foregoing license is expressly conditioned on your not acting + outside its scope. No license is granted hereunder for any other purpose (including, + for example, modifying the Specification, other than to the extent of your fair use + rights, or distributing the Specification to third parties). + + 3. Pass-through Conditions. You need not include limitations (a)-(c) from the previous paragraph + or any other particular "pass through" requirements in any license You grant concerning the + use of your Independent Implementation or products derived from it. However, except with + respect to Independent Implementations (and products derived from them) that satisfy + limitations (a)-(c) from the previous paragraph, You may neither: + + (a) grant or otherwise pass through to your licensees any licenses under Licensor's + applicable intellectual property rights; nor + (b) authorize your licensees to make any claims concerning their implementation's + compliance with the Specification. + + 4. Reciprocity Concerning Patent Licenses. With respect to any patent claims covered by the + license granted under subparagraph 2 above that would be infringed by all technically + feasible implementations of the Specification, such license is conditioned upon your + offering on fair, reasonable and non-discriminatory terms, to any party seeking it from + You, a perpetual, non-exclusive, non-transferable, worldwide license under Your patent + rights that are or would be infringed by all technically feasible implementations of the + Specification to develop, distribute and use a Compliant Implementation. + + 5. Definitions. For the purposes of this Agreement: "Independent Implementation" shall mean an + implementation of the Specification that neither derives from any of Licensor's source code + or binary code materials nor, except with an appropriate and separate license from Licensor, + includes any of Licensor's source code or binary code materials; "Licensor Name Space" shall + mean the public class or interface declarations whose names begin with "java", "javax", + "javax.jcr" or their equivalents in any subsequent naming convention adopted by Licensor + through the Java Community Process, or any recognized successors or replacements thereof; + and "Technology Compatibility Kit" or "TCK" shall mean the test suite and accompanying TCK + User's Guide provided by Licensor which corresponds to the particular version of the + Specification being tested. + + 6. Termination. This Agreement will terminate immediately without notice from Licensor if + you fail to comply with any material provision of or act outside the scope of the licenses + granted above. + + 7. Trademarks. No right, title, or interest in or to any trademarks, service marks, or trade + names of Licensor is granted hereunder. Java is a registered trademark of Sun Microsystems, + Inc. in the United States and other countries. + + 8. Disclaimer of Warranties. The Specification is provided "AS IS". LICENSOR MAKES NO + REPRESENTATIONS OR WARRANTIES, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO, + WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT + (INCLUDING AS A CONSEQUENCE OF ANY PRACTICE OR IMPLEMENTATION OF THE SPECIFICATION), + OR THAT THE CONTENTS OF THE SPECIFICATION ARE SUITABLE FOR ANY PURPOSE. This document + does not represent any commitment to release or implement any portion of the Specification + in any product. + + The Specification could include technical inaccuracies or typographical errors. Changes are + periodically added to the information therein; these changes will be incorporated into new + versions of the Specification, if any. Licensor may make improvements and/or changes to the + product(s) and/or the program(s) described in the Specification at any time. Any use of such + changes in the Specification will be governed by the then-current license for the applicable + version of the Specification. + + 9. Limitation of Liability. TO THE EXTENT NOT PROHIBITED BY LAW, IN NO EVENT WILL LICENSOR + BE LIABLE FOR ANY DAMAGES, INCLUDING WITHOUT LIMITATION, LOST REVENUE, PROFITS OR DATA, OR + FOR SPECIAL, INDIRECT, CONSEQUENTIAL, INCIDENTAL OR PUNITIVE DAMAGES, HOWEVER CAUSED AND + REGARDLESS OF THE THEORY OF LIABILITY, ARISING OUT OF OR RELATED TO ANY FURNISHING, + PRACTICING, MODIFYING OR ANY USE OF THE SPECIFICATION, EVEN IF LICENSOR HAS BEEN ADVISED + OF THE POSSIBILITY OF SUCH DAMAGES. + + 10. Report. If you provide Licensor with any comments or suggestions in connection with your + use of the Specification ("Feedback"), you hereby: (i) agree that such Feedback is provided + on a non-proprietary and non-confidential basis, and (ii) grant Licensor a perpetual, + non-exclusive, worldwide, fully paid-up, irrevocable license, with the right to sublicense + through multiple levels of sublicensees, to incorporate, disclose, and use without + limitation the Feedback for any purpose related to the Specification and future versions, + implementations, and test suites thereof. + + Day Specification License Addendum + + In addition to the permissions granted under the Specification + License, Day Management AG hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + license to reproduce, publicly display, publicly perform, + sublicense, and distribute unmodified copies of the Content + Repository for Java Technology API (JCR 2.0) Java Archive (JAR) + file ("jcr-2.0.jar") and to make, have made, use, offer to sell, + sell, import, and otherwise transfer said file on its own or + as part of a larger work that makes use of the JCR API. + + With respect to any patent claims covered by this license + that would be infringed by all technically feasible implementations + of the Specification, such license is conditioned upon your + offering on fair, reasonable and non-discriminatory terms, + to any party seeking it from You, a perpetual, non-exclusive, + non-transferable, worldwide license under Your patent rights + that are or would be infringed by all technically feasible + implementations of the Specification to develop, distribute + and use a Compliant Implementation. + + + from Day Software http://www.day.com + jcr-2.0.jar + + licensed under the MIT License http://www.opensource.org/licenses/mit-license.php (as follows) + + Copyright (c) 2004-2008 QOS.ch + All rights reserved. + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal in the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + from QOS.ch http://www.qos.ch + jcl-over-slf4j-1.6.1.jar + slf4j-api-1.6.1.jar + slf4j-log4j12-1.6.1.jar + + licensed under the Tanuki Software License (as follows) + + + Copyright (c) 1999, 2006 Tanuki Software, Inc. + + Permission is hereby granted, free of charge, to any person + obtaining a copy of the Java Service Wrapper and associated + documentation files (the "Software"), to deal in the Software + without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sub-license, + and/or sell copies of the Software, and to permit persons to + whom the Software is furnished to do so, subject to the + following conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE. + + + Portions of the Software have been derived from source code + developed by Silver Egg Technology under the following license: + + Copyright (c) 2001 Silver Egg Technology + + Permission is hereby granted, free of charge, to any person + obtaining a copy of this software and associated documentation + files (the "Software"), to deal in the Software without + restriction, including without limitation the rights to use, + copy, modify, merge, publish, distribute, sub-license, and/or + sell copies of the Software, and to permit persons to whom the + Software is furnished to do so, subject to the following + conditions: + + The above copyright notice and this permission notice shall be + included in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NON-INFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE. + + from Tanuki Software http://www.tanukisoftware.com/ + libwrapper-linux-ppc-64.so + libwrapper-linux-x86-32.so + libwrapper-linux-x86-64.so + libwrapper-macosx-ppc-32.jnilib + libwrapper-macosx-universal-32.jnilib + libwrapper-solaris-sparc-32.so + libwrapper-solaris-sparc-64.so + libwrapper-solaris-x86-32.so + wrapper-windows-x86-32.dll + wrapper.jar + + + licensed under the Day Specification License http://www.day.com/content/dam/day/downloads/jsr283/LICENSE.txt (as follows) + + In addition to the permissions granted under the Specification + License, Day Management AG hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + license to reproduce, publicly display, publicly perform, + sublicense, and distribute unmodified copies of the Content + Repository for Java Technology API (JCR 2.0) Java Archive (JAR) + file ("jcr-2.0.jar") and to make, have made, use, offer to sell, + sell, import, and otherwise transfer said file on its own or + as part of a larger work that makes use of the JCR API. + + With respect to any patent claims covered by this license + that would be infringed by all technically feasible implementations + of the Specification, such license is conditioned upon your + offering on fair, reasonable and non-discriminatory terms, + to any party seeking it from You, a perpetual, non-exclusive, + non-transferable, worldwide license under Your patent rights + that are or would be infringed by all technically feasible + implementations of the Specification to develop, distribute + and use a Compliant Implementation. + + + licensed under the BSD (3-clause style) http://jetm.void.fm/license.html (as follows) + + Copyright (c) 2004, 2005, 2006, 2007 void.fm + All rights reserved. + + Redistribution and use in source and binary forms, with or without modification, + are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this list + of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright notice, this + list of conditions and the following disclaimer in the documentation and/or + other materials provided with the distribution. + + * Neither the name void.fm nor the names of its contributors may be + used to endorse or promote products derived from this software without specific + prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. + IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, + INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, + BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED + OF THE POSSIBILITY OF SUCH DAMAGE. + + from JETM http://jetm.void.fm + jetm-1.2.3.jar + jetm-optional-1.2.3.jar diff --git a/server/apps/postgres-app/src/main/extensions-jars/README.md b/server/apps/postgres-app/src/main/extensions-jars/README.md new file mode 100644 index 00000000000..dab5c40e60d --- /dev/null +++ b/server/apps/postgres-app/src/main/extensions-jars/README.md @@ -0,0 +1,5 @@ +# Adding Jars to JAMES + +The jar in this folder will be added to JAMES classpath when mounted under /root/extensions-jars inside the running container. + +You can use it to add your custom Mailets/Matchers. diff --git a/server/apps/postgres-app/src/main/glowroot/admin.json b/server/apps/postgres-app/src/main/glowroot/admin.json new file mode 100644 index 00000000000..c75c59d555a --- /dev/null +++ b/server/apps/postgres-app/src/main/glowroot/admin.json @@ -0,0 +1,5 @@ +{ + "web": { + "bindAddress": "0.0.0.0" + } +} \ No newline at end of file diff --git a/server/apps/postgres-app/src/main/glowroot/plugins/imap.json b/server/apps/postgres-app/src/main/glowroot/plugins/imap.json new file mode 100644 index 00000000000..d27904feb5e --- /dev/null +++ b/server/apps/postgres-app/src/main/glowroot/plugins/imap.json @@ -0,0 +1,19 @@ +{ + "name": "IMAP Plugin", + "id": "imap", + "instrumentation": [ + { + "className": "org.apache.james.imap.processor.base.AbstractChainedProcessor", + "methodName": "doProcess", + "methodParameterTypes": [ + ".." + ], + "captureKind": "transaction", + "transactionType": "IMAP", + "transactionNameTemplate": "IMAP processor : {{this.class.name}}", + "alreadyInTransactionBehavior": "capture-trace-entry", + "traceEntryMessageTemplate": "{{this.class.name}}.{{methodName}}", + "timerName": "imapProcessor" + } + ] +} \ No newline at end of file diff --git a/server/apps/postgres-app/src/main/glowroot/plugins/jmap.json b/server/apps/postgres-app/src/main/glowroot/plugins/jmap.json new file mode 100644 index 00000000000..9afce4bf94c --- /dev/null +++ b/server/apps/postgres-app/src/main/glowroot/plugins/jmap.json @@ -0,0 +1,19 @@ +{ + "name": "JMAP Plugin", + "id": "jmap", + "instrumentation": [ + { + "className": "org.apache.james.jmap.draft.methods.Method", + "methodName": "processToStream", + "methodParameterTypes": [ + ".." + ], + "captureKind": "transaction", + "transactionType": "JMAP", + "transactionNameTemplate": "JMAP method : {{this.class.name}}", + "alreadyInTransactionBehavior": "capture-trace-entry", + "traceEntryMessageTemplate": "{{this.class.name}}.{{methodName}}", + "timerName": "jmapMethod" + } + ] +} \ No newline at end of file diff --git a/server/apps/postgres-app/src/main/glowroot/plugins/mailboxListener.json b/server/apps/postgres-app/src/main/glowroot/plugins/mailboxListener.json new file mode 100644 index 00000000000..54a55ac1e4c --- /dev/null +++ b/server/apps/postgres-app/src/main/glowroot/plugins/mailboxListener.json @@ -0,0 +1,19 @@ +{ + "name": "MailboxListener Plugin", + "id": "mailboxListener", + "instrumentation": [ + { + "className": "org.apache.james.mailbox.events.MailboxListener", + "methodName": "event", + "methodParameterTypes": [ + ".." + ], + "captureKind": "transaction", + "transactionType": "MailboxListener", + "transactionNameTemplate": "MailboxListener : {{this.class.name}}", + "alreadyInTransactionBehavior": "capture-trace-entry", + "traceEntryMessageTemplate": "{{this.class.name}}.{{methodName}}", + "timerName": "mailboxListener" + } + ] +} \ No newline at end of file diff --git a/server/apps/postgres-app/src/main/glowroot/plugins/pop3.json b/server/apps/postgres-app/src/main/glowroot/plugins/pop3.json new file mode 100644 index 00000000000..a5bcdccce1f --- /dev/null +++ b/server/apps/postgres-app/src/main/glowroot/plugins/pop3.json @@ -0,0 +1,19 @@ +{ + "name": "POP3 Plugin", + "id": "pop3", + "instrumentation": [ + { + "className": "org.apache.james.protocols.pop3.core.AbstractPOP3CommandHandler", + "methodName": "onCommand", + "methodParameterTypes": [ + ".." + ], + "captureKind": "transaction", + "transactionType": "POP3", + "transactionNameTemplate": "POP3 Command: {{this.class.name}}", + "alreadyInTransactionBehavior": "capture-trace-entry", + "traceEntryMessageTemplate": "{{this.class.name}}.{{methodName}}", + "timerName": "pop3Timer" + } + ] +} diff --git a/server/apps/postgres-app/src/main/glowroot/plugins/smtp.json b/server/apps/postgres-app/src/main/glowroot/plugins/smtp.json new file mode 100644 index 00000000000..393bac9d9c3 --- /dev/null +++ b/server/apps/postgres-app/src/main/glowroot/plugins/smtp.json @@ -0,0 +1,19 @@ +{ + "name": "SMTP Plugin", + "id": "smtp", + "instrumentation": [ + { + "className": "org.apache.james.protocols.smtp.core.AbstractHookableCmdHandler", + "methodName": "onCommand", + "methodParameterTypes": [ + ".." + ], + "captureKind": "transaction", + "transactionType": "SMTP", + "transactionNameTemplate": "SMTP command : {{this.class.name}}", + "alreadyInTransactionBehavior": "capture-trace-entry", + "traceEntryMessageTemplate": "{{this.class.name}}.{{methodName}}", + "timerName": "smtpProcessor" + } + ] +} \ No newline at end of file diff --git a/server/apps/postgres-app/src/main/glowroot/plugins/spooler.json b/server/apps/postgres-app/src/main/glowroot/plugins/spooler.json new file mode 100644 index 00000000000..fd7732de8b2 --- /dev/null +++ b/server/apps/postgres-app/src/main/glowroot/plugins/spooler.json @@ -0,0 +1,45 @@ +{ + "name": "Spooler Plugin", + "id": "spooler", + "instrumentation": [ + { + "className": "org.apache.james.mailetcontainer.api.MailProcessor", + "methodName": "service", + "methodParameterTypes": [ + ".." + ], + "captureKind": "transaction", + "transactionType": "Spooler", + "transactionNameTemplate": "Mailet processor : {{this.class.name}}", + "alreadyInTransactionBehavior": "capture-trace-entry", + "traceEntryMessageTemplate": "{{this.class.name}}.{{methodName}}", + "timerName": "mailetProcessor" + }, + { + "className": "org.apache.mailet.Mailet", + "methodName": "service", + "methodParameterTypes": [ + ".." + ], + "captureKind": "transaction", + "transactionType": "Mailet", + "transactionNameTemplate": "Mailet : {{this.class.name}}", + "alreadyInTransactionBehavior": "capture-trace-entry", + "traceEntryMessageTemplate": "{{this.class.name}}.{{methodName}}", + "timerName": "mailet" + }, + { + "className": "org.apache.mailet.Matcher", + "methodName": "match", + "methodParameterTypes": [ + ".." + ], + "captureKind": "transaction", + "transactionType": "Matcher", + "transactionNameTemplate": "Mailet processor : {{this.class.name}}", + "alreadyInTransactionBehavior": "capture-trace-entry", + "traceEntryMessageTemplate": "{{this.class.name}}.{{methodName}}", + "timerName": "matcher" + } + ] +} \ No newline at end of file diff --git a/server/apps/postgres-app/src/main/glowroot/plugins/task.json b/server/apps/postgres-app/src/main/glowroot/plugins/task.json new file mode 100644 index 00000000000..8f04c69e741 --- /dev/null +++ b/server/apps/postgres-app/src/main/glowroot/plugins/task.json @@ -0,0 +1,19 @@ +{ + "name": "Task Plugin", + "id": "task", + "instrumentation": [ + { + "className": "org.apache.james.task.Task", + "methodName": "run", + "methodParameterTypes": [ + ".." + ], + "captureKind": "transaction", + "transactionType": "TASK", + "transactionNameTemplate": "TASK : {{this.class.name}}", + "alreadyInTransactionBehavior": "capture-trace-entry", + "traceEntryMessageTemplate": "{{this.class.name}}.{{methodName}}", + "timerName": "task" + } + ] +} \ No newline at end of file diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java new file mode 100644 index 00000000000..34305a69e36 --- /dev/null +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java @@ -0,0 +1,127 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james; + +import java.io.File; +import java.util.Optional; + +import org.apache.james.data.UsersRepositoryModuleChooser; +import org.apache.james.filesystem.api.FileSystem; +import org.apache.james.filesystem.api.JamesDirectoriesProvider; +import org.apache.james.server.core.JamesServerResourceLoader; +import org.apache.james.server.core.MissingArgumentException; +import org.apache.james.server.core.configuration.Configuration; +import org.apache.james.server.core.configuration.FileConfigurationProvider; +import org.apache.james.server.core.filesystem.FileSystemImpl; + +public class PostgresJamesConfiguration implements Configuration { + public static class Builder { + private Optional rootDirectory; + private Optional configurationPath; + private Optional usersRepositoryImplementation; + + private Builder() { + rootDirectory = Optional.empty(); + configurationPath = Optional.empty(); + usersRepositoryImplementation = Optional.empty(); + } + + public Builder workingDirectory(String path) { + rootDirectory = Optional.of(path); + return this; + } + + public Builder workingDirectory(File file) { + rootDirectory = Optional.of(file.getAbsolutePath()); + return this; + } + + public Builder useWorkingDirectoryEnvProperty() { + rootDirectory = Optional.ofNullable(System.getProperty(WORKING_DIRECTORY)); + if (!rootDirectory.isPresent()) { + throw new MissingArgumentException("Server needs a working.directory env entry"); + } + return this; + } + + public Builder configurationPath(ConfigurationPath path) { + configurationPath = Optional.of(path); + return this; + } + + public Builder configurationFromClasspath() { + configurationPath = Optional.of(new ConfigurationPath(FileSystem.CLASSPATH_PROTOCOL)); + return this; + } + + public Builder usersRepository(UsersRepositoryModuleChooser.Implementation implementation) { + this.usersRepositoryImplementation = Optional.of(implementation); + return this; + } + + public PostgresJamesConfiguration build() { + ConfigurationPath configurationPath = this.configurationPath.orElse(new ConfigurationPath(FileSystem.FILE_PROTOCOL_AND_CONF)); + JamesServerResourceLoader directories = new JamesServerResourceLoader(rootDirectory + .orElseThrow(() -> new MissingArgumentException("Server needs a working.directory env entry"))); + + FileSystemImpl fileSystem = new FileSystemImpl(directories); + + FileConfigurationProvider configurationProvider = new FileConfigurationProvider(fileSystem, Basic.builder() + .configurationPath(configurationPath) + .workingDirectory(directories.getRootDirectory()) + .build()); + UsersRepositoryModuleChooser.Implementation usersRepositoryChoice = usersRepositoryImplementation.orElseGet( + () -> UsersRepositoryModuleChooser.Implementation.parse(configurationProvider)); + + return new PostgresJamesConfiguration( + configurationPath, + directories, + usersRepositoryChoice); + } + } + + public static Builder builder() { + return new Builder(); + } + + private final ConfigurationPath configurationPath; + private final JamesDirectoriesProvider directories; + private final UsersRepositoryModuleChooser.Implementation usersRepositoryImplementation; + + public PostgresJamesConfiguration(ConfigurationPath configurationPath, JamesDirectoriesProvider directories, UsersRepositoryModuleChooser.Implementation usersRepositoryImplementation) { + this.configurationPath = configurationPath; + this.directories = directories; + this.usersRepositoryImplementation = usersRepositoryImplementation; + } + + @Override + public ConfigurationPath configurationPath() { + return configurationPath; + } + + @Override + public JamesDirectoriesProvider directories() { + return directories; + } + + public UsersRepositoryModuleChooser.Implementation getUsersRepositoryImplementation() { + return usersRepositoryImplementation; + } +} diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java new file mode 100644 index 00000000000..42ce13a20fe --- /dev/null +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -0,0 +1,119 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james; + +import org.apache.james.data.UsersRepositoryModuleChooser; +import org.apache.james.modules.MailboxModule; +import org.apache.james.modules.MailetProcessingModule; +import org.apache.james.modules.RunArgumentsModule; +import org.apache.james.modules.data.JPADataModule; +import org.apache.james.modules.data.JPAUsersRepositoryModule; +import org.apache.james.modules.data.SieveJPARepositoryModules; +import org.apache.james.modules.mailbox.DefaultEventModule; +import org.apache.james.modules.mailbox.JPAMailboxModule; +import org.apache.james.modules.mailbox.LuceneSearchMailboxModule; +import org.apache.james.modules.mailbox.MemoryDeadLetterModule; +import org.apache.james.modules.protocols.IMAPServerModule; +import org.apache.james.modules.protocols.LMTPServerModule; +import org.apache.james.modules.protocols.ManageSieveServerModule; +import org.apache.james.modules.protocols.POP3ServerModule; +import org.apache.james.modules.protocols.ProtocolHandlerModule; +import org.apache.james.modules.protocols.SMTPServerModule; +import org.apache.james.modules.queue.activemq.ActiveMQQueueModule; +import org.apache.james.modules.server.DataRoutesModules; +import org.apache.james.modules.server.DefaultProcessorsConfigurationProviderModule; +import org.apache.james.modules.server.InconsistencyQuotasSolvingRoutesModule; +import org.apache.james.modules.server.JMXServerModule; +import org.apache.james.modules.server.MailQueueRoutesModule; +import org.apache.james.modules.server.MailRepositoriesRoutesModule; +import org.apache.james.modules.server.MailboxRoutesModule; +import org.apache.james.modules.server.NoJwtModule; +import org.apache.james.modules.server.RawPostDequeueDecoratorModule; +import org.apache.james.modules.server.ReIndexingModule; +import org.apache.james.modules.server.SieveRoutesModule; +import org.apache.james.modules.server.TaskManagerModule; +import org.apache.james.modules.server.WebAdminReIndexingTaskSerializationModule; +import org.apache.james.modules.server.WebAdminServerModule; + +import com.google.inject.Module; +import com.google.inject.util.Modules; + +public class PostgresJamesServerMain implements JamesServerMain { + + private static final Module WEBADMIN = Modules.combine( + new WebAdminServerModule(), + new DataRoutesModules(), + new InconsistencyQuotasSolvingRoutesModule(), + new MailboxRoutesModule(), + new MailQueueRoutesModule(), + new MailRepositoriesRoutesModule(), + new ReIndexingModule(), + new SieveRoutesModule(), + new WebAdminReIndexingTaskSerializationModule()); + + private static final Module PROTOCOLS = Modules.combine( + new IMAPServerModule(), + new LMTPServerModule(), + new ManageSieveServerModule(), + new POP3ServerModule(), + new ProtocolHandlerModule(), + new SMTPServerModule(), + WEBADMIN); + + private static final Module JPA_SERVER_MODULE = Modules.combine( + new ActiveMQQueueModule(), + new NaiveDelegationStoreModule(), + new DefaultProcessorsConfigurationProviderModule(), + new JPADataModule(), + new JPAMailboxModule(), + new MailboxModule(), + new LuceneSearchMailboxModule(), + new NoJwtModule(), + new RawPostDequeueDecoratorModule(), + new SieveJPARepositoryModules(), + new DefaultEventModule(), + new TaskManagerModule(), + new MemoryDeadLetterModule()); + + private static final Module JPA_MODULE_AGGREGATE = Modules.combine( + new MailetProcessingModule(), JPA_SERVER_MODULE, PROTOCOLS); + + public static void main(String[] args) throws Exception { + ExtraProperties.initialize(); + + PostgresJamesConfiguration configuration = PostgresJamesConfiguration.builder() + .useWorkingDirectoryEnvProperty() + .build(); + + LOGGER.info("Loading configuration {}", configuration.toString()); + GuiceJamesServer server = createServer(configuration) + .combineWith(new JMXServerModule()) + .overrideWith(new RunArgumentsModule(args)); + + JamesServerMain.main(server); + } + + static GuiceJamesServer createServer(PostgresJamesConfiguration configuration) { + return GuiceJamesServer.forConfiguration(configuration) + .combineWith(JPA_MODULE_AGGREGATE) + .combineWith(new UsersRepositoryModuleChooser(new JPAUsersRepositoryModule()) + .chooseModules(configuration.getUsersRepositoryImplementation())); + } +} diff --git a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml new file mode 100644 index 00000000000..3c26a90ca2c --- /dev/null +++ b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml @@ -0,0 +1,64 @@ + + + + + + + org.apache.james.mailbox.jpa.mail.model.JPAMailbox + org.apache.james.mailbox.jpa.mail.model.JPAUserFlag + org.apache.james.mailbox.jpa.mail.model.openjpa.AbstractJPAMailboxMessage + org.apache.james.mailbox.jpa.mail.model.openjpa.JPAMailboxMessage + org.apache.james.mailbox.jpa.mail.model.JPAProperty + org.apache.james.mailbox.jpa.user.model.JPASubscription + + org.apache.james.domainlist.jpa.model.JPADomain + org.apache.james.mailrepository.jpa.model.JPAUrl + org.apache.james.mailrepository.jpa.model.JPAMail + org.apache.james.user.jpa.model.JPAUser + org.apache.james.rrt.jpa.model.JPARecipientRewrite + org.apache.james.sieve.jpa.model.JPASieveQuota + org.apache.james.sieve.jpa.model.JPASieveScript + + org.apache.james.mailbox.jpa.quota.model.MaxDomainMessageCount + org.apache.james.mailbox.jpa.quota.model.MaxDomainStorage + org.apache.james.mailbox.jpa.quota.model.MaxGlobalMessageCount + org.apache.james.mailbox.jpa.quota.model.MaxGlobalStorage + org.apache.james.mailbox.jpa.quota.model.MaxUserMessageCount + org.apache.james.mailbox.jpa.quota.model.MaxUserStorage + org.apache.james.mailbox.jpa.quota.model.MaxDomainStorage + org.apache.james.mailbox.jpa.quota.model.MaxDomainMessageCount + org.apache.james.mailbox.jpa.quota.model.JpaCurrentQuota + + org.apache.james.mailbox.jpa.mail.model.JPAMailboxAnnotation + org.apache.james.mailbox.jpa.mail.model.JPAMailboxAnnotationId + + + + + + + + + + + diff --git a/server/apps/postgres-app/src/main/resources/defaultMailetContainer.xml b/server/apps/postgres-app/src/main/resources/defaultMailetContainer.xml new file mode 100644 index 00000000000..3822f0c210f --- /dev/null +++ b/server/apps/postgres-app/src/main/resources/defaultMailetContainer.xml @@ -0,0 +1,87 @@ + + + + + + + + transport + + + + + + + + + + + + X-UserIsAuth + true + + + bcc + + + + + local-address-error + 550 - Requested action not taken: no such user here + + + outgoing + 5000, 100000, 500000 + 3 + 0 + 10 + true + bounces + + + relay-denied + + + + + + + + + + none + + + + + + + none + + + + + + + false + + + + \ No newline at end of file diff --git a/server/apps/postgres-app/src/main/scripts/james-cli b/server/apps/postgres-app/src/main/scripts/james-cli new file mode 100755 index 00000000000..19a73b6fb12 --- /dev/null +++ b/server/apps/postgres-app/src/main/scripts/james-cli @@ -0,0 +1,3 @@ +#!/bin/bash + +java -cp /root/resources:/root/classes:/root/libs/* org.apache.james.cli.ServerCmd "$@" \ No newline at end of file diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerTest.java new file mode 100644 index 00000000000..4ff4cee67f5 --- /dev/null +++ b/server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerTest.java @@ -0,0 +1,98 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Durations.FIVE_HUNDRED_MILLISECONDS; +import static org.awaitility.Durations.ONE_MINUTE; + +import org.apache.james.core.quota.QuotaSizeLimit; +import org.apache.james.modules.QuotaProbesImpl; +import org.apache.james.modules.protocols.ImapGuiceProbe; +import org.apache.james.modules.protocols.SmtpGuiceProbe; +import org.apache.james.utils.DataProbeImpl; +import org.apache.james.utils.SMTPMessageSender; +import org.apache.james.utils.TestIMAPClient; +import org.awaitility.Awaitility; +import org.awaitility.core.ConditionFactory; +import org.junit.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.google.common.base.Strings; + +class JPAJamesServerTest implements JamesServerConcreteContract { + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .usersRepository(DEFAULT) + .build()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(new TestJPAConfigurationModule())) + .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) + .build(); + + private static final ConditionFactory AWAIT = Awaitility.await() + .atMost(ONE_MINUTE) + .with() + .pollInterval(FIVE_HUNDRED_MILLISECONDS); + static final String DOMAIN = "james.local"; + private static final String USER = "toto@" + DOMAIN; + private static final String PASSWORD = "123456"; + + private TestIMAPClient testIMAPClient; + private SMTPMessageSender smtpMessageSender; + + @BeforeEach + void setUp() { + this.testIMAPClient = new TestIMAPClient(); + this.smtpMessageSender = new SMTPMessageSender(DOMAIN); + } + + @Test + void jpaGuiceServerShouldUpdateQuota(GuiceJamesServer jamesServer) throws Exception { + jamesServer.getProbe(DataProbeImpl.class) + .fluent() + .addDomain(DOMAIN) + .addUser(USER, PASSWORD); + jamesServer.getProbe(QuotaProbesImpl.class).setGlobalMaxStorage(QuotaSizeLimit.size(50 * 1024)); + + // ~ 12 KB email + int imapPort = jamesServer.getProbe(ImapGuiceProbe.class).getImapPort(); + smtpMessageSender.connect(JAMES_SERVER_HOST, jamesServer.getProbe(SmtpGuiceProbe.class).getSmtpPort()) + .authenticate(USER, PASSWORD) + .sendMessageWithHeaders(USER, USER, "header: toto\\r\\n\\r\\n" + Strings.repeat("0123456789\n", 1024)); + AWAIT.until(() -> testIMAPClient.connect(JAMES_SERVER_HOST, imapPort) + .login(USER, PASSWORD) + .select(TestIMAPClient.INBOX) + .hasAMessage()); + + assertThat( + testIMAPClient.connect(JAMES_SERVER_HOST, imapPort) + .login(USER, PASSWORD) + .getQuotaRoot(TestIMAPClient.INBOX)) + .startsWith("* QUOTAROOT \"INBOX\" #private&toto@james.local\r\n" + + "* QUOTA #private&toto@james.local (STORAGE 12 50)\r\n") + .endsWith("OK GETQUOTAROOT completed.\r\n"); + } +} diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerWithAuthenticatedDatabaseSqlValidationTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerWithAuthenticatedDatabaseSqlValidationTest.java new file mode 100644 index 00000000000..a1345fe7f67 --- /dev/null +++ b/server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerWithAuthenticatedDatabaseSqlValidationTest.java @@ -0,0 +1,39 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.junit.jupiter.api.extension.RegisterExtension; + +class JPAJamesServerWithAuthenticatedDatabaseSqlValidationTest extends JPAJamesServerWithSqlValidationTest { + + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .usersRepository(DEFAULT) + .build()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(new TestJPAConfigurationModuleWithSqlValidation.WithDatabaseAuthentication())) + .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) + .build(); +} diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java new file mode 100644 index 00000000000..42e03ee83fc --- /dev/null +++ b/server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java @@ -0,0 +1,38 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.junit.jupiter.api.extension.RegisterExtension; + +class JPAJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest extends JPAJamesServerWithSqlValidationTest { + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .usersRepository(DEFAULT) + .build()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(new TestJPAConfigurationModuleWithSqlValidation.NoDatabaseAuthentication())) + .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) + .build(); +} diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerWithSqlValidationTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerWithSqlValidationTest.java new file mode 100644 index 00000000000..4a0e1f513d6 --- /dev/null +++ b/server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerWithSqlValidationTest.java @@ -0,0 +1,30 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james; + +import org.junit.jupiter.api.Disabled; + +abstract class JPAJamesServerWithSqlValidationTest extends JPAJamesServerTest { + + @Override + @Disabled("Failing to create the domain: duplicate with test in JPAJamesServerTest") + void jpaGuiceServerShouldUpdateQuota(GuiceJamesServer jamesServer) { + } +} diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/JPAWithLDAPJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/JPAWithLDAPJamesServerTest.java new file mode 100644 index 00000000000..a853cd0b284 --- /dev/null +++ b/server/apps/postgres-app/src/test/java/org/apache/james/JPAWithLDAPJamesServerTest.java @@ -0,0 +1,57 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james; + +import static org.apache.james.MailsShouldBeWellReceived.JAMES_SERVER_HOST; +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.LDAP; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; + +import org.apache.commons.net.imap.IMAPClient; +import org.apache.james.data.LdapTestExtension; +import org.apache.james.modules.protocols.ImapGuiceProbe; +import org.apache.james.user.ldap.DockerLdapSingleton; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +class JPAWithLDAPJamesServerTest { + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .usersRepository(LDAP) + .build()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(new TestJPAConfigurationModule())) + .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) + .extension(new LdapTestExtension()) + .build(); + + + @Test + void userFromLdapShouldLoginViaImapProtocol(GuiceJamesServer server) throws IOException { + IMAPClient imapClient = new IMAPClient(); + imapClient.connect(JAMES_SERVER_HOST, server.getProbe(ImapGuiceProbe.class).getImapPort()); + + assertThat(imapClient.login(DockerLdapSingleton.JAMES_USER.asString(), DockerLdapSingleton.PASSWORD)).isTrue(); + } +} diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java new file mode 100644 index 00000000000..451eb4d024c --- /dev/null +++ b/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java @@ -0,0 +1,59 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.EnumSet; + +import org.apache.james.mailbox.MailboxManager; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +class JamesCapabilitiesServerTest { + private static MailboxManager mailboxManager() { + MailboxManager mailboxManager = mock(MailboxManager.class); + when(mailboxManager.getSupportedMailboxCapabilities()) + .thenReturn(EnumSet.noneOf(MailboxManager.MailboxCapabilities.class)); + when(mailboxManager.getSupportedMessageCapabilities()) + .thenReturn(EnumSet.noneOf(MailboxManager.MessageCapabilities.class)); + when(mailboxManager.getSupportedSearchCapabilities()) + .thenReturn(EnumSet.noneOf(MailboxManager.SearchCapabilities.class)); + return mailboxManager; + } + + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .usersRepository(DEFAULT) + .build()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(new TestJPAConfigurationModule()) + .overrideWith(binder -> binder.bind(MailboxManager.class).toInstance(mailboxManager()))) + .build(); + + @Test + void startShouldSucceedWhenRequiredCapabilities(GuiceJamesServer server) { + + } +} diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/JamesServerConcreteContract.java b/server/apps/postgres-app/src/test/java/org/apache/james/JamesServerConcreteContract.java new file mode 100644 index 00000000000..3ac19242eeb --- /dev/null +++ b/server/apps/postgres-app/src/test/java/org/apache/james/JamesServerConcreteContract.java @@ -0,0 +1,52 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james; + +import org.apache.james.modules.protocols.ImapGuiceProbe; +import org.apache.james.modules.protocols.LmtpGuiceProbe; +import org.apache.james.modules.protocols.Pop3GuiceProbe; +import org.apache.james.modules.protocols.SmtpGuiceProbe; + +public interface JamesServerConcreteContract extends JamesServerContract { + @Override + default int imapPort(GuiceJamesServer server) { + return server.getProbe(ImapGuiceProbe.class).getImapPort(); + } + + @Override + default int imapsPort(GuiceJamesServer server) { + return server.getProbe(ImapGuiceProbe.class).getImapStartTLSPort(); + } + + @Override + default int smtpPort(GuiceJamesServer server) { + return server.getProbe(SmtpGuiceProbe.class).getSmtpPort().getValue(); + } + + @Override + default int lmtpPort(GuiceJamesServer server) { + return server.getProbe(LmtpGuiceProbe.class).getLmtpPort(); + } + + @Override + default int pop3Port(GuiceJamesServer server) { + return server.getProbe(Pop3GuiceProbe.class).getPop3Port(); + } +} diff --git a/server/apps/postgres-app/src/test/resources/dnsservice.xml b/server/apps/postgres-app/src/test/resources/dnsservice.xml new file mode 100644 index 00000000000..6e4fbd2efb5 --- /dev/null +++ b/server/apps/postgres-app/src/test/resources/dnsservice.xml @@ -0,0 +1,25 @@ + + + + + true + false + 50000 + diff --git a/server/apps/postgres-app/src/test/resources/domainlist.xml b/server/apps/postgres-app/src/test/resources/domainlist.xml new file mode 100644 index 00000000000..fe17431a1ea --- /dev/null +++ b/server/apps/postgres-app/src/test/resources/domainlist.xml @@ -0,0 +1,24 @@ + + + + + false + false + diff --git a/server/apps/postgres-app/src/test/resources/fakemailrepositorystore.xml b/server/apps/postgres-app/src/test/resources/fakemailrepositorystore.xml new file mode 100644 index 00000000000..2d19a802da9 --- /dev/null +++ b/server/apps/postgres-app/src/test/resources/fakemailrepositorystore.xml @@ -0,0 +1,31 @@ + + + + + + + + + file + + + + + diff --git a/server/apps/postgres-app/src/test/resources/imapserver.xml b/server/apps/postgres-app/src/test/resources/imapserver.xml new file mode 100644 index 00000000000..3434dbce390 --- /dev/null +++ b/server/apps/postgres-app/src/test/resources/imapserver.xml @@ -0,0 +1,57 @@ + + + + + + + + imapserver + 0.0.0.0:0 + 200 + + + classpath://keystore + james72laBalle + org.bouncycastle.jce.provider.BouncyCastleProvider + + 0 + 0 + false + false + + + imapserver-ssl + 0.0.0.0:0 + 200 + + + classpath://keystore + james72laBalle + org.bouncycastle.jce.provider.BouncyCastleProvider + + 0 + 0 + false + + diff --git a/server/apps/postgres-app/src/test/resources/keystore b/server/apps/postgres-app/src/test/resources/keystore new file mode 100644 index 0000000000000000000000000000000000000000..536a6c792b0740ef4273327bf4a61ffc2d6491d8 GIT binary patch literal 2245 zcmchY={pn*7sh8LBQ%y#WJwLmHX~!-n&e4IWZ$x7nXzWyM!ymF9%GH0|)`01HpknC;&o)EW|hTnC0KzVn%TKNE#dU1v||+1tZxX zS_9GsgkCLFCv|_)LvA!S*k!K2h)$={;+p9hHH7Nb0p>KwaVg~IFb3Sc1wDRw9$A){s zjWgyn8QQ_DwD67^UN~?lj{Brp?9aL{)#!V+F@3yd+SXoy#ls2T};RV4e2y4MYI1_L5*8Y+3@jZ}Jq=k;pjN{&W6V&8CnMam*;{LK8_ zVM=cij+9`Yn?R}TQ&+mUIg*K2CR|gqXqw>>3OJI|3T0Q6?~|~GQ+Cq*Ub{W= z#tEY5JH3B7<^Ay^isK!NQlyqlK>%jK4bn-JJ1I_tg1E53mrrAfv?W-!v5v*W1PD^o zxAg%m|LiTHI$`?t4_QyHAX{D{qH>>39tRp>KI;&`pMqjM%_S@a>jO>` z6pB-cdX{xVxy#YMXTrC-^vxG;KHTzHJl8ZO(ySb{-z~l#bcPwmZz!xT*qai`@=~g7 zm%`Wwk)!3E8#0=esd0RL9=xO}l_gdqO`CGH7ked&sARd)5kT$wm= z(V}s9O156MBTz(2khxa8_$Q`dZatu&qt;^pD<4J1$qXsr6Vb23Hu=&yB~!VNc_Jq7 z>VHqD5r3dce|yB1wtClTIY>%O@DHRB{=}X}6o%-w9had83mD84mrS?s_A(A^%{Ybf zRT$$U8`bB!I?xkRBP`95KfExp?{qx}b$oLcb-j z058_v&mR{oY2ohUgL4l=i3{_fF(`FqRg~I!WempdH=@zXD*wg*_c%nL)ISY5{1;#% zkPm<&0%0H`5C}-{<*=1KBbO?SE#xkKMXvqKHKh)AwKZ^R?x7Gq zEJ*}Q`i!-;D;`bn<_(PMs?Z!Azhb;wGdEjk+VigAO}tt$&0gSSAkd^Qu!YeAVl>_P zq$(ep;B$ZZRcA%4lYiy6#UI5)x3Z~7q5Zti`7%_(oi!vm`e!I-%8fY0(DZ6xzl)3s zC8vu)lBpgh%sJWw?xJ&^Lf|}E;FK>dP{OL^>8>odoE0JSm(A1w7;@mTwWsWTaS38liiOoY7+EQJp|1|ONst!#A z0&q=oUM&(2S+u)9)NE3)LgN5Iy~&PWa%6*-3MUjfcyByu7b)f3tpKXQeTd-2|17(3qjJ zuCdt!7~*+Jj-k$)2}|B;vFe5_aZzP>x+f-|h}*dnJi&WkeY1Xb&&jLmqkgpE0spgY zybxo}kn!S$8P;k(zWJ(t|K7IXP**)mv%t;DM3PJALygR(3trmZ)bjb(P7m4wUZX6{ zTa^)O + + + + + + lmtpserver + + 127.0.0.1:0 + 200 + 1200 + + 0 + + 0 + + + 0 + + + + false + + + diff --git a/server/apps/postgres-app/src/test/resources/mailetcontainer.xml b/server/apps/postgres-app/src/test/resources/mailetcontainer.xml new file mode 100644 index 00000000000..b8b531ddfb7 --- /dev/null +++ b/server/apps/postgres-app/src/test/resources/mailetcontainer.xml @@ -0,0 +1,123 @@ + + + + + + + + postmaster + + + + 20 + file://var/mail/error/ + + + + + + + + transport + + + + + + ignore + + + file://var/mail/error/ + propagate + + + + + + + + + + + + bcc + + + rrt-error + + + + local-address-error + 550 - Requested action not taken: no such user here + + + + outgoing + 5000, 100000, 500000 + 3 + 0 + 10 + true + bounces + + + relay-denied + + + + + + none + + + file://var/mail/address-error/ + + + + + + none + + + file://var/mail/relay-denied/ + Warning: You are sending an e-mail to a remote server. You must be authentified to perform such an operation + + + + + + false + + + + + + file://var/mail/rrt-error/ + true + + + + + + + + + + diff --git a/server/apps/postgres-app/src/test/resources/mailrepositorystore.xml b/server/apps/postgres-app/src/test/resources/mailrepositorystore.xml new file mode 100644 index 00000000000..3ca4a1d0056 --- /dev/null +++ b/server/apps/postgres-app/src/test/resources/mailrepositorystore.xml @@ -0,0 +1,31 @@ + + + + + + + + + file + + + + + diff --git a/server/apps/postgres-app/src/test/resources/managesieveserver.xml b/server/apps/postgres-app/src/test/resources/managesieveserver.xml new file mode 100644 index 00000000000..b644fa43177 --- /dev/null +++ b/server/apps/postgres-app/src/test/resources/managesieveserver.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + managesieveserver + + 0.0.0.0:0 + + 200 + + + + file://conf/keystore + james72laBalle + org.bouncycastle.jce.provider.BouncyCastleProvider + + SunX509 + + + + 360 + + + 0 + + + 0 + 0 + true + false + + + + + + diff --git a/server/apps/postgres-app/src/test/resources/pop3server.xml b/server/apps/postgres-app/src/test/resources/pop3server.xml new file mode 100644 index 00000000000..6e4473aae2b --- /dev/null +++ b/server/apps/postgres-app/src/test/resources/pop3server.xml @@ -0,0 +1,43 @@ + + + + + + + pop3server + 0.0.0.0:0 + 200 + + + file://conf/keystore + james72laBalle + org.bouncycastle.jce.provider.BouncyCastleProvider + + 1200 + 0 + 0 + + + + false + + diff --git a/server/apps/postgres-app/src/test/resources/smtpserver.xml b/server/apps/postgres-app/src/test/resources/smtpserver.xml new file mode 100644 index 00000000000..36ac142375e --- /dev/null +++ b/server/apps/postgres-app/src/test/resources/smtpserver.xml @@ -0,0 +1,111 @@ + + + + + + + smtpserver-global + 0.0.0.0:0 + 200 + + file://conf/keystore + james72laBalle + org.bouncycastle.jce.provider.BouncyCastleProvider + SunX509 + + 360 + 0 + 0 + + never + false + true + + false + 0 + true + Apache JAMES awesome SMTP Server + + + + + false + + + smtpserver-TLS + 0.0.0.0:0 + 200 + + file://conf/keystore + james72laBalle + org.bouncycastle.jce.provider.BouncyCastleProvider + SunX509 + + 360 + 0 + 0 + + forUnauthorizedAddresses + false + true + + + false + 0 + true + Apache JAMES awesome SMTP Server + + + + + false + + + smtpserver-authenticated + 0.0.0.0:0 + 200 + + file://conf/keystore + james72laBalle + org.bouncycastle.jce.provider.BouncyCastleProvider + SunX509 + + 360 + 0 + 0 + + forUnauthorizedAddresses + false + true + + + false + 0 + true + Apache JAMES awesome SMTP Server + + + + + false + + + + diff --git a/server/pom.xml b/server/pom.xml index a9ee64b77d6..26085d0e6b2 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -46,6 +46,7 @@ apps/jpa-app apps/jpa-smtp-app apps/memory-app + apps/postgres-app apps/scaling-pulsar-smtp apps/spring-app apps/webadmin-cli From 99e294953001107dce5db4e30256e7b0d20ea9e8 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Tue, 31 Oct 2023 10:06:42 +0700 Subject: [PATCH 023/341] JAMES-2586 - Postgres - Init james-server-data-postgres --- pom.xml | 11 + server/data/data-postgres/pom.xml | 176 ++++++++ .../james/domainlist/jpa/JPADomainList.java | 178 ++++++++ .../james/domainlist/jpa/model/JPADomain.java | 69 +++ .../james/jpa/healthcheck/JPAHealthCheck.java | 64 +++ .../mailrepository/jpa/JPAMailRepository.java | 407 ++++++++++++++++++ .../jpa/JPAMailRepositoryFactory.java | 52 +++ .../jpa/JPAMailRepositoryUrlStore.java | 65 +++ .../jpa/MimeMessageJPASource.java | 54 +++ .../mailrepository/jpa/model/JPAMail.java | 246 +++++++++++ .../mailrepository/jpa/model/JPAUrl.java | 65 +++ .../rrt/jpa/JPARecipientRewriteTable.java | 251 +++++++++++ .../rrt/jpa/model/JPARecipientRewrite.java | 147 +++++++ .../james/sieve/jpa/JPASieveRepository.java | 363 ++++++++++++++++ .../james/sieve/jpa/model/JPASieveQuota.java | 97 +++++ .../james/sieve/jpa/model/JPASieveScript.java | 200 +++++++++ .../apache/james/user/jpa/JPAUsersDAO.java | 267 ++++++++++++ .../james/user/jpa/JPAUsersRepository.java | 64 +++ .../apache/james/user/jpa/model/JPAUser.java | 193 +++++++++ .../data-postgres/src/reporting-site/site.xml | 29 ++ .../domainlist/jpa/JPADomainListTest.java | 71 +++ .../jpa/healthcheck/JPAHealthCheckTest.java | 62 +++ .../jpa/JPAMailRepositoryTest.java | 70 +++ .../JPAMailRepositoryUrlStoreExtension.java | 48 +++ .../jpa/JPAMailRepositoryUrlStoreTest.java | 28 ++ .../rrt/jpa/JPARecipientRewriteTableTest.java | 60 +++ .../org/apache/james/rrt/jpa/JPAStepdefs.java | 60 +++ .../james/rrt/jpa/RewriteTablesTest.java | 32 ++ .../sieve/jpa/JpaSieveRepositoryTest.java | 50 +++ .../user/jpa/JpaUsersRepositoryTest.java | 103 +++++ .../james/user/jpa/model/JPAUserTest.java | 73 ++++ .../src/test/resources/log4j.properties | 6 + .../src/test/resources/persistence.xml | 46 ++ server/pom.xml | 1 + 34 files changed, 3708 insertions(+) create mode 100644 server/data/data-postgres/pom.xml create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/JPADomainList.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/model/JPADomain.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/jpa/healthcheck/JPAHealthCheck.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/JPAMailRepository.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryFactory.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryUrlStore.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/MimeMessageJPASource.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/model/JPAMail.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/model/JPAUrl.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/rrt/jpa/JPARecipientRewriteTable.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/rrt/jpa/model/JPARecipientRewrite.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/sieve/jpa/JPASieveRepository.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/sieve/jpa/model/JPASieveQuota.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/sieve/jpa/model/JPASieveScript.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/user/jpa/JPAUsersDAO.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/user/jpa/JPAUsersRepository.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/user/jpa/model/JPAUser.java create mode 100644 server/data/data-postgres/src/reporting-site/site.xml create mode 100644 server/data/data-postgres/src/test/java/org/apache/james/domainlist/jpa/JPADomainListTest.java create mode 100644 server/data/data-postgres/src/test/java/org/apache/james/jpa/healthcheck/JPAHealthCheckTest.java create mode 100644 server/data/data-postgres/src/test/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryTest.java create mode 100644 server/data/data-postgres/src/test/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryUrlStoreExtension.java create mode 100644 server/data/data-postgres/src/test/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryUrlStoreTest.java create mode 100644 server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/JPARecipientRewriteTableTest.java create mode 100644 server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/JPAStepdefs.java create mode 100644 server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/RewriteTablesTest.java create mode 100644 server/data/data-postgres/src/test/java/org/apache/james/sieve/jpa/JpaSieveRepositoryTest.java create mode 100644 server/data/data-postgres/src/test/java/org/apache/james/user/jpa/JpaUsersRepositoryTest.java create mode 100644 server/data/data-postgres/src/test/java/org/apache/james/user/jpa/model/JPAUserTest.java create mode 100644 server/data/data-postgres/src/test/resources/log4j.properties create mode 100644 server/data/data-postgres/src/test/resources/persistence.xml diff --git a/pom.xml b/pom.xml index 86cac7b04bd..bdc7681caf7 100644 --- a/pom.xml +++ b/pom.xml @@ -1438,6 +1438,17 @@ ${project.version} test-jar + + ${james.groupId} + james-server-data-postgres + ${project.version} + + + ${james.groupId} + james-server-data-postgres + ${project.version} + test-jar + ${james.groupId} james-server-deleted-messages-vault diff --git a/server/data/data-postgres/pom.xml b/server/data/data-postgres/pom.xml new file mode 100644 index 00000000000..6d87122bfef --- /dev/null +++ b/server/data/data-postgres/pom.xml @@ -0,0 +1,176 @@ + + + 4.0.0 + + org.apache.james + james-server + 3.9.0-SNAPSHOT + ../../pom.xml + + + james-server-data-postgres + Apache James :: Server :: Data :: Postgres + + + + ${james.groupId} + apache-james-backends-jpa + + + ${james.groupId} + apache-james-backends-jpa + test-jar + test + + + ${james.groupId} + james-server-core + + + ${james.groupId} + james-server-data-api + + + ${james.groupId} + james-server-data-api + test-jar + test + + + ${james.groupId} + james-server-data-library + + + ${james.groupId} + james-server-data-library + test-jar + test + + + ${james.groupId} + james-server-dnsservice-api + + + ${james.groupId} + james-server-dnsservice-test + test + + + ${james.groupId} + james-server-lifecycle-api + + + ${james.groupId} + james-server-mailrepository-api + + + ${james.groupId} + james-server-mailrepository-api + test-jar + test + + + ${james.groupId} + james-server-testing + test + + + ${james.groupId} + testing-base + test + + + com.google.guava + guava + + + io.cucumber + cucumber-java + test + + + io.cucumber + cucumber-junit + test + + + io.cucumber + cucumber-picocontainer + test + + + org.apache.commons + commons-configuration2 + + + org.apache.derby + derby + test + + + org.mockito + mockito-core + test + + + org.slf4j + jcl-over-slf4j + + + org.slf4j + log4j-over-slf4j + + + org.slf4j + slf4j-api + + + + + + + + org.apache.openjpa + openjpa-maven-plugin + ${apache.openjpa.version} + + org/apache/james/sieve/jpa/model/JPASieveQuota.class, + org/apache/james/sieve/jpa/model/JPASieveScript.class, + org/apache/james/user/jpa/model/JPAUser.class, + org/apache/james/rrt/jpa/model/JPARecipientRewrite.class, + org/apache/james/domainlist/jpa/model/JPADomain.class, + org/apache/james/mailrepository/jpa/model/JPAUrl.class, + org/apache/james/mailrepository/jpa/model/JPAMail.class + true + true + + + log + TOOL=TRACE + + + metaDataFactory + jpa(Types=org.apache.james.sieve.jpa.model.JPASieveQuota; + org.apache.james.sieve.jpa.model.JPASieveScript; + org.apache.james.user.jpa.model.JPAUser; + org.apache.james.rrt.jpa.model.JPARecipientRewrite; + org.apache.james.domainlist.jpa.model.JPADomain; + org.apache.james.mailrepository.jpa.model.JPAUrl; + org.apache.james.mailrepository.jpa.model.JPAMail) + + + ${basedir}/src/test/resources/persistence.xml + + + + enhancer + + enhance + + process-classes + + + + + + diff --git a/server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/JPADomainList.java b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/JPADomainList.java new file mode 100644 index 00000000000..1432b211b8c --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/JPADomainList.java @@ -0,0 +1,178 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.domainlist.jpa; + +import java.util.List; + +import javax.annotation.PostConstruct; +import javax.inject.Inject; +import javax.persistence.EntityManager; +import javax.persistence.EntityManagerFactory; +import javax.persistence.EntityTransaction; +import javax.persistence.NoResultException; +import javax.persistence.PersistenceException; +import javax.persistence.PersistenceUnit; + +import org.apache.james.backends.jpa.EntityManagerUtils; +import org.apache.james.core.Domain; +import org.apache.james.dnsservice.api.DNSService; +import org.apache.james.domainlist.api.DomainListException; +import org.apache.james.domainlist.jpa.model.JPADomain; +import org.apache.james.domainlist.lib.AbstractDomainList; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.collect.ImmutableList; + +/** + * JPA implementation of the DomainList.
    + * This implementation is compatible with the JDBCDomainList, meaning same + * database schema can be reused. + */ +public class JPADomainList extends AbstractDomainList { + private static final Logger LOGGER = LoggerFactory.getLogger(JPADomainList.class); + + /** + * The entity manager to access the database. + */ + private EntityManagerFactory entityManagerFactory; + + @Inject + public JPADomainList(DNSService dns, EntityManagerFactory entityManagerFactory) { + super(dns); + this.entityManagerFactory = entityManagerFactory; + } + + /** + * Set the entity manager to use. + */ + @Inject + @PersistenceUnit(unitName = "James") + public void setEntityManagerFactory(EntityManagerFactory entityManagerFactory) { + this.entityManagerFactory = entityManagerFactory; + } + + @PostConstruct + public void init() { + EntityManagerUtils.safelyClose(createEntityManager()); + } + + @SuppressWarnings("unchecked") + @Override + protected List getDomainListInternal() throws DomainListException { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + try { + List resultList = entityManager + .createNamedQuery("listDomainNames") + .getResultList(); + return resultList + .stream() + .map(Domain::of) + .collect(ImmutableList.toImmutableList()); + } catch (PersistenceException e) { + LOGGER.error("Failed to list domains", e); + throw new DomainListException("Unable to retrieve domains", e); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + @Override + protected boolean containsDomainInternal(Domain domain) throws DomainListException { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + try { + return containsDomainInternal(domain, entityManager); + } catch (PersistenceException e) { + LOGGER.error("Failed to find domain", e); + throw new DomainListException("Unable to retrieve domains", e); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + @Override + public void addDomain(Domain domain) throws DomainListException { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + final EntityTransaction transaction = entityManager.getTransaction(); + try { + transaction.begin(); + if (containsDomainInternal(domain, entityManager)) { + transaction.commit(); + throw new DomainListException(domain.name() + " already exists."); + } + JPADomain jpaDomain = new JPADomain(domain); + entityManager.persist(jpaDomain); + transaction.commit(); + } catch (PersistenceException e) { + LOGGER.error("Failed to save domain", e); + rollback(transaction); + throw new DomainListException("Unable to add domain " + domain.name(), e); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + @Override + public void doRemoveDomain(Domain domain) throws DomainListException { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + final EntityTransaction transaction = entityManager.getTransaction(); + try { + transaction.begin(); + if (!containsDomainInternal(domain, entityManager)) { + transaction.commit(); + throw new DomainListException(domain.name() + " was not found."); + } + entityManager.createNamedQuery("deleteDomainByName").setParameter("name", domain.asString()).executeUpdate(); + transaction.commit(); + } catch (PersistenceException e) { + LOGGER.error("Failed to remove domain", e); + rollback(transaction); + throw new DomainListException("Unable to remove domain " + domain.name(), e); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + private void rollback(EntityTransaction transaction) { + if (transaction.isActive()) { + transaction.rollback(); + } + } + + private boolean containsDomainInternal(Domain domain, EntityManager entityManager) { + try { + return entityManager.createNamedQuery("findDomainByName") + .setParameter("name", domain.asString()) + .getSingleResult() != null; + } catch (NoResultException e) { + LOGGER.debug("No domain found", e); + return false; + } + } + + /** + * Return a new {@link EntityManager} instance + * + * @return manager + */ + private EntityManager createEntityManager() { + return entityManagerFactory.createEntityManager(); + } + +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/model/JPADomain.java b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/model/JPADomain.java new file mode 100644 index 00000000000..3b4367494cf --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/model/JPADomain.java @@ -0,0 +1,69 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.domainlist.jpa.model; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.NamedQueries; +import javax.persistence.NamedQuery; +import javax.persistence.Table; + +import org.apache.james.core.Domain; + +/** + * Domain class for the James Domain to be used for JPA persistence. + */ +@Entity(name = "JamesDomain") +@Table(name = "JAMES_DOMAIN") +@NamedQueries({ + @NamedQuery(name = "findDomainByName", query = "SELECT domain FROM JamesDomain domain WHERE domain.name=:name"), + @NamedQuery(name = "containsDomain", query = "SELECT COUNT(domain) FROM JamesDomain domain WHERE domain.name=:name"), + @NamedQuery(name = "listDomainNames", query = "SELECT domain.name FROM JamesDomain domain"), + @NamedQuery(name = "deleteDomainByName", query = "DELETE FROM JamesDomain domain WHERE domain.name=:name") }) +public class JPADomain { + + /** + * The name of the domain. column name is chosen to be compatible with the + * JDBCDomainList. + */ + @Id + @Column(name = "DOMAIN_NAME", nullable = false, length = 100) + private String name; + + /** + * Default no-args constructor for JPA class enhancement. + * The constructor need to be public or protected to be used by JPA. + * See: http://docs.oracle.com/javaee/6/tutorial/doc/bnbqa.html + * Do not us this constructor, it is for JPA only. + */ + protected JPADomain() { + } + + /** + * Use this simple constructor to create a new Domain. + * + * @param name + * the name of the Domain + */ + public JPADomain(Domain name) { + this.name = name.asString(); + } + +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/jpa/healthcheck/JPAHealthCheck.java b/server/data/data-postgres/src/main/java/org/apache/james/jpa/healthcheck/JPAHealthCheck.java new file mode 100644 index 00000000000..7dbea33e7f3 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/jpa/healthcheck/JPAHealthCheck.java @@ -0,0 +1,64 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.jpa.healthcheck; + +import static org.apache.james.core.healthcheck.Result.healthy; +import static org.apache.james.core.healthcheck.Result.unhealthy; + +import javax.inject.Inject; +import javax.persistence.EntityManagerFactory; + +import org.apache.james.backends.jpa.EntityManagerUtils; +import org.apache.james.core.healthcheck.ComponentName; +import org.apache.james.core.healthcheck.HealthCheck; +import org.apache.james.core.healthcheck.Result; + +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +public class JPAHealthCheck implements HealthCheck { + + private final EntityManagerFactory entityManagerFactory; + + @Inject + public JPAHealthCheck(EntityManagerFactory entityManagerFactory) { + this.entityManagerFactory = entityManagerFactory; + } + + @Override + public ComponentName componentName() { + return new ComponentName("JPA Backend"); + } + + @Override + public Mono check() { + return Mono.usingWhen(Mono.fromCallable(entityManagerFactory::createEntityManager).subscribeOn(Schedulers.boundedElastic()), + entityManager -> { + if (entityManager.isOpen()) { + return Mono.just(healthy(componentName())); + } else { + return Mono.just(unhealthy(componentName(), "entityManager is not open")); + } + }, + entityManager -> Mono.fromRunnable(() -> EntityManagerUtils.safelyClose(entityManager)).subscribeOn(Schedulers.boundedElastic())) + .onErrorResume(IllegalStateException.class, + e -> Mono.just(unhealthy(componentName(), "EntityManagerFactory or EntityManager thrown an IllegalStateException, the connection is unhealthy", e))) + .onErrorResume(e -> Mono.just(unhealthy(componentName(), "Unexpected exception upon checking JPA driver", e))); + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/JPAMailRepository.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/JPAMailRepository.java new file mode 100644 index 00000000000..a70b4be6f7b --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/JPAMailRepository.java @@ -0,0 +1,407 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailrepository.jpa; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.sql.Timestamp; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.StringTokenizer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.annotation.PostConstruct; +import javax.inject.Inject; +import javax.mail.MessagingException; +import javax.mail.internet.AddressException; +import javax.mail.internet.MimeMessage; +import javax.persistence.EntityManager; +import javax.persistence.EntityManagerFactory; +import javax.persistence.EntityTransaction; +import javax.persistence.NoResultException; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; +import org.apache.james.backends.jpa.EntityManagerUtils; +import org.apache.james.core.MailAddress; +import org.apache.james.lifecycle.api.Configurable; +import org.apache.james.mailrepository.api.Initializable; +import org.apache.james.mailrepository.api.MailKey; +import org.apache.james.mailrepository.api.MailRepository; +import org.apache.james.mailrepository.api.MailRepositoryUrl; +import org.apache.james.mailrepository.jpa.model.JPAMail; +import org.apache.james.server.core.MailImpl; +import org.apache.james.server.core.MimeMessageWrapper; +import org.apache.james.util.AuditTrail; +import org.apache.james.util.streams.Iterators; +import org.apache.mailet.Attribute; +import org.apache.mailet.AttributeName; +import org.apache.mailet.AttributeValue; +import org.apache.mailet.Mail; +import org.apache.mailet.PerRecipientHeaders; +import org.apache.mailet.PerRecipientHeaders.Header; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.github.fge.lambdas.Throwing; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +/** + * Implementation of a MailRepository on a database via JPA. + */ +public class JPAMailRepository implements MailRepository, Configurable, Initializable { + private static final Logger LOGGER = LoggerFactory.getLogger(JPAMailRepository.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private String repositoryName; + + private final EntityManagerFactory entityManagerFactory; + + @Inject + public JPAMailRepository(EntityManagerFactory entityManagerFactory) { + this.entityManagerFactory = entityManagerFactory; + } + + public JPAMailRepository(EntityManagerFactory entityManagerFactory, MailRepositoryUrl url) throws ConfigurationException { + this.entityManagerFactory = entityManagerFactory; + this.repositoryName = url.getPath().asString(); + if (repositoryName.isEmpty()) { + throw new ConfigurationException( + "Malformed destinationURL - Must be of the format 'jpa://'. Was passed " + url); + } + } + + public String getRepositoryName() { + return repositoryName; + } + + // note: caller must close the returned EntityManager when done using it + protected EntityManager entityManager() { + return entityManagerFactory.createEntityManager(); + } + + @Override + public void configure(HierarchicalConfiguration configuration) throws ConfigurationException { + LOGGER.debug("{}.configure()", getClass().getName()); + String destination = configuration.getString("[@destinationURL]"); + MailRepositoryUrl url = MailRepositoryUrl.from(destination); // also validates url and standardizes slashes + repositoryName = url.getPath().asString(); + if (repositoryName.isEmpty()) { + throw new ConfigurationException( + "Malformed destinationURL - Must be of the format 'jpa://'. Was passed " + url); + } + LOGGER.debug("Parsed URL: repositoryName = '{}'", repositoryName); + } + + /** + * Initialises the JPA repository. + * + * @throws Exception if an error occurs + */ + @Override + @PostConstruct + public void init() throws Exception { + LOGGER.debug("{}.initialize()", getClass().getName()); + list(); + } + + @Override + public MailKey store(Mail mail) throws MessagingException { + MailKey key = MailKey.forMail(mail); + EntityManager entityManager = entityManager(); + try { + JPAMail jpaMail = new JPAMail(); + jpaMail.setRepositoryName(repositoryName); + jpaMail.setMessageName(mail.getName()); + jpaMail.setMessageState(mail.getState()); + jpaMail.setErrorMessage(mail.getErrorMessage()); + if (!mail.getMaybeSender().isNullSender()) { + jpaMail.setSender(mail.getMaybeSender().get().toString()); + } + String recipients = mail.getRecipients().stream() + .map(MailAddress::toString) + .collect(Collectors.joining("\r\n")); + jpaMail.setRecipients(recipients); + jpaMail.setRemoteHost(mail.getRemoteHost()); + jpaMail.setRemoteAddr(mail.getRemoteAddr()); + jpaMail.setPerRecipientHeaders(serializePerRecipientHeaders(mail.getPerRecipientSpecificHeaders())); + jpaMail.setLastUpdated(new Timestamp(mail.getLastUpdated().getTime())); + jpaMail.setMessageBody(getBody(mail)); + jpaMail.setMessageAttributes(serializeAttributes(mail.attributes())); + EntityTransaction transaction = entityManager.getTransaction(); + transaction.begin(); + jpaMail = entityManager.merge(jpaMail); + transaction.commit(); + + AuditTrail.entry() + .protocol("mailrepository") + .action("store") + .parameters(Throwing.supplier(() -> ImmutableMap.of("mailId", mail.getName(), + "mimeMessageId", Optional.ofNullable(mail.getMessage()) + .map(Throwing.function(MimeMessage::getMessageID)) + .orElse(""), + "sender", mail.getMaybeSender().asString(), + "recipients", StringUtils.join(mail.getRecipients())))) + .log("JPAMailRepository stored mail."); + + return key; + } catch (MessagingException e) { + LOGGER.error("Exception caught while storing mail {}", key, e); + throw e; + } catch (Exception e) { + LOGGER.error("Exception caught while storing mail {}", key, e); + throw new MessagingException("Exception caught while storing mail " + key, e); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + private byte[] getBody(Mail mail) throws MessagingException, IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream((int)mail.getMessageSize()); + if (mail instanceof MimeMessageWrapper) { + // we need to force the loading of the message from the + // stream as we want to override the old message + ((MimeMessageWrapper) mail).loadMessage(); + ((MimeMessageWrapper) mail).writeTo(out, out, null, true); + } else { + mail.getMessage().writeTo(out); + } + return out.toByteArray(); + } + + private String serializeAttributes(Stream attributes) { + Map map = attributes + .flatMap(entry -> entry.getValue().toJson().map(value -> Pair.of(entry.getName().asString(), value)).stream()) + .collect(ImmutableMap.toImmutableMap(Pair::getKey, Pair::getValue)); + + return new ObjectNode(JsonNodeFactory.instance, map).toString(); + } + + private List deserializeAttributes(String data) { + try { + JsonNode jsonNode = OBJECT_MAPPER.readTree(data); + if (jsonNode instanceof ObjectNode) { + ObjectNode objectNode = (ObjectNode) jsonNode; + + return Iterators.toStream(objectNode.fields()) + .map(entry -> new Attribute(AttributeName.of(entry.getKey()), AttributeValue.fromJson(entry.getValue()))) + .collect(ImmutableList.toImmutableList()); + } + throw new IllegalArgumentException("JSON object corresponding to mail attibutes must be a JSON object"); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("Mail attributes is not a valid JSON object", e); + } + } + + private String serializePerRecipientHeaders(PerRecipientHeaders perRecipientHeaders) { + if (perRecipientHeaders == null) { + return null; + } + Map> map = perRecipientHeaders.getHeadersByRecipient().asMap(); + if (map.isEmpty()) { + return null; + } + ObjectNode node = JsonNodeFactory.instance.objectNode(); + for (Map.Entry> entry : map.entrySet()) { + String recipient = entry.getKey().asString(); + ObjectNode headers = node.putObject(recipient); + entry.getValue().forEach(header -> headers.put(header.getName(), header.getValue())); + } + return node.toString(); + } + + private PerRecipientHeaders deserializePerRecipientHeaders(String data) { + if (data == null || data.isEmpty()) { + return null; + } + PerRecipientHeaders perRecipientHeaders = new PerRecipientHeaders(); + try { + JsonNode node = OBJECT_MAPPER.readTree(data); + if (node instanceof ObjectNode) { + ObjectNode objectNode = (ObjectNode) node; + Iterators.toStream(objectNode.fields()).forEach( + entry -> addPerRecipientHeaders(perRecipientHeaders, entry.getKey(), entry.getValue())); + return perRecipientHeaders; + } + throw new IllegalArgumentException("JSON object corresponding to recipient headers must be a JSON object"); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("per recipient headers is not a valid JSON object", e); + } + } + + private void addPerRecipientHeaders(PerRecipientHeaders perRecipientHeaders, String recipient, JsonNode headers) { + try { + MailAddress address = new MailAddress(recipient); + Iterators.toStream(headers.fields()).forEach( + entry -> { + String name = entry.getKey(); + String value = entry.getValue().textValue(); + Header header = Header.builder().name(name).value(value).build(); + perRecipientHeaders.addHeaderForRecipient(header, address); + }); + } catch (AddressException ae) { + throw new IllegalArgumentException("invalid recipient address", ae); + } + } + + @Override + public Mail retrieve(MailKey key) throws MessagingException { + EntityManager entityManager = entityManager(); + try { + JPAMail jpaMail = entityManager.createNamedQuery("findMailMessage", JPAMail.class) + .setParameter("repositoryName", repositoryName) + .setParameter("messageName", key.asString()) + .getSingleResult(); + + MailImpl.Builder mail = MailImpl.builder().name(key.asString()); + if (jpaMail.getMessageAttributes() != null) { + mail.addAttributes(deserializeAttributes(jpaMail.getMessageAttributes())); + } + mail.state(jpaMail.getMessageState()); + mail.errorMessage(jpaMail.getErrorMessage()); + String sender = jpaMail.getSender(); + if (sender == null) { + mail.sender((MailAddress)null); + } else { + mail.sender(new MailAddress(sender)); + } + StringTokenizer st = new StringTokenizer(jpaMail.getRecipients(), "\r\n", false); + while (st.hasMoreTokens()) { + mail.addRecipient(st.nextToken()); + } + mail.remoteHost(jpaMail.getRemoteHost()); + mail.remoteAddr(jpaMail.getRemoteAddr()); + PerRecipientHeaders perRecipientHeaders = deserializePerRecipientHeaders(jpaMail.getPerRecipientHeaders()); + if (perRecipientHeaders != null) { + mail.addAllHeadersForRecipients(perRecipientHeaders); + } + mail.lastUpdated(jpaMail.getLastUpdated()); + + MimeMessageJPASource source = new MimeMessageJPASource(this, key.asString(), jpaMail.getMessageBody()); + MimeMessageWrapper message = new MimeMessageWrapper(source); + mail.mimeMessage(message); + return mail.build(); + } catch (NoResultException nre) { + LOGGER.debug("Did not find mail {} in repository {}", key, repositoryName); + return null; + } catch (Exception e) { + throw new MessagingException("Exception while retrieving mail: " + e.getMessage(), e); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + @Override + public long size() throws MessagingException { + EntityManager entityManager = entityManager(); + try { + return entityManager.createNamedQuery("countMailMessages", long.class) + .setParameter("repositoryName", repositoryName) + .getSingleResult(); + } catch (Exception me) { + throw new MessagingException("Exception while listing messages: " + me.getMessage(), me); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + @Override + public Iterator list() throws MessagingException { + EntityManager entityManager = entityManager(); + try { + return entityManager.createNamedQuery("listMailMessages", String.class) + .setParameter("repositoryName", repositoryName) + .getResultStream() + .map(MailKey::new) + .iterator(); + } catch (Exception me) { + throw new MessagingException("Exception while listing messages: " + me.getMessage(), me); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + @Override + public void remove(MailKey key) throws MessagingException { + remove(Collections.singleton(key)); + } + + @Override + public void remove(Collection keys) throws MessagingException { + Collection messageNames = keys.stream().map(MailKey::asString).collect(Collectors.toList()); + EntityManager entityManager = entityManager(); + EntityTransaction transaction = entityManager.getTransaction(); + transaction.begin(); + try { + entityManager.createNamedQuery("deleteMailMessages") + .setParameter("repositoryName", repositoryName) + .setParameter("messageNames", messageNames) + .executeUpdate(); + transaction.commit(); + } catch (Exception e) { + throw new MessagingException("Exception while removing message(s): " + e.getMessage(), e); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + @Override + public void removeAll() throws MessagingException { + EntityManager entityManager = entityManager(); + EntityTransaction transaction = entityManager.getTransaction(); + transaction.begin(); + try { + entityManager.createNamedQuery("deleteAllMailMessages") + .setParameter("repositoryName", repositoryName) + .executeUpdate(); + transaction.commit(); + } catch (Exception e) { + throw new MessagingException("Exception while removing message(s): " + e.getMessage(), e); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + @Override + public boolean equals(Object obj) { + return obj instanceof JPAMailRepository + && Objects.equals(repositoryName, ((JPAMailRepository)obj).repositoryName); + } + + @Override + public int hashCode() { + return Objects.hash(repositoryName); + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryFactory.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryFactory.java new file mode 100644 index 00000000000..09bb004ef88 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryFactory.java @@ -0,0 +1,52 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailrepository.jpa; + +import javax.inject.Inject; +import javax.persistence.EntityManagerFactory; + +import org.apache.james.mailrepository.api.MailRepository; +import org.apache.james.mailrepository.api.MailRepositoryFactory; +import org.apache.james.mailrepository.api.MailRepositoryUrl; + +import com.github.fge.lambdas.Throwing; + +public class JPAMailRepositoryFactory implements MailRepositoryFactory { + private final EntityManagerFactory entityManagerFactory; + + @Inject + public JPAMailRepositoryFactory(EntityManagerFactory entityManagerFactory) { + this.entityManagerFactory = entityManagerFactory; + } + + @Override + public Class mailRepositoryClass() { + return JPAMailRepository.class; + } + + @Override + public MailRepository create(MailRepositoryUrl url) { + // Injecting the url here is redundant since the class is also a + // Configurable and the mail repository store will call #configure() + // with the same effect. However, this paves the way to drop the + // Configurable aspect in the future. + return Throwing.supplier(() -> new JPAMailRepository(entityManagerFactory, url)).sneakyThrow().get(); + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryUrlStore.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryUrlStore.java new file mode 100644 index 00000000000..1f448a9eca7 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryUrlStore.java @@ -0,0 +1,65 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailrepository.jpa; + +import java.util.stream.Stream; + +import javax.inject.Inject; +import javax.persistence.EntityManagerFactory; + +import org.apache.james.backends.jpa.TransactionRunner; +import org.apache.james.mailrepository.api.MailRepositoryUrl; +import org.apache.james.mailrepository.api.MailRepositoryUrlStore; +import org.apache.james.mailrepository.jpa.model.JPAUrl; + +public class JPAMailRepositoryUrlStore implements MailRepositoryUrlStore { + private final TransactionRunner transactionRunner; + + @Inject + public JPAMailRepositoryUrlStore(EntityManagerFactory entityManagerFactory) { + this.transactionRunner = new TransactionRunner(entityManagerFactory); + } + + @Override + public void add(MailRepositoryUrl url) { + transactionRunner.run(entityManager -> + entityManager.merge(JPAUrl.from(url))); + } + + @Override + public Stream listDistinct() { + return transactionRunner.runAndRetrieveResult(entityManager -> + entityManager + .createNamedQuery("listUrls", JPAUrl.class) + .getResultList() + .stream() + .map(JPAUrl::toMailRepositoryUrl)); + } + + @Override + public boolean contains(MailRepositoryUrl url) { + return transactionRunner.runAndRetrieveResult(entityManager -> + ! entityManager.createNamedQuery("getUrl", JPAUrl.class) + .setParameter("value", url.asString()) + .getResultList() + .isEmpty()); + } +} + diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/MimeMessageJPASource.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/MimeMessageJPASource.java new file mode 100644 index 00000000000..f5445c279c5 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/MimeMessageJPASource.java @@ -0,0 +1,54 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailrepository.jpa; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + +import org.apache.james.server.core.MimeMessageSource; + +public class MimeMessageJPASource implements MimeMessageSource { + + private final JPAMailRepository jpaMailRepository; + private final String key; + private final byte[] body; + + public MimeMessageJPASource(JPAMailRepository jpaMailRepository, String key, byte[] body) { + this.jpaMailRepository = jpaMailRepository; + this.key = key; + this.body = body; + } + + @Override + public String getSourceId() { + return jpaMailRepository.getRepositoryName() + "/" + key; + } + + @Override + public InputStream getInputStream() throws IOException { + return new ByteArrayInputStream(body); + } + + @Override + public long getMessageSize() throws IOException { + return body.length; + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/model/JPAMail.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/model/JPAMail.java new file mode 100644 index 00000000000..187241dfcb8 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/model/JPAMail.java @@ -0,0 +1,246 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailrepository.jpa.model; + +import java.io.Serializable; +import java.sql.Timestamp; +import java.util.Objects; + +import javax.persistence.Basic; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.Id; +import javax.persistence.IdClass; +import javax.persistence.Index; +import javax.persistence.Lob; +import javax.persistence.NamedQueries; +import javax.persistence.NamedQuery; +import javax.persistence.Table; + +@Entity(name = "JamesMailStore") +@IdClass(JPAMail.JPAMailId.class) +@Table(name = "JAMES_MAIL_STORE", indexes = { + @Index(name = "REPOSITORY_NAME_MESSAGE_NAME_INDEX", columnList = "REPOSITORY_NAME, MESSAGE_NAME") +}) +@NamedQueries({ + @NamedQuery(name = "listMailMessages", + query = "SELECT mail.messageName FROM JamesMailStore mail WHERE mail.repositoryName = :repositoryName"), + @NamedQuery(name = "countMailMessages", + query = "SELECT COUNT(mail) FROM JamesMailStore mail WHERE mail.repositoryName = :repositoryName"), + @NamedQuery(name = "deleteMailMessages", + query = "DELETE FROM JamesMailStore mail WHERE mail.repositoryName = :repositoryName AND mail.messageName IN (:messageNames)"), + @NamedQuery(name = "deleteAllMailMessages", + query = "DELETE FROM JamesMailStore mail WHERE mail.repositoryName = :repositoryName"), + @NamedQuery(name = "findMailMessage", + query = "SELECT mail FROM JamesMailStore mail WHERE mail.repositoryName = :repositoryName AND mail.messageName = :messageName") +}) +public class JPAMail { + + static class JPAMailId implements Serializable { + public JPAMailId() { + } + + String repositoryName; + String messageName; + + public boolean equals(Object obj) { + return obj instanceof JPAMailId + && Objects.equals(messageName, ((JPAMailId) obj).messageName) + && Objects.equals(repositoryName, ((JPAMailId) obj).repositoryName); + } + + public int hashCode() { + return Objects.hash(messageName, repositoryName); + } + } + + @Id + @Basic(optional = false) + @Column(name = "REPOSITORY_NAME", nullable = false, length = 255) + private String repositoryName; + + @Id + @Basic(optional = false) + @Column(name = "MESSAGE_NAME", nullable = false, length = 200) + private String messageName; + + @Basic(optional = false) + @Column(name = "MESSAGE_STATE", nullable = false, length = 30) + private String messageState; + + @Basic(optional = true) + @Column(name = "ERROR_MESSAGE", nullable = true, length = 200) + private String errorMessage; + + @Basic(optional = true) + @Column(name = "SENDER", nullable = true, length = 255) + private String sender; + + @Basic(optional = false) + @Column(name = "RECIPIENTS", nullable = false) + private String recipients; // CRLF delimited + + @Basic(optional = false) + @Column(name = "REMOTE_HOST", nullable = false, length = 255) + private String remoteHost; + + @Basic(optional = false) + @Column(name = "REMOTE_ADDR", nullable = false, length = 20) + private String remoteAddr; + + @Basic(optional = false) + @Column(name = "LAST_UPDATED", nullable = false) + private Timestamp lastUpdated; + + @Basic(optional = true) + @Column(name = "PER_RECIPIENT_HEADERS", nullable = true, length = 10485760) + @Lob + private String perRecipientHeaders; + + @Basic(optional = false, fetch = FetchType.LAZY) + @Column(name = "MESSAGE_BODY", nullable = false, length = 1048576000) + @Lob + private byte[] messageBody; // TODO: support streaming body where possible (see e.g. JPAStreamingMailboxMessage) + + @Basic(optional = true) + @Column(name = "MESSAGE_ATTRIBUTES", nullable = true, length = 10485760) + @Lob + private String messageAttributes; + + public JPAMail() { + } + + public String getRepositoryName() { + return repositoryName; + } + + public void setRepositoryName(String repositoryName) { + this.repositoryName = repositoryName; + } + + public String getMessageName() { + return messageName; + } + + public void setMessageName(String messageName) { + this.messageName = messageName; + } + + public String getMessageState() { + return messageState; + } + + public void setMessageState(String messageState) { + this.messageState = messageState; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public String getSender() { + return sender; + } + + public void setSender(String sender) { + this.sender = sender; + } + + public String getRecipients() { + return recipients; + } + + public void setRecipients(String recipients) { + this.recipients = recipients; + } + + public String getRemoteHost() { + return remoteHost; + } + + public void setRemoteHost(String remoteHost) { + this.remoteHost = remoteHost; + } + + public String getRemoteAddr() { + return remoteAddr; + } + + public void setRemoteAddr(String remoteAddr) { + this.remoteAddr = remoteAddr; + } + + public Timestamp getLastUpdated() { + return lastUpdated; + } + + public void setLastUpdated(Timestamp lastUpdated) { + this.lastUpdated = lastUpdated; + } + + public String getPerRecipientHeaders() { + return perRecipientHeaders; + } + + public void setPerRecipientHeaders(String perRecipientHeaders) { + this.perRecipientHeaders = perRecipientHeaders; + } + + public byte[] getMessageBody() { + return messageBody; + } + + public void setMessageBody(byte[] messageBody) { + this.messageBody = messageBody; + } + + public String getMessageAttributes() { + return messageAttributes; + } + + public void setMessageAttributes(String messageAttributes) { + this.messageAttributes = messageAttributes; + } + + @Override + public String toString() { + return "JPAMail ( " + + "repositoryName = " + repositoryName + + ", messageName = " + messageName + + " )"; + } + + @Override + public final boolean equals(Object obj) { + return obj instanceof JPAMail + && Objects.equals(this.repositoryName, ((JPAMail)obj).repositoryName) + && Objects.equals(this.messageName, ((JPAMail)obj).messageName); + } + + @Override + public final int hashCode() { + return Objects.hash(repositoryName, messageName); + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/model/JPAUrl.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/model/JPAUrl.java new file mode 100644 index 00000000000..9f8e74c69cd --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/model/JPAUrl.java @@ -0,0 +1,65 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailrepository.jpa.model; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.NamedQueries; +import javax.persistence.NamedQuery; +import javax.persistence.Table; + +import org.apache.james.mailrepository.api.MailRepositoryUrl; + +@Entity(name = "JamesMailRepos") +@Table(name = "JAMES_MAIL_REPOS") +@NamedQueries({ + @NamedQuery(name = "listUrls", query = "SELECT url FROM JamesMailRepos url"), + @NamedQuery(name = "getUrl", query = "SELECT url FROM JamesMailRepos url WHERE url.value=:value")}) +public class JPAUrl { + public static JPAUrl from(MailRepositoryUrl url) { + return new JPAUrl(url.asString()); + } + + @Id + @Column(name = "MAIL_REPO_NAME", nullable = false) + private String value; + + /** + * Default no-args constructor for JPA class enhancement. + * The constructor need to be public or protected to be used by JPA. + * See: http://docs.oracle.com/javaee/6/tutorial/doc/bnbqa.html + * Do not us this constructor, it is for JPA only. + */ + protected JPAUrl() { + } + + public JPAUrl(String value) { + this.value = value; + } + + public String getValue() { + return value; + } + + public MailRepositoryUrl toMailRepositoryUrl() { + return MailRepositoryUrl.from(value); + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/rrt/jpa/JPARecipientRewriteTable.java b/server/data/data-postgres/src/main/java/org/apache/james/rrt/jpa/JPARecipientRewriteTable.java new file mode 100644 index 00000000000..1d33448a54e --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/rrt/jpa/JPARecipientRewriteTable.java @@ -0,0 +1,251 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.rrt.jpa; + +import static org.apache.james.rrt.jpa.model.JPARecipientRewrite.DELETE_MAPPING_QUERY; +import static org.apache.james.rrt.jpa.model.JPARecipientRewrite.SELECT_ALL_MAPPINGS_QUERY; +import static org.apache.james.rrt.jpa.model.JPARecipientRewrite.SELECT_SOURCES_BY_MAPPING_QUERY; +import static org.apache.james.rrt.jpa.model.JPARecipientRewrite.SELECT_USER_DOMAIN_MAPPING_QUERY; +import static org.apache.james.rrt.jpa.model.JPARecipientRewrite.UPDATE_MAPPING_QUERY; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import javax.inject.Inject; +import javax.persistence.EntityManager; +import javax.persistence.EntityManagerFactory; +import javax.persistence.EntityTransaction; +import javax.persistence.PersistenceException; +import javax.persistence.PersistenceUnit; + +import org.apache.james.backends.jpa.EntityManagerUtils; +import org.apache.james.core.Domain; +import org.apache.james.rrt.api.RecipientRewriteTableException; +import org.apache.james.rrt.jpa.model.JPARecipientRewrite; +import org.apache.james.rrt.lib.AbstractRecipientRewriteTable; +import org.apache.james.rrt.lib.Mapping; +import org.apache.james.rrt.lib.MappingSource; +import org.apache.james.rrt.lib.Mappings; +import org.apache.james.rrt.lib.MappingsImpl; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.base.Preconditions; + +/** + * Class responsible to implement the Virtual User Table in database with JPA + * access. + */ +public class JPARecipientRewriteTable extends AbstractRecipientRewriteTable { + private static final Logger LOGGER = LoggerFactory.getLogger(JPARecipientRewriteTable.class); + + /** + * The entity manager to access the database. + */ + private EntityManagerFactory entityManagerFactory; + + /** + * Set the entity manager to use. + */ + @Inject + @PersistenceUnit(unitName = "James") + public void setEntityManagerFactory(EntityManagerFactory entityManagerFactory) { + this.entityManagerFactory = entityManagerFactory; + } + + @Override + public void addMapping(MappingSource source, Mapping mapping) throws RecipientRewriteTableException { + Mappings map = getStoredMappings(source); + if (!map.isEmpty()) { + Mappings updatedMappings = MappingsImpl.from(map).add(mapping).build(); + doUpdateMapping(source, updatedMappings.serialize()); + } else { + doAddMapping(source, mapping.asString()); + } + } + + @Override + protected Mappings mapAddress(String user, Domain domain) throws RecipientRewriteTableException { + Mappings userDomainMapping = getStoredMappings(MappingSource.fromUser(user, domain)); + if (userDomainMapping != null && !userDomainMapping.isEmpty()) { + return userDomainMapping; + } + Mappings domainMapping = getStoredMappings(MappingSource.fromDomain(domain)); + if (domainMapping != null && !domainMapping.isEmpty()) { + return domainMapping; + } + return MappingsImpl.empty(); + } + + @Override + public Mappings getStoredMappings(MappingSource source) throws RecipientRewriteTableException { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + try { + @SuppressWarnings("unchecked") + List virtualUsers = entityManager.createNamedQuery(SELECT_USER_DOMAIN_MAPPING_QUERY) + .setParameter("user", source.getFixedUser()) + .setParameter("domain", source.getFixedDomain()) + .getResultList(); + if (virtualUsers.size() > 0) { + return MappingsImpl.fromRawString(virtualUsers.get(0).getTargetAddress()); + } + return MappingsImpl.empty(); + } catch (PersistenceException e) { + LOGGER.debug("Failed to get user domain mappings", e); + throw new RecipientRewriteTableException("Error while retrieve mappings", e); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + @Override + public Map getAllMappings() throws RecipientRewriteTableException { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + Map mapping = new HashMap<>(); + try { + @SuppressWarnings("unchecked") + List virtualUsers = entityManager.createNamedQuery(SELECT_ALL_MAPPINGS_QUERY).getResultList(); + for (JPARecipientRewrite virtualUser : virtualUsers) { + mapping.put(MappingSource.fromUser(virtualUser.getUser(), virtualUser.getDomain()), MappingsImpl.fromRawString(virtualUser.getTargetAddress())); + } + return mapping; + } catch (PersistenceException e) { + LOGGER.debug("Failed to get all mappings", e); + throw new RecipientRewriteTableException("Error while retrieve mappings", e); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + @Override + public Stream listSources(Mapping mapping) throws RecipientRewriteTableException { + Preconditions.checkArgument(listSourcesSupportedType.contains(mapping.getType()), + "Not supported mapping of type %s", mapping.getType()); + + EntityManager entityManager = entityManagerFactory.createEntityManager(); + try { + return entityManager.createNamedQuery(SELECT_SOURCES_BY_MAPPING_QUERY, JPARecipientRewrite.class) + .setParameter("targetAddress", mapping.asString()) + .getResultList() + .stream() + .map(user -> MappingSource.fromUser(user.getUser(), user.getDomain())); + } catch (PersistenceException e) { + String error = "Unable to list sources by mapping"; + LOGGER.debug(error, e); + throw new RecipientRewriteTableException(error, e); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + @Override + public void removeMapping(MappingSource source, Mapping mapping) throws RecipientRewriteTableException { + Mappings map = getStoredMappings(source); + if (map.size() > 1) { + Mappings updatedMappings = map.remove(mapping); + doUpdateMapping(source, updatedMappings.serialize()); + } else { + doRemoveMapping(source, mapping.asString()); + } + } + + /** + * Update the mapping for the given user and domain + * + * @return true if update was successfully + */ + private boolean doUpdateMapping(MappingSource source, String mapping) throws RecipientRewriteTableException { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + final EntityTransaction transaction = entityManager.getTransaction(); + try { + transaction.begin(); + int updated = entityManager + .createNamedQuery(UPDATE_MAPPING_QUERY) + .setParameter("targetAddress", mapping) + .setParameter("user", source.getFixedUser()) + .setParameter("domain", source.getFixedDomain()) + .executeUpdate(); + transaction.commit(); + if (updated > 0) { + return true; + } + } catch (PersistenceException e) { + LOGGER.debug("Failed to update mapping", e); + if (transaction.isActive()) { + transaction.rollback(); + } + throw new RecipientRewriteTableException("Unable to update mapping", e); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + return false; + } + + /** + * Remove a mapping for the given user and domain + */ + private void doRemoveMapping(MappingSource source, String mapping) throws RecipientRewriteTableException { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + final EntityTransaction transaction = entityManager.getTransaction(); + try { + transaction.begin(); + entityManager.createNamedQuery(DELETE_MAPPING_QUERY) + .setParameter("user", source.getFixedUser()) + .setParameter("domain", source.getFixedDomain()) + .setParameter("targetAddress", mapping) + .executeUpdate(); + transaction.commit(); + + } catch (PersistenceException e) { + LOGGER.debug("Failed to remove mapping", e); + if (transaction.isActive()) { + transaction.rollback(); + } + throw new RecipientRewriteTableException("Unable to remove mapping", e); + + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + /** + * Add mapping for given user and domain + */ + private void doAddMapping(MappingSource source, String mapping) throws RecipientRewriteTableException { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + final EntityTransaction transaction = entityManager.getTransaction(); + try { + transaction.begin(); + JPARecipientRewrite jpaRecipientRewrite = new JPARecipientRewrite(source.getFixedUser(), Domain.of(source.getFixedDomain()), mapping); + entityManager.persist(jpaRecipientRewrite); + transaction.commit(); + } catch (PersistenceException e) { + LOGGER.debug("Failed to save virtual user", e); + if (transaction.isActive()) { + transaction.rollback(); + } + throw new RecipientRewriteTableException("Unable to add mapping", e); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/rrt/jpa/model/JPARecipientRewrite.java b/server/data/data-postgres/src/main/java/org/apache/james/rrt/jpa/model/JPARecipientRewrite.java new file mode 100644 index 00000000000..47402762c02 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/rrt/jpa/model/JPARecipientRewrite.java @@ -0,0 +1,147 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.rrt.jpa.model; + +import static org.apache.james.rrt.jpa.model.JPARecipientRewrite.DELETE_MAPPING_QUERY; +import static org.apache.james.rrt.jpa.model.JPARecipientRewrite.SELECT_ALL_MAPPINGS_QUERY; +import static org.apache.james.rrt.jpa.model.JPARecipientRewrite.SELECT_SOURCES_BY_MAPPING_QUERY; +import static org.apache.james.rrt.jpa.model.JPARecipientRewrite.SELECT_USER_DOMAIN_MAPPING_QUERY; +import static org.apache.james.rrt.jpa.model.JPARecipientRewrite.UPDATE_MAPPING_QUERY; + +import java.io.Serializable; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.IdClass; +import javax.persistence.NamedQueries; +import javax.persistence.NamedQuery; +import javax.persistence.Table; + +import org.apache.james.core.Domain; + +import com.google.common.base.Objects; + +/** + * RecipientRewriteTable class for the James Virtual User Table to be used for JPA + * persistence. + */ +@Entity(name = "JamesRecipientRewrite") +@Table(name = JPARecipientRewrite.JAMES_RECIPIENT_REWRITE) +@NamedQueries({ + @NamedQuery(name = SELECT_USER_DOMAIN_MAPPING_QUERY, query = "SELECT rrt FROM JamesRecipientRewrite rrt WHERE rrt.user=:user AND rrt.domain=:domain"), + @NamedQuery(name = SELECT_ALL_MAPPINGS_QUERY, query = "SELECT rrt FROM JamesRecipientRewrite rrt"), + @NamedQuery(name = DELETE_MAPPING_QUERY, query = "DELETE FROM JamesRecipientRewrite rrt WHERE rrt.user=:user AND rrt.domain=:domain AND rrt.targetAddress=:targetAddress"), + @NamedQuery(name = UPDATE_MAPPING_QUERY, query = "UPDATE JamesRecipientRewrite rrt SET rrt.targetAddress=:targetAddress WHERE rrt.user=:user AND rrt.domain=:domain"), + @NamedQuery(name = SELECT_SOURCES_BY_MAPPING_QUERY, query = "SELECT rrt FROM JamesRecipientRewrite rrt WHERE rrt.targetAddress=:targetAddress")}) +@IdClass(JPARecipientRewrite.RecipientRewriteTableId.class) +public class JPARecipientRewrite { + public static final String SELECT_USER_DOMAIN_MAPPING_QUERY = "selectUserDomainMapping"; + public static final String SELECT_ALL_MAPPINGS_QUERY = "selectAllMappings"; + public static final String DELETE_MAPPING_QUERY = "deleteMapping"; + public static final String UPDATE_MAPPING_QUERY = "updateMapping"; + public static final String SELECT_SOURCES_BY_MAPPING_QUERY = "selectSourcesByMapping"; + + public static final String JAMES_RECIPIENT_REWRITE = "JAMES_RECIPIENT_REWRITE"; + + public static class RecipientRewriteTableId implements Serializable { + + private static final long serialVersionUID = 1L; + + private String user; + + private String domain; + + public RecipientRewriteTableId() { + } + + @Override + public int hashCode() { + return Objects.hashCode(user, domain); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + final RecipientRewriteTableId other = (RecipientRewriteTableId) obj; + return Objects.equal(this.user, other.user) && Objects.equal(this.domain, other.domain); + } + } + + /** + * The name of the user. + */ + @Id + @Column(name = "USER_NAME", nullable = false, length = 100) + private String user = ""; + + /** + * The name of the domain. Column name is chosen to be compatible with the + * JDBCRecipientRewriteTableList. + */ + @Id + @Column(name = "DOMAIN_NAME", nullable = false, length = 100) + private String domain = ""; + + /** + * The target address. column name is chosen to be compatible with the + * JDBCRecipientRewriteTableList. + */ + @Column(name = "TARGET_ADDRESS", nullable = false, length = 100) + private String targetAddress = ""; + + /** + * Default no-args constructor for JPA class enhancement. + * The constructor need to be public or protected to be used by JPA. + * See: http://docs.oracle.com/javaee/6/tutorial/doc/bnbqa.html + * Do not us this constructor, it is for JPA only. + */ + protected JPARecipientRewrite() { + } + + /** + * Use this simple constructor to create a new RecipientRewriteTable. + * + * @param user + * , domain and their associated targetAddress + */ + public JPARecipientRewrite(String user, Domain domain, String targetAddress) { + this.user = user; + this.domain = domain.asString(); + this.targetAddress = targetAddress; + } + + public String getUser() { + return user; + } + + public String getDomain() { + return domain; + } + + public String getTargetAddress() { + return targetAddress; + } + +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/jpa/JPASieveRepository.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/jpa/JPASieveRepository.java new file mode 100644 index 00000000000..53c96fc2637 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/sieve/jpa/JPASieveRepository.java @@ -0,0 +1,363 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.sieve.jpa; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Function; + +import javax.inject.Inject; +import javax.persistence.EntityManager; +import javax.persistence.EntityManagerFactory; +import javax.persistence.EntityTransaction; +import javax.persistence.NoResultException; +import javax.persistence.PersistenceException; + +import org.apache.commons.io.IOUtils; +import org.apache.james.backends.jpa.TransactionRunner; +import org.apache.james.core.Username; +import org.apache.james.core.quota.QuotaSizeLimit; +import org.apache.james.core.quota.QuotaSizeUsage; +import org.apache.james.sieve.jpa.model.JPASieveQuota; +import org.apache.james.sieve.jpa.model.JPASieveScript; +import org.apache.james.sieverepository.api.ScriptContent; +import org.apache.james.sieverepository.api.ScriptName; +import org.apache.james.sieverepository.api.ScriptSummary; +import org.apache.james.sieverepository.api.SieveRepository; +import org.apache.james.sieverepository.api.exception.DuplicateException; +import org.apache.james.sieverepository.api.exception.IsActiveException; +import org.apache.james.sieverepository.api.exception.QuotaExceededException; +import org.apache.james.sieverepository.api.exception.QuotaNotFoundException; +import org.apache.james.sieverepository.api.exception.ScriptNotFoundException; +import org.apache.james.sieverepository.api.exception.StorageException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.github.fge.lambdas.Throwing; +import com.google.common.collect.ImmutableList; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class JPASieveRepository implements SieveRepository { + + private static final Logger LOGGER = LoggerFactory.getLogger(JPASieveRepository.class); + private static final String DEFAULT_SIEVE_QUOTA_USERNAME = "default.quota"; + + private final TransactionRunner transactionRunner; + + @Inject + public JPASieveRepository(EntityManagerFactory entityManagerFactory) { + this.transactionRunner = new TransactionRunner(entityManagerFactory); + } + + @Override + public void haveSpace(Username username, ScriptName name, long size) throws QuotaExceededException, StorageException { + long usedSpace = findAllSieveScriptsForUser(username).stream() + .filter(sieveScript -> !sieveScript.getScriptName().equals(name.getValue())) + .mapToLong(JPASieveScript::getScriptSize) + .sum(); + + QuotaSizeLimit quota = limitToUser(username); + if (overQuotaAfterModification(usedSpace, size, quota)) { + throw new QuotaExceededException(); + } + } + + private QuotaSizeLimit limitToUser(Username username) throws StorageException { + return findQuotaForUser(username.asString()) + .or(Throwing.supplier(() -> findQuotaForUser(DEFAULT_SIEVE_QUOTA_USERNAME)).sneakyThrow()) + .map(JPASieveQuota::toQuotaSize) + .orElse(QuotaSizeLimit.unlimited()); + } + + private boolean overQuotaAfterModification(long usedSpace, long size, QuotaSizeLimit quota) { + return QuotaSizeUsage.size(usedSpace) + .add(size) + .exceedLimit(quota); + } + + @Override + public void putScript(Username username, ScriptName name, ScriptContent content) throws StorageException, QuotaExceededException { + transactionRunner.runAndHandleException(Throwing.consumer(entityManager -> { + try { + haveSpace(username, name, content.length()); + JPASieveScript jpaSieveScript = JPASieveScript.builder() + .username(username.asString()) + .scriptName(name.getValue()) + .scriptContent(content) + .build(); + entityManager.persist(jpaSieveScript); + } catch (QuotaExceededException | StorageException e) { + rollbackTransactionIfActive(entityManager.getTransaction()); + throw e; + } + }).sneakyThrow(), throwStorageExceptionConsumer("Unable to put script for user " + username.asString())); + } + + @Override + public List listScripts(Username username) throws StorageException { + return findAllSieveScriptsForUser(username).stream() + .map(JPASieveScript::toSummary) + .collect(ImmutableList.toImmutableList()); + } + + @Override + public Flux listScriptsReactive(Username username) { + return Mono.fromCallable(() -> listScripts(username)).flatMapMany(Flux::fromIterable); + } + + private List findAllSieveScriptsForUser(Username username) throws StorageException { + return transactionRunner.runAndRetrieveResult(entityManager -> { + List sieveScripts = entityManager.createNamedQuery("findAllByUsername", JPASieveScript.class) + .setParameter("username", username.asString()).getResultList(); + return Optional.ofNullable(sieveScripts).orElse(ImmutableList.of()); + }, throwStorageException("Unable to list scripts for user " + username.asString())); + } + + @Override + public ZonedDateTime getActivationDateForActiveScript(Username username) throws StorageException, ScriptNotFoundException { + Optional script = findActiveSieveScript(username); + JPASieveScript activeSieveScript = script.orElseThrow(() -> new ScriptNotFoundException("Unable to find active script for user " + username.asString())); + return activeSieveScript.getActivationDateTime().toZonedDateTime(); + } + + @Override + public InputStream getActive(Username username) throws ScriptNotFoundException, StorageException { + Optional script = findActiveSieveScript(username); + JPASieveScript activeSieveScript = script.orElseThrow(() -> new ScriptNotFoundException("Unable to find active script for user " + username.asString())); + return IOUtils.toInputStream(activeSieveScript.getScriptContent(), StandardCharsets.UTF_8); + } + + private Optional findActiveSieveScript(Username username) throws StorageException { + return transactionRunner.runAndRetrieveResult( + Throwing.>function(entityManager -> findActiveSieveScript(username, entityManager)).sneakyThrow(), + throwStorageException("Unable to find active script for user " + username.asString())); + } + + private Optional findActiveSieveScript(Username username, EntityManager entityManager) throws StorageException { + try { + JPASieveScript activeSieveScript = entityManager.createNamedQuery("findActiveByUsername", JPASieveScript.class) + .setParameter("username", username.asString()).getSingleResult(); + return Optional.ofNullable(activeSieveScript); + } catch (NoResultException e) { + LOGGER.debug("Sieve script not found for user {}", username.asString()); + return Optional.empty(); + } + } + + @Override + public void setActive(Username username, ScriptName name) throws ScriptNotFoundException, StorageException { + transactionRunner.runAndHandleException(Throwing.consumer(entityManager -> { + try { + if (SieveRepository.NO_SCRIPT_NAME.equals(name)) { + switchOffActiveScript(username, entityManager); + } else { + setActiveScript(username, name, entityManager); + } + } catch (StorageException | ScriptNotFoundException e) { + rollbackTransactionIfActive(entityManager.getTransaction()); + throw e; + } + }).sneakyThrow(), throwStorageExceptionConsumer("Unable to set active script " + name.getValue() + " for user " + username.asString())); + } + + private void switchOffActiveScript(Username username, EntityManager entityManager) throws StorageException { + Optional activeSieveScript = findActiveSieveScript(username, entityManager); + activeSieveScript.ifPresent(JPASieveScript::deactivate); + } + + private void setActiveScript(Username username, ScriptName name, EntityManager entityManager) throws StorageException, ScriptNotFoundException { + JPASieveScript sieveScript = findSieveScript(username, name, entityManager) + .orElseThrow(() -> new ScriptNotFoundException("Unable to find script " + name.getValue() + " for user " + username.asString())); + findActiveSieveScript(username, entityManager).ifPresent(JPASieveScript::deactivate); + sieveScript.activate(); + } + + @Override + public InputStream getScript(Username username, ScriptName name) throws ScriptNotFoundException, StorageException { + Optional script = findSieveScript(username, name); + JPASieveScript sieveScript = script.orElseThrow(() -> new ScriptNotFoundException("Unable to find script " + name.getValue() + " for user " + username.asString())); + return IOUtils.toInputStream(sieveScript.getScriptContent(), StandardCharsets.UTF_8); + } + + private Optional findSieveScript(Username username, ScriptName scriptName) throws StorageException { + return transactionRunner.runAndRetrieveResult(entityManager -> findSieveScript(username, scriptName, entityManager), + throwStorageException("Unable to find script " + scriptName.getValue() + " for user " + username.asString())); + } + + private Optional findSieveScript(Username username, ScriptName scriptName, EntityManager entityManager) { + try { + JPASieveScript sieveScript = entityManager.createNamedQuery("findSieveScript", JPASieveScript.class) + .setParameter("username", username.asString()) + .setParameter("scriptName", scriptName.getValue()).getSingleResult(); + return Optional.ofNullable(sieveScript); + } catch (NoResultException e) { + LOGGER.debug("Sieve script not found for user {}", username.asString()); + return Optional.empty(); + } + } + + @Override + public void deleteScript(Username username, ScriptName name) throws ScriptNotFoundException, IsActiveException, StorageException { + transactionRunner.runAndHandleException(Throwing.consumer(entityManager -> { + Optional sieveScript = findSieveScript(username, name, entityManager); + if (!sieveScript.isPresent()) { + rollbackTransactionIfActive(entityManager.getTransaction()); + throw new ScriptNotFoundException("Unable to find script " + name.getValue() + " for user " + username.asString()); + } + JPASieveScript sieveScriptToRemove = sieveScript.get(); + if (sieveScriptToRemove.isActive()) { + rollbackTransactionIfActive(entityManager.getTransaction()); + throw new IsActiveException("Unable to delete active script " + name.getValue() + " for user " + username.asString()); + } + entityManager.remove(sieveScriptToRemove); + }).sneakyThrow(), throwStorageExceptionConsumer("Unable to delete script " + name.getValue() + " for user " + username.asString())); + } + + @Override + public void renameScript(Username username, ScriptName oldName, ScriptName newName) throws ScriptNotFoundException, DuplicateException, StorageException { + transactionRunner.runAndHandleException(Throwing.consumer(entityManager -> { + Optional sieveScript = findSieveScript(username, oldName, entityManager); + if (!sieveScript.isPresent()) { + rollbackTransactionIfActive(entityManager.getTransaction()); + throw new ScriptNotFoundException("Unable to find script " + oldName.getValue() + " for user " + username.asString()); + } + + Optional duplicatedSieveScript = findSieveScript(username, newName, entityManager); + if (duplicatedSieveScript.isPresent()) { + rollbackTransactionIfActive(entityManager.getTransaction()); + throw new DuplicateException("Unable to rename script. Duplicate found " + newName.getValue() + " for user " + username.asString()); + } + + JPASieveScript sieveScriptToRename = sieveScript.get(); + sieveScriptToRename.renameTo(newName); + }).sneakyThrow(), throwStorageExceptionConsumer("Unable to rename script " + oldName.getValue() + " for user " + username.asString())); + } + + private void rollbackTransactionIfActive(EntityTransaction transaction) { + if (transaction.isActive()) { + transaction.rollback(); + } + } + + @Override + public boolean hasDefaultQuota() throws StorageException { + Optional defaultQuota = findQuotaForUser(DEFAULT_SIEVE_QUOTA_USERNAME); + return defaultQuota.isPresent(); + } + + @Override + public QuotaSizeLimit getDefaultQuota() throws QuotaNotFoundException, StorageException { + JPASieveQuota jpaSieveQuota = findQuotaForUser(DEFAULT_SIEVE_QUOTA_USERNAME) + .orElseThrow(() -> new QuotaNotFoundException("Unable to find quota for default user")); + return QuotaSizeLimit.size(jpaSieveQuota.getSize()); + } + + @Override + public void setDefaultQuota(QuotaSizeLimit quota) throws StorageException { + setQuotaForUser(DEFAULT_SIEVE_QUOTA_USERNAME, quota); + } + + @Override + public void removeQuota() throws QuotaNotFoundException, StorageException { + removeQuotaForUser(DEFAULT_SIEVE_QUOTA_USERNAME); + } + + @Override + public boolean hasQuota(Username username) throws StorageException { + Optional quotaForUser = findQuotaForUser(username.asString()); + return quotaForUser.isPresent(); + } + + @Override + public QuotaSizeLimit getQuota(Username username) throws QuotaNotFoundException, StorageException { + JPASieveQuota jpaSieveQuota = findQuotaForUser(username.asString()) + .orElseThrow(() -> new QuotaNotFoundException("Unable to find quota for user " + username.asString())); + return QuotaSizeLimit.size(jpaSieveQuota.getSize()); + } + + @Override + public void setQuota(Username username, QuotaSizeLimit quota) throws StorageException { + setQuotaForUser(username.asString(), quota); + } + + @Override + public void removeQuota(Username username) throws QuotaNotFoundException, StorageException { + removeQuotaForUser(username.asString()); + } + + private Optional findQuotaForUser(String username) throws StorageException { + return transactionRunner.runAndRetrieveResult(entityManager -> findQuotaForUser(username, entityManager), + throwStorageException("Unable to find quota for user " + username)); + } + + private Function throwStorageException(String message) { + return Throwing.function(e -> { + throw new StorageException(message, e); + }).sneakyThrow(); + } + + private Consumer throwStorageExceptionConsumer(String message) { + return Throwing.consumer(e -> { + throw new StorageException(message, e); + }).sneakyThrow(); + } + + private Optional findQuotaForUser(String username, EntityManager entityManager) { + try { + JPASieveQuota sieveQuota = entityManager.createNamedQuery("findByUsername", JPASieveQuota.class) + .setParameter("username", username).getSingleResult(); + return Optional.of(sieveQuota); + } catch (NoResultException e) { + return Optional.empty(); + } + } + + private void setQuotaForUser(String username, QuotaSizeLimit quota) throws StorageException { + transactionRunner.runAndHandleException(Throwing.consumer(entityManager -> { + Optional sieveQuota = findQuotaForUser(username, entityManager); + if (sieveQuota.isPresent()) { + JPASieveQuota jpaSieveQuota = sieveQuota.get(); + jpaSieveQuota.setSize(quota); + entityManager.merge(jpaSieveQuota); + } else { + JPASieveQuota jpaSieveQuota = new JPASieveQuota(username, quota.asLong()); + entityManager.persist(jpaSieveQuota); + } + }), throwStorageExceptionConsumer("Unable to set quota for user " + username)); + } + + private void removeQuotaForUser(String username) throws StorageException { + transactionRunner.runAndHandleException(Throwing.consumer(entityManager -> { + Optional quotaForUser = findQuotaForUser(username, entityManager); + quotaForUser.ifPresent(entityManager::remove); + }), throwStorageExceptionConsumer("Unable to remove quota for user " + username)); + } + + @Override + public Mono resetSpaceUsedReactive(Username username, long spaceUsed) { + return Mono.error(new UnsupportedOperationException()); + } +} \ No newline at end of file diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/jpa/model/JPASieveQuota.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/jpa/model/JPASieveQuota.java new file mode 100644 index 00000000000..52485c12ec1 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/sieve/jpa/model/JPASieveQuota.java @@ -0,0 +1,97 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.sieve.jpa.model; + +import java.util.Objects; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.NamedQueries; +import javax.persistence.NamedQuery; +import javax.persistence.Table; + +import org.apache.james.core.quota.QuotaSizeLimit; + +import com.google.common.base.MoreObjects; + +@Entity(name = "JamesSieveQuota") +@Table(name = "JAMES_SIEVE_QUOTA") +@NamedQueries({ + @NamedQuery(name = "findByUsername", query = "SELECT sieveQuota FROM JamesSieveQuota sieveQuota WHERE sieveQuota.username=:username") +}) +public class JPASieveQuota { + + @Id + @Column(name = "USER_NAME", nullable = false, length = 100) + private String username; + + @Column(name = "SIZE", nullable = false) + private long size; + + /** + * @deprecated enhancement only + */ + @Deprecated + protected JPASieveQuota() { + } + + public JPASieveQuota(String username, long size) { + this.username = username; + this.size = size; + } + + public long getSize() { + return size; + } + + public void setSize(QuotaSizeLimit quotaSize) { + this.size = quotaSize.asLong(); + } + + public QuotaSizeLimit toQuotaSize() { + return QuotaSizeLimit.size(size); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + JPASieveQuota that = (JPASieveQuota) o; + return Objects.equals(username, that.username); + } + + @Override + public int hashCode() { + return Objects.hash(username); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("username", username) + .add("size", size) + .toString(); + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/jpa/model/JPASieveScript.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/jpa/model/JPASieveScript.java new file mode 100644 index 00000000000..72b5ba53f51 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/sieve/jpa/model/JPASieveScript.java @@ -0,0 +1,200 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.sieve.jpa.model; + +import java.time.OffsetDateTime; +import java.util.Objects; +import java.util.UUID; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.NamedQueries; +import javax.persistence.NamedQuery; +import javax.persistence.Table; + +import org.apache.commons.lang3.StringUtils; +import org.apache.james.sieverepository.api.ScriptContent; +import org.apache.james.sieverepository.api.ScriptName; +import org.apache.james.sieverepository.api.ScriptSummary; + +import com.google.common.base.MoreObjects; +import com.google.common.base.Preconditions; + +@Entity(name = "JamesSieveScript") +@Table(name = "JAMES_SIEVE_SCRIPT") +@NamedQueries({ + @NamedQuery(name = "findAllByUsername", query = "SELECT sieveScript FROM JamesSieveScript sieveScript WHERE sieveScript.username=:username"), + @NamedQuery(name = "findActiveByUsername", query = "SELECT sieveScript FROM JamesSieveScript sieveScript WHERE sieveScript.username=:username AND sieveScript.isActive=true"), + @NamedQuery(name = "findSieveScript", query = "SELECT sieveScript FROM JamesSieveScript sieveScript WHERE sieveScript.username=:username AND sieveScript.scriptName=:scriptName") +}) +public class JPASieveScript { + + public static Builder builder() { + return new Builder(); + } + + public static ScriptSummary toSummary(JPASieveScript script) { + return new ScriptSummary(new ScriptName(script.getScriptName()), script.isActive(), script.getScriptSize()); + } + + public static class Builder { + + private String username; + private String scriptName; + private String scriptContent; + private long scriptSize; + private boolean isActive; + private OffsetDateTime activationDateTime; + + public Builder username(String username) { + Preconditions.checkNotNull(username); + this.username = username; + return this; + } + + public Builder scriptName(String scriptName) { + Preconditions.checkNotNull(scriptName); + this.scriptName = scriptName; + return this; + } + + public Builder scriptContent(ScriptContent scriptContent) { + Preconditions.checkNotNull(scriptContent); + this.scriptContent = scriptContent.getValue(); + this.scriptSize = scriptContent.length(); + return this; + } + + public Builder isActive(boolean isActive) { + this.isActive = isActive; + return this; + } + + public JPASieveScript build() { + Preconditions.checkState(StringUtils.isNotBlank(username), "'username' is mandatory"); + Preconditions.checkState(StringUtils.isNotBlank(scriptName), "'scriptName' is mandatory"); + this.activationDateTime = isActive ? OffsetDateTime.now() : null; + return new JPASieveScript(username, scriptName, scriptContent, scriptSize, isActive, activationDateTime); + } + } + + @Id + private String uuid = UUID.randomUUID().toString(); + + @Column(name = "USER_NAME", nullable = false, length = 100) + private String username; + + @Column(name = "SCRIPT_NAME", nullable = false, length = 255) + private String scriptName; + + @Column(name = "SCRIPT_CONTENT", nullable = false, length = 1024) + private String scriptContent; + + @Column(name = "SCRIPT_SIZE", nullable = false) + private long scriptSize; + + @Column(name = "IS_ACTIVE", nullable = false) + private boolean isActive; + + @Column(name = "ACTIVATION_DATE_TIME") + private OffsetDateTime activationDateTime; + + /** + * @deprecated enhancement only + */ + @Deprecated + protected JPASieveScript() { + } + + private JPASieveScript(String username, String scriptName, String scriptContent, long scriptSize, boolean isActive, OffsetDateTime activationDateTime) { + this.username = username; + this.scriptName = scriptName; + this.scriptContent = scriptContent; + this.scriptSize = scriptSize; + this.isActive = isActive; + this.activationDateTime = activationDateTime; + } + + public String getUsername() { + return username; + } + + public String getScriptName() { + return scriptName; + } + + public String getScriptContent() { + return scriptContent; + } + + public long getScriptSize() { + return scriptSize; + } + + public boolean isActive() { + return isActive; + } + + public OffsetDateTime getActivationDateTime() { + return activationDateTime; + } + + public void activate() { + this.isActive = true; + this.activationDateTime = OffsetDateTime.now(); + } + + public void deactivate() { + this.isActive = false; + this.activationDateTime = null; + } + + public void renameTo(ScriptName newName) { + this.scriptName = newName.getValue(); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + JPASieveScript that = (JPASieveScript) o; + return Objects.equals(uuid, that.uuid); + } + + @Override + public int hashCode() { + return Objects.hash(uuid); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("uuid", uuid) + .add("username", username) + .add("scriptName", scriptName) + .add("isActive", isActive) + .toString(); + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/user/jpa/JPAUsersDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/user/jpa/JPAUsersDAO.java new file mode 100644 index 00000000000..fc12e0eaa0e --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/user/jpa/JPAUsersDAO.java @@ -0,0 +1,267 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.user.jpa; + +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +import javax.persistence.EntityManager; +import javax.persistence.EntityManagerFactory; +import javax.persistence.EntityTransaction; +import javax.persistence.NoResultException; +import javax.persistence.PersistenceException; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.backends.jpa.EntityManagerUtils; +import org.apache.james.core.Username; +import org.apache.james.lifecycle.api.Configurable; +import org.apache.james.user.api.UsersRepositoryException; +import org.apache.james.user.api.model.User; +import org.apache.james.user.jpa.model.JPAUser; +import org.apache.james.user.lib.UsersDAO; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; + +/** + * JPA based UserRepository + */ +public class JPAUsersDAO implements UsersDAO, Configurable { + private static final Logger LOGGER = LoggerFactory.getLogger(JPAUsersDAO.class); + + private EntityManagerFactory entityManagerFactory; + private String algo; + + @Override + public void configure(HierarchicalConfiguration config) { + algo = config.getString("algorithm", "PBKDF2"); + } + + /** + * Sets entity manager. + * + * @param entityManagerFactory + * the entityManager to set + */ + public final void setEntityManagerFactory(EntityManagerFactory entityManagerFactory) { + this.entityManagerFactory = entityManagerFactory; + } + + public void init() { + EntityManagerUtils.safelyClose(createEntityManager()); + } + + /** + * Get the user object with the specified user name. Return null if no such + * user. + * + * @param name + * the name of the user to retrieve + * @return the user being retrieved, null if the user doesn't exist + * + * @since James 1.2.2 + */ + @Override + public Optional getUserByName(Username name) throws UsersRepositoryException { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + + try { + JPAUser singleResult = (JPAUser) entityManager + .createNamedQuery("findUserByName") + .setParameter("name", name.asString()) + .getSingleResult(); + return Optional.of(singleResult); + } catch (NoResultException e) { + return Optional.empty(); + } catch (PersistenceException e) { + LOGGER.debug("Failed to find user", e); + throw new UsersRepositoryException("Unable to search user", e); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + /** + * Update the repository with the specified user object. A user object with + * this username must already exist. + */ + @Override + public void updateUser(User user) throws UsersRepositoryException { + Preconditions.checkNotNull(user); + + EntityManager entityManager = entityManagerFactory.createEntityManager(); + + final EntityTransaction transaction = entityManager.getTransaction(); + try { + if (contains(user.getUserName())) { + transaction.begin(); + entityManager.merge(user); + transaction.commit(); + } else { + LOGGER.debug("User not found"); + throw new UsersRepositoryException("User " + user.getUserName() + " not found"); + } + } catch (PersistenceException e) { + LOGGER.debug("Failed to update user", e); + if (transaction.isActive()) { + transaction.rollback(); + } + throw new UsersRepositoryException("Failed to update user " + user.getUserName().asString(), e); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + /** + * Removes a user from the repository + * + * @param name + * the user to remove from the repository + */ + @Override + public void removeUser(Username name) throws UsersRepositoryException { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + + final EntityTransaction transaction = entityManager.getTransaction(); + try { + transaction.begin(); + if (entityManager.createNamedQuery("deleteUserByName").setParameter("name", name.asString()).executeUpdate() < 1) { + transaction.commit(); + throw new UsersRepositoryException("User " + name.asString() + " does not exist"); + } else { + transaction.commit(); + } + } catch (PersistenceException e) { + LOGGER.debug("Failed to remove user", e); + if (transaction.isActive()) { + transaction.rollback(); + } + throw new UsersRepositoryException("Failed to remove user " + name.asString(), e); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + /** + * Returns whether or not this user is in the repository + * + * @param name + * the name to check in the repository + * @return whether the user is in the repository + */ + @Override + public boolean contains(Username name) throws UsersRepositoryException { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + + try { + return (Long) entityManager.createNamedQuery("containsUser") + .setParameter("name", name.asString().toLowerCase(Locale.US)) + .getSingleResult() > 0; + } catch (PersistenceException e) { + LOGGER.debug("Failed to find user", e); + throw new UsersRepositoryException("Failed to find user" + name.asString(), e); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + /** + * Returns a count of the users in the repository. + * + * @return the number of users in the repository + */ + @Override + public int countUsers() throws UsersRepositoryException { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + + try { + return ((Long) entityManager.createNamedQuery("countUsers").getSingleResult()).intValue(); + } catch (PersistenceException e) { + LOGGER.debug("Failed to find user", e); + throw new UsersRepositoryException("Failed to count users", e); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + /** + * List users in repository. + * + * @return Iterator over a collection of Strings, each being one user in the + * repository. + */ + @Override + @SuppressWarnings("unchecked") + public Iterator list() throws UsersRepositoryException { + EntityManager entityManager = entityManagerFactory.createEntityManager(); + + try { + return ((List) entityManager.createNamedQuery("listUserNames").getResultList()) + .stream() + .map(Username::of) + .collect(ImmutableList.toImmutableList()).iterator(); + + } catch (PersistenceException e) { + LOGGER.debug("Failed to find user", e); + throw new UsersRepositoryException("Failed to list users", e); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + + /** + * Return a new {@link EntityManager} instance + * + * @return manager + */ + private EntityManager createEntityManager() { + return entityManagerFactory.createEntityManager(); + } + + @Override + public void addUser(Username username, String password) throws UsersRepositoryException { + Username lowerCasedUsername = Username.of(username.asString().toLowerCase(Locale.US)); + if (contains(lowerCasedUsername)) { + throw new UsersRepositoryException(lowerCasedUsername.asString() + " already exists."); + } + EntityManager entityManager = entityManagerFactory.createEntityManager(); + final EntityTransaction transaction = entityManager.getTransaction(); + try { + transaction.begin(); + JPAUser user = new JPAUser(lowerCasedUsername.asString(), password, algo); + entityManager.persist(user); + transaction.commit(); + } catch (PersistenceException e) { + LOGGER.debug("Failed to save user", e); + if (transaction.isActive()) { + transaction.rollback(); + } + throw new UsersRepositoryException("Failed to add user" + username.asString(), e); + } finally { + EntityManagerUtils.safelyClose(entityManager); + } + } + +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/user/jpa/JPAUsersRepository.java b/server/data/data-postgres/src/main/java/org/apache/james/user/jpa/JPAUsersRepository.java new file mode 100644 index 00000000000..b3f9397abe9 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/user/jpa/JPAUsersRepository.java @@ -0,0 +1,64 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.user.jpa; + +import javax.annotation.PostConstruct; +import javax.inject.Inject; +import javax.persistence.EntityManagerFactory; +import javax.persistence.PersistenceUnit; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.domainlist.api.DomainList; +import org.apache.james.user.lib.UsersRepositoryImpl; + +/** + * JPA based UserRepository + */ +public class JPAUsersRepository extends UsersRepositoryImpl { + @Inject + public JPAUsersRepository(DomainList domainList) { + super(domainList, new JPAUsersDAO()); + } + + /** + * Sets entity manager. + * + * @param entityManagerFactory + * the entityManager to set + */ + @Inject + @PersistenceUnit(unitName = "James") + public final void setEntityManagerFactory(EntityManagerFactory entityManagerFactory) { + usersDAO.setEntityManagerFactory(entityManagerFactory); + } + + @PostConstruct + public void init() { + usersDAO.init(); + } + + @Override + public void configure(HierarchicalConfiguration config) throws ConfigurationException { + usersDAO.configure(config); + super.configure(config); + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/user/jpa/model/JPAUser.java b/server/data/data-postgres/src/main/java/org/apache/james/user/jpa/model/JPAUser.java new file mode 100644 index 00000000000..8a5cad22efb --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/user/jpa/model/JPAUser.java @@ -0,0 +1,193 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.user.jpa.model; + +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import java.util.function.Function; + +import javax.persistence.Basic; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.NamedQueries; +import javax.persistence.NamedQuery; +import javax.persistence.Table; +import javax.persistence.Version; + +import org.apache.james.core.Username; +import org.apache.james.user.api.model.User; +import org.apache.james.user.lib.model.Algorithm; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.hash.HashFunction; +import com.google.common.hash.Hashing; + +@Entity(name = "JamesUser") +@Table(name = "JAMES_USER") +@NamedQueries({ + @NamedQuery(name = "findUserByName", query = "SELECT user FROM JamesUser user WHERE user.name=:name"), + @NamedQuery(name = "deleteUserByName", query = "DELETE FROM JamesUser user WHERE user.name=:name"), + @NamedQuery(name = "containsUser", query = "SELECT COUNT(user) FROM JamesUser user WHERE user.name=:name"), + @NamedQuery(name = "countUsers", query = "SELECT COUNT(user) FROM JamesUser user"), + @NamedQuery(name = "listUserNames", query = "SELECT user.name FROM JamesUser user") }) +public class JPAUser implements User { + + /** + * Hash password. + * + * @param password + * not null + * @return not null + */ + @VisibleForTesting + static String hashPassword(String password, String nullableSalt, String nullableAlgorithm) { + Algorithm algorithm = Algorithm.of(Optional.ofNullable(nullableAlgorithm).orElse("SHA-512")); + if (algorithm.isPBKDF2()) { + return algorithm.digest(password, nullableSalt); + } + String credentials = password; + if (algorithm.isSalted() && nullableSalt != null) { + credentials = nullableSalt + password; + } + return chooseHashFunction(algorithm.getName()).apply(credentials); + } + + interface PasswordHashFunction extends Function {} + + private static PasswordHashFunction chooseHashFunction(String algorithm) { + switch (algorithm) { + case "NONE": + return password -> password; + default: + return password -> chooseHashing(algorithm).hashString(password, StandardCharsets.UTF_8).toString(); + } + } + + @SuppressWarnings("deprecation") + private static HashFunction chooseHashing(String algorithm) { + switch (algorithm) { + case "MD5": + return Hashing.md5(); + case "SHA-256": + return Hashing.sha256(); + case "SHA-512": + return Hashing.sha512(); + case "SHA-1": + case "SHA1": + return Hashing.sha1(); + default: + return Hashing.sha512(); + } + } + + /** Prevents concurrent modification */ + @Version + private int version; + + /** Key by user name */ + @Id + @Column(name = "USER_NAME", nullable = false, length = 100) + private String name; + + /** Hashed password */ + @Basic + @Column(name = "PASSWORD", nullable = false, length = 128) + private String password; + + @Basic + @Column(name = "PASSWORD_HASH_ALGORITHM", nullable = false, length = 100) + private String alg; + + protected JPAUser() { + } + + public JPAUser(String userName, String password, String alg) { + super(); + this.name = userName; + this.alg = alg; + this.password = hashPassword(password, userName, alg); + } + + @Override + public Username getUserName() { + return Username.of(name); + } + + @Override + public boolean setPassword(String newPass) { + final boolean result; + if (newPass == null) { + result = false; + } else { + password = hashPassword(newPass, name, alg); + result = true; + } + return result; + } + + @Override + public boolean verifyPassword(String pass) { + final boolean result; + if (pass == null) { + result = password == null; + } else { + result = password != null && password.equals(hashPassword(pass, name, alg)); + } + + return result; + } + + @Override + public int hashCode() { + final int PRIME = 31; + int result = 1; + result = PRIME * result + ((name == null) ? 0 : name.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + final JPAUser other = (JPAUser) obj; + if (name == null) { + if (other.name != null) { + return false; + } + } else if (!name.equals(other.name)) { + return false; + } + return true; + } + + @Override + public String toString() { + return "[User " + name + "]"; + } + +} diff --git a/server/data/data-postgres/src/reporting-site/site.xml b/server/data/data-postgres/src/reporting-site/site.xml new file mode 100644 index 00000000000..d9191644908 --- /dev/null +++ b/server/data/data-postgres/src/reporting-site/site.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/server/data/data-postgres/src/test/java/org/apache/james/domainlist/jpa/JPADomainListTest.java b/server/data/data-postgres/src/test/java/org/apache/james/domainlist/jpa/JPADomainListTest.java new file mode 100644 index 00000000000..2a9bb30fd36 --- /dev/null +++ b/server/data/data-postgres/src/test/java/org/apache/james/domainlist/jpa/JPADomainListTest.java @@ -0,0 +1,71 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.domainlist.jpa; + +import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.core.Domain; +import org.apache.james.domainlist.api.DomainList; +import org.apache.james.domainlist.jpa.model.JPADomain; +import org.apache.james.domainlist.lib.DomainListConfiguration; +import org.apache.james.domainlist.lib.DomainListContract; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +/** + * Test the JPA implementation of the DomainList. + */ +class JPADomainListTest implements DomainListContract { + + static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPADomain.class); + + JPADomainList jpaDomainList; + + @BeforeEach + public void setUp() throws Exception { + jpaDomainList = createDomainList(); + } + + @AfterEach + public void tearDown() throws Exception { + DomainList domainList = createDomainList(); + for (Domain domain: domainList.getDomains()) { + try { + domainList.removeDomain(domain); + } catch (Exception e) { + // silent: exception arise where clearing auto detected domains + } + } + } + + @Override + public DomainList domainList() { + return jpaDomainList; + } + + private JPADomainList createDomainList() throws Exception { + JPADomainList jpaDomainList = new JPADomainList(getDNSServer("localhost"), + JPA_TEST_CLUSTER.getEntityManagerFactory()); + jpaDomainList.configure(DomainListConfiguration.builder() + .autoDetect(false) + .autoDetectIp(false) + .build()); + + return jpaDomainList; + } +} diff --git a/server/data/data-postgres/src/test/java/org/apache/james/jpa/healthcheck/JPAHealthCheckTest.java b/server/data/data-postgres/src/test/java/org/apache/james/jpa/healthcheck/JPAHealthCheckTest.java new file mode 100644 index 00000000000..20ed1bbaa22 --- /dev/null +++ b/server/data/data-postgres/src/test/java/org/apache/james/jpa/healthcheck/JPAHealthCheckTest.java @@ -0,0 +1,62 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.jpa.healthcheck; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.core.healthcheck.Result; +import org.apache.james.core.healthcheck.ResultStatus; +import org.apache.james.mailrepository.jpa.model.JPAUrl; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class JPAHealthCheckTest { + JPAHealthCheck jpaHealthCheck; + JpaTestCluster jpaTestCluster; + + @BeforeEach + void setUp() { + jpaTestCluster = JpaTestCluster.create(JPAUrl.class); + jpaHealthCheck = new JPAHealthCheck(jpaTestCluster.getEntityManagerFactory()); + } + + @Test + void testWhenActive() { + Result result = jpaHealthCheck.check().block(); + ResultStatus healthy = ResultStatus.HEALTHY; + assertThat(result.getStatus()).as("Result %s status should be %s", result.getStatus(), healthy) + .isEqualTo(healthy); + } + + @Test + void testWhenInactive() { + jpaTestCluster.getEntityManagerFactory().close(); + Result result = Result.healthy(jpaHealthCheck.componentName()); + try { + result = jpaHealthCheck.check().block(); + } catch (IllegalStateException e) { + fail("The exception of the EMF was not handled property.ª"); + } + ResultStatus unhealthy = ResultStatus.UNHEALTHY; + assertThat(result.getStatus()).as("Result %s status should be %s", result.getStatus(), unhealthy) + .isEqualTo(unhealthy); + } +} diff --git a/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryTest.java b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryTest.java new file mode 100644 index 00000000000..3c41aa53abe --- /dev/null +++ b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryTest.java @@ -0,0 +1,70 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailrepository.jpa; + +import org.apache.commons.configuration2.BaseHierarchicalConfiguration; +import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.mailrepository.MailRepositoryContract; +import org.apache.james.mailrepository.api.MailRepository; +import org.apache.james.mailrepository.api.MailRepositoryPath; +import org.apache.james.mailrepository.api.MailRepositoryUrl; +import org.apache.james.mailrepository.api.Protocol; +import org.apache.james.mailrepository.jpa.model.JPAMail; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; + +public class JPAMailRepositoryTest implements MailRepositoryContract { + + final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMail.class); + + private JPAMailRepository mailRepository; + + @BeforeEach + void setUp() throws Exception { + mailRepository = retrieveRepository(MailRepositoryPath.from("testrepo")); + } + + @AfterEach + void tearDown() { + JPA_TEST_CLUSTER.clear("JAMES_MAIL_STORE"); + } + + @Override + public MailRepository retrieveRepository() { + return mailRepository; + } + + @Override + public JPAMailRepository retrieveRepository(MailRepositoryPath url) throws Exception { + BaseHierarchicalConfiguration conf = new BaseHierarchicalConfiguration(); + conf.addProperty("[@destinationURL]", MailRepositoryUrl.fromPathAndProtocol(new Protocol("jpa"), url).asString()); + JPAMailRepository mailRepository = new JPAMailRepository(JPA_TEST_CLUSTER.getEntityManagerFactory()); + mailRepository.configure(conf); + mailRepository.init(); + return mailRepository; + } + + @Override + @Disabled("JAMES-3431 No support for Attribute collection Java serialization yet") + public void shouldPreserveDsnParameters() throws Exception { + MailRepositoryContract.super.shouldPreserveDsnParameters(); + } +} diff --git a/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryUrlStoreExtension.java b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryUrlStoreExtension.java new file mode 100644 index 00000000000..c8af2008d1a --- /dev/null +++ b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryUrlStoreExtension.java @@ -0,0 +1,48 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailrepository.jpa; + +import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.mailrepository.api.MailRepositoryUrlStore; +import org.apache.james.mailrepository.jpa.model.JPAUrl; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junit.jupiter.api.extension.ParameterResolver; + +public class JPAMailRepositoryUrlStoreExtension implements ParameterResolver, AfterEachCallback { + private static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAUrl.class); + + @Override + public void afterEach(ExtensionContext context) { + JPA_TEST_CLUSTER.clear("JAMES_MAIL_REPOS"); + } + + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + return (parameterContext.getParameter().getType() == MailRepositoryUrlStore.class); + } + + @Override + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + return new JPAMailRepositoryUrlStore(JPA_TEST_CLUSTER.getEntityManagerFactory()); + } +} diff --git a/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryUrlStoreTest.java b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryUrlStoreTest.java new file mode 100644 index 00000000000..ed8b69316a1 --- /dev/null +++ b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryUrlStoreTest.java @@ -0,0 +1,28 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailrepository.jpa; + +import org.apache.james.mailrepository.MailRepositoryUrlStoreContract; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(JPAMailRepositoryUrlStoreExtension.class) +public class JPAMailRepositoryUrlStoreTest implements MailRepositoryUrlStoreContract { + +} diff --git a/server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/JPARecipientRewriteTableTest.java b/server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/JPARecipientRewriteTableTest.java new file mode 100644 index 00000000000..2f60f581928 --- /dev/null +++ b/server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/JPARecipientRewriteTableTest.java @@ -0,0 +1,60 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.rrt.jpa; + +import static org.mockito.Mockito.mock; + +import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.domainlist.api.DomainList; +import org.apache.james.rrt.jpa.model.JPARecipientRewrite; +import org.apache.james.rrt.lib.AbstractRecipientRewriteTable; +import org.apache.james.rrt.lib.RecipientRewriteTableContract; +import org.apache.james.user.jpa.JPAUsersRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +class JPARecipientRewriteTableTest implements RecipientRewriteTableContract { + + static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPARecipientRewrite.class); + + AbstractRecipientRewriteTable recipientRewriteTable; + + @BeforeEach + void setup() throws Exception { + setUp(); + } + + @AfterEach + void teardown() throws Exception { + tearDown(); + } + + @Override + public void createRecipientRewriteTable() { + JPARecipientRewriteTable localVirtualUserTable = new JPARecipientRewriteTable(); + localVirtualUserTable.setEntityManagerFactory(JPA_TEST_CLUSTER.getEntityManagerFactory()); + localVirtualUserTable.setUsersRepository(new JPAUsersRepository(mock(DomainList.class))); + recipientRewriteTable = localVirtualUserTable; + } + + @Override + public AbstractRecipientRewriteTable virtualUserTable() { + return recipientRewriteTable; + } +} diff --git a/server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/JPAStepdefs.java b/server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/JPAStepdefs.java new file mode 100644 index 00000000000..3908dfe98e0 --- /dev/null +++ b/server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/JPAStepdefs.java @@ -0,0 +1,60 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.rrt.jpa; + +import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.rrt.jpa.model.JPARecipientRewrite; +import org.apache.james.rrt.lib.AbstractRecipientRewriteTable; +import org.apache.james.rrt.lib.RecipientRewriteTableFixture; +import org.apache.james.rrt.lib.RewriteTablesStepdefs; +import org.apache.james.user.jpa.JPAUsersRepository; + +import com.github.fge.lambdas.Throwing; + +import cucumber.api.java.After; +import cucumber.api.java.Before; + +public class JPAStepdefs { + + private static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPARecipientRewrite.class); + + private final RewriteTablesStepdefs mainStepdefs; + + public JPAStepdefs(RewriteTablesStepdefs mainStepdefs) { + this.mainStepdefs = mainStepdefs; + } + + @Before + public void setup() throws Throwable { + mainStepdefs.setUp(Throwing.supplier(this::getRecipientRewriteTable).sneakyThrow()); + } + + @After + public void tearDown() { + JPA_TEST_CLUSTER.clear(JPARecipientRewrite.JAMES_RECIPIENT_REWRITE); + } + + private AbstractRecipientRewriteTable getRecipientRewriteTable() throws Exception { + JPARecipientRewriteTable localVirtualUserTable = new JPARecipientRewriteTable(); + localVirtualUserTable.setEntityManagerFactory(JPA_TEST_CLUSTER.getEntityManagerFactory()); + localVirtualUserTable.setUsersRepository(new JPAUsersRepository(RecipientRewriteTableFixture.domainListForCucumberTests())); + localVirtualUserTable.setDomainList(RecipientRewriteTableFixture.domainListForCucumberTests()); + return localVirtualUserTable; + } +} diff --git a/server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/RewriteTablesTest.java b/server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/RewriteTablesTest.java new file mode 100644 index 00000000000..7cb0a007f01 --- /dev/null +++ b/server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/RewriteTablesTest.java @@ -0,0 +1,32 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.rrt.jpa; + +import org.junit.runner.RunWith; + +import cucumber.api.CucumberOptions; +import cucumber.api.junit.Cucumber; + +@RunWith(Cucumber.class) +@CucumberOptions( + features = { "classpath:cucumber/" }, + glue = { "org.apache.james.rrt.lib", "org.apache.james.rrt.jpa" } + ) +public class RewriteTablesTest { +} diff --git a/server/data/data-postgres/src/test/java/org/apache/james/sieve/jpa/JpaSieveRepositoryTest.java b/server/data/data-postgres/src/test/java/org/apache/james/sieve/jpa/JpaSieveRepositoryTest.java new file mode 100644 index 00000000000..ab59dc651cc --- /dev/null +++ b/server/data/data-postgres/src/test/java/org/apache/james/sieve/jpa/JpaSieveRepositoryTest.java @@ -0,0 +1,50 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.sieve.jpa; + +import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.sieve.jpa.model.JPASieveQuota; +import org.apache.james.sieve.jpa.model.JPASieveScript; +import org.apache.james.sieverepository.api.SieveRepository; +import org.apache.james.sieverepository.lib.SieveRepositoryContract; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; + +class JpaSieveRepositoryTest implements SieveRepositoryContract { + + final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPASieveScript.class, JPASieveQuota.class); + + SieveRepository sieveRepository; + + @BeforeEach + void setUp() { + sieveRepository = new JPASieveRepository(JPA_TEST_CLUSTER.getEntityManagerFactory()); + } + + @AfterEach + void tearDown() { + JPA_TEST_CLUSTER.clear("JAMES_SIEVE_SCRIPT", "JAMES_SIEVE_QUOTA"); + } + + @Override + public SieveRepository sieveRepository() { + return sieveRepository; + } +} diff --git a/server/data/data-postgres/src/test/java/org/apache/james/user/jpa/JpaUsersRepositoryTest.java b/server/data/data-postgres/src/test/java/org/apache/james/user/jpa/JpaUsersRepositoryTest.java new file mode 100644 index 00000000000..55355b0a9d4 --- /dev/null +++ b/server/data/data-postgres/src/test/java/org/apache/james/user/jpa/JpaUsersRepositoryTest.java @@ -0,0 +1,103 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.user.jpa; + +import java.util.Optional; + +import org.apache.commons.configuration2.BaseHierarchicalConfiguration; +import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.core.Username; +import org.apache.james.domainlist.api.DomainList; +import org.apache.james.user.api.UsersRepository; +import org.apache.james.user.jpa.model.JPAUser; +import org.apache.james.user.lib.UsersRepositoryContract; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.extension.RegisterExtension; + +class JpaUsersRepositoryTest { + + private static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAUser.class); + + @Nested + class WhenEnableVirtualHosting implements UsersRepositoryContract.WithVirtualHostingContract { + @RegisterExtension + UserRepositoryExtension extension = UserRepositoryExtension.withVirtualHost(); + + private JPAUsersRepository usersRepository; + private TestSystem testSystem; + + @BeforeEach + void setUp(TestSystem testSystem) throws Exception { + usersRepository = getUsersRepository(testSystem.getDomainList(), extension.isSupportVirtualHosting(), Optional.empty()); + this.testSystem = testSystem; + } + + @Override + public UsersRepository testee() { + return usersRepository; + } + + @Override + public UsersRepository testee(Optional administrator) throws Exception { + return getUsersRepository(testSystem.getDomainList(), extension.isSupportVirtualHosting(), administrator); + } + } + + @Nested + class WhenDisableVirtualHosting implements UsersRepositoryContract.WithOutVirtualHostingContract { + @RegisterExtension + UserRepositoryExtension extension = UserRepositoryExtension.withoutVirtualHosting(); + + private JPAUsersRepository usersRepository; + private TestSystem testSystem; + + @BeforeEach + void setUp(TestSystem testSystem) throws Exception { + usersRepository = getUsersRepository(testSystem.getDomainList(), extension.isSupportVirtualHosting(), Optional.empty()); + this.testSystem = testSystem; + } + + @Override + public UsersRepository testee() { + return usersRepository; + } + + @Override + public UsersRepository testee(Optional administrator) throws Exception { + return getUsersRepository(testSystem.getDomainList(), extension.isSupportVirtualHosting(), administrator); + } + } + + @AfterEach + void tearDown() { + JPA_TEST_CLUSTER.clear("JAMES_USER"); + } + + private static JPAUsersRepository getUsersRepository(DomainList domainList, boolean enableVirtualHosting, Optional administrator) throws Exception { + JPAUsersRepository repos = new JPAUsersRepository(domainList); + repos.setEntityManagerFactory(JPA_TEST_CLUSTER.getEntityManagerFactory()); + BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); + configuration.addProperty("enableVirtualHosting", String.valueOf(enableVirtualHosting)); + administrator.ifPresent(username -> configuration.addProperty("administratorId", username.asString())); + repos.configure(configuration); + return repos; + } +} diff --git a/server/data/data-postgres/src/test/java/org/apache/james/user/jpa/model/JPAUserTest.java b/server/data/data-postgres/src/test/java/org/apache/james/user/jpa/model/JPAUserTest.java new file mode 100644 index 00000000000..fa11b2504de --- /dev/null +++ b/server/data/data-postgres/src/test/java/org/apache/james/user/jpa/model/JPAUserTest.java @@ -0,0 +1,73 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.user.jpa.model; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +class JPAUserTest { + + private static final String RANDOM_PASSWORD = "baeMiqu7"; + + @Test + void hashPasswordShouldBeNoopWhenNone() { + //I doubt the expected result was the author intent + Assertions.assertThat(JPAUser.hashPassword(RANDOM_PASSWORD, null, "NONE")).isEqualTo("baeMiqu7"); + } + + @Test + void hashPasswordShouldHashWhenMD5() { + Assertions.assertThat(JPAUser.hashPassword(RANDOM_PASSWORD, null, "MD5")).isEqualTo("702000e50c9fd3755b8fc20ecb07d1ac"); + } + + @Test + void hashPasswordShouldHashWhenSHA1() { + Assertions.assertThat(JPAUser.hashPassword(RANDOM_PASSWORD, null, "SHA1")).isEqualTo("05dbbaa7b4bcae245f14d19ae58ef1b80adf3363"); + } + + @Test + void hashPasswordShouldHashWhenSHA256() { + Assertions.assertThat(JPAUser.hashPassword(RANDOM_PASSWORD, null, "SHA-256")).isEqualTo("6d06c72a578fe0b78ede2393b07739831a287774dcad0b18bc4bde8b0c948b82"); + } + + @Test + void hashPasswordShouldHashWhenSHA512() { + Assertions.assertThat(JPAUser.hashPassword(RANDOM_PASSWORD, null, "SHA-512")).isEqualTo("f9cc82d1c04bb2ce0494a51f7a21d07ac60b6f79a8a55397f454603acac29d8589fdfd694d5c01ba01a346c76b090abca9ad855b5b0c92c6062ad6d93cdc0d03"); + } + + @Test + void hashPasswordShouldSha512WhenRandomString() { + Assertions.assertThat(JPAUser.hashPassword(RANDOM_PASSWORD, null, "random")).isEqualTo("f9cc82d1c04bb2ce0494a51f7a21d07ac60b6f79a8a55397f454603acac29d8589fdfd694d5c01ba01a346c76b090abca9ad855b5b0c92c6062ad6d93cdc0d03"); + } + + @Test + void hashPasswordShouldSha512WhenNull() { + Assertions.assertThat(JPAUser.hashPassword(RANDOM_PASSWORD, null, null)).isEqualTo("f9cc82d1c04bb2ce0494a51f7a21d07ac60b6f79a8a55397f454603acac29d8589fdfd694d5c01ba01a346c76b090abca9ad855b5b0c92c6062ad6d93cdc0d03"); + } + + @Test + void hashPasswordShouldHashWithNullSalt() { + Assertions.assertThat(JPAUser.hashPassword(RANDOM_PASSWORD, null, "SHA-512/salted")).isEqualTo("f9cc82d1c04bb2ce0494a51f7a21d07ac60b6f79a8a55397f454603acac29d8589fdfd694d5c01ba01a346c76b090abca9ad855b5b0c92c6062ad6d93cdc0d03"); + } + + @Test + void hashPasswordShouldHashWithSalt() { + Assertions.assertThat(JPAUser.hashPassword(RANDOM_PASSWORD, "salt", "SHA-512/salted")).isEqualTo("b7941dcdc380ec414623834919f7d5cbe241a2b6a23be79a61cd9f36178382901b8d83642b743297ac72e5de24e4111885dd05df06e14e47c943c05fdd1ff15a"); + } +} \ No newline at end of file diff --git a/server/data/data-postgres/src/test/resources/log4j.properties b/server/data/data-postgres/src/test/resources/log4j.properties new file mode 100644 index 00000000000..34f5a5f5c28 --- /dev/null +++ b/server/data/data-postgres/src/test/resources/log4j.properties @@ -0,0 +1,6 @@ +log4j.rootLogger=WARN, A1 +log4j.appender.A1=org.apache.log4j.ConsoleAppender +log4j.appender.A1.layout=org.apache.log4j.PatternLayout + +# Print the date in ISO 8601 format +log4j.appender.A1.layout.ConversionPattern=%d [%t] %-5p %c - %m%n diff --git a/server/data/data-postgres/src/test/resources/persistence.xml b/server/data/data-postgres/src/test/resources/persistence.xml new file mode 100644 index 00000000000..6224adb74fb --- /dev/null +++ b/server/data/data-postgres/src/test/resources/persistence.xml @@ -0,0 +1,46 @@ + + + + + + + org.apache.openjpa.persistence.PersistenceProviderImpl + osgi:service/javax.sql.DataSource/(osgi.jndi.service.name=jdbc/james) + org.apache.james.domainlist.jpa.model.JPADomain + org.apache.james.user.jpa.model.JPAUser + org.apache.james.rrt.jpa.model.JPARecipientRewrite + org.apache.james.mailrepository.jpa.model.JPAUrl + org.apache.james.mailrepository.jpa.model.JPAMail + org.apache.james.sieve.jpa.model.JPASieveQuota + org.apache.james.sieve.jpa.model.JPASieveScript + true + + + + + + + + + + diff --git a/server/pom.xml b/server/pom.xml index 26085d0e6b2..bd896caf5af 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -72,6 +72,7 @@ data/data-ldap data/data-library data/data-memory + data/data-postgres dns-service/dnsservice-api dns-service/dnsservice-dnsjava From 220cc5088c88929b6ca0b611c753a0c0c4d81f71 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Tue, 31 Oct 2023 10:23:38 +0700 Subject: [PATCH 024/341] JAMES-2586 - Postgres - Init james-server-postgres-common-guice --- server/container/guice/pom.xml | 1 + .../container/guice/postgres-common/pom.xml | 68 ++++++++++ .../modules/data/JPAAuthorizatorModule.java | 35 ++++++ .../james/modules/data/JPADataModule.java | 33 +++++ .../modules/data/JPADomainListModule.java | 44 +++++++ .../modules/data/JPAEntityManagerModule.java | 116 ++++++++++++++++++ .../modules/data/JPAMailRepositoryModule.java | 53 ++++++++ .../data/JPARecipientRewriteTableModule.java | 52 ++++++++ .../data/JPAUsersRepositoryModule.java | 44 +++++++ .../james/TestJPAConfigurationModule.java | 46 +++++++ ...AConfigurationModuleWithSqlValidation.java | 108 ++++++++++++++++ 11 files changed, 600 insertions(+) create mode 100644 server/container/guice/postgres-common/pom.xml create mode 100644 server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAAuthorizatorModule.java create mode 100644 server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPADataModule.java create mode 100644 server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPADomainListModule.java create mode 100644 server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAEntityManagerModule.java create mode 100644 server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAMailRepositoryModule.java create mode 100644 server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPARecipientRewriteTableModule.java create mode 100644 server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAUsersRepositoryModule.java create mode 100644 server/container/guice/postgres-common/src/test/java/org/apache/james/TestJPAConfigurationModule.java create mode 100644 server/container/guice/postgres-common/src/test/java/org/apache/james/TestJPAConfigurationModuleWithSqlValidation.java diff --git a/server/container/guice/pom.xml b/server/container/guice/pom.xml index 82a57ef5893..e5b21e3df4c 100644 --- a/server/container/guice/pom.xml +++ b/server/container/guice/pom.xml @@ -56,6 +56,7 @@ memory onami opensearch + postgres-common protocols/imap protocols/jmap protocols/lmtp diff --git a/server/container/guice/postgres-common/pom.xml b/server/container/guice/postgres-common/pom.xml new file mode 100644 index 00000000000..dc1f2e8ad84 --- /dev/null +++ b/server/container/guice/postgres-common/pom.xml @@ -0,0 +1,68 @@ + + + + + 4.0.0 + + + org.apache.james + james-server-guice + 3.9.0-SNAPSHOT + ../pom.xml + + + james-server-postgres-common-guice + jar + + Apache James :: Server :: Postgres - guice common + + + empty + + + + + ${james.groupId} + james-server-data-file + + + ${james.groupId} + james-server-data-postgres + + + ${james.groupId} + james-server-guice-common + + + ${james.groupId} + james-server-mailbox-adapter + + + ${james.groupId} + testing-base + test + + + org.apache.derby + derby + test + + + diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAAuthorizatorModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAAuthorizatorModule.java new file mode 100644 index 00000000000..4c28118779f --- /dev/null +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAAuthorizatorModule.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.data; + +import org.apache.james.adapter.mailbox.UserRepositoryAuthorizator; +import org.apache.james.mailbox.Authorizator; + +import com.google.inject.AbstractModule; + +public class JPAAuthorizatorModule extends AbstractModule { + + + @Override + protected void configure() { + bind(Authorizator.class).to(UserRepositoryAuthorizator.class); + } + +} diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPADataModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPADataModule.java new file mode 100644 index 00000000000..ff1b84b4495 --- /dev/null +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPADataModule.java @@ -0,0 +1,33 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.modules.data; + +import org.apache.james.CoreDataModule; + +import com.google.inject.AbstractModule; + +public class JPADataModule extends AbstractModule { + @Override + protected void configure() { + install(new CoreDataModule()); + install(new JPADomainListModule()); + install(new JPARecipientRewriteTableModule()); + install(new JPAMailRepositoryModule()); + } +} diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPADomainListModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPADomainListModule.java new file mode 100644 index 00000000000..116fd4b8ace --- /dev/null +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPADomainListModule.java @@ -0,0 +1,44 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.modules.data; + +import org.apache.james.domainlist.api.DomainList; +import org.apache.james.domainlist.jpa.JPADomainList; +import org.apache.james.domainlist.lib.DomainListConfiguration; +import org.apache.james.utils.InitializationOperation; +import org.apache.james.utils.InitilizationOperationBuilder; + +import com.google.inject.AbstractModule; +import com.google.inject.Scopes; +import com.google.inject.multibindings.ProvidesIntoSet; + +public class JPADomainListModule extends AbstractModule { + @Override + public void configure() { + bind(JPADomainList.class).in(Scopes.SINGLETON); + bind(DomainList.class).to(JPADomainList.class); + } + + @ProvidesIntoSet + InitializationOperation configureDomainList(DomainListConfiguration configuration, JPADomainList jpaDomainList) { + return InitilizationOperationBuilder + .forClass(JPADomainList.class) + .init(() -> jpaDomainList.configure(configuration)); + } +} diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAEntityManagerModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAEntityManagerModule.java new file mode 100644 index 00000000000..19432d372c0 --- /dev/null +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAEntityManagerModule.java @@ -0,0 +1,116 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.modules.data; + +import java.io.FileNotFoundException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import javax.inject.Singleton; +import javax.persistence.EntityManagerFactory; +import javax.persistence.Persistence; + +import org.apache.commons.configuration2.Configuration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.james.backends.jpa.JPAConfiguration; +import org.apache.james.utils.PropertiesProvider; + +import com.google.common.base.Joiner; +import com.google.inject.AbstractModule; +import com.google.inject.Provides; + +public class JPAEntityManagerModule extends AbstractModule { + @Provides + @Singleton + public EntityManagerFactory provideEntityManagerFactory(JPAConfiguration jpaConfiguration) { + HashMap properties = new HashMap<>(); + + properties.put(JPAConfiguration.JPA_CONNECTION_DRIVER_NAME, jpaConfiguration.getDriverName()); + properties.put(JPAConfiguration.JPA_CONNECTION_URL, jpaConfiguration.getDriverURL()); + jpaConfiguration.getCredential() + .ifPresent(credential -> { + properties.put(JPAConfiguration.JPA_CONNECTION_USERNAME, credential.getUsername()); + properties.put(JPAConfiguration.JPA_CONNECTION_PASSWORD, credential.getPassword()); + }); + + List connectionProperties = new ArrayList<>(); + jpaConfiguration.isTestOnBorrow().ifPresent(testOnBorrow -> connectionProperties.add("TestOnBorrow=" + testOnBorrow)); + jpaConfiguration.getValidationQueryTimeoutSec() + .ifPresent(timeoutSecond -> connectionProperties.add("ValidationTimeout=" + timeoutSecond * 1000)); + jpaConfiguration.getValidationQuery() + .ifPresent(validationQuery -> connectionProperties.add("ValidationSQL='" + validationQuery + "'")); + jpaConfiguration.getMaxConnections() + .ifPresent(maxConnections -> connectionProperties.add("MaxTotal=" + maxConnections)); + + connectionProperties.addAll(jpaConfiguration.getCustomDatasourceProperties().entrySet().stream().map(entry -> entry.getKey() + "=" + entry.getValue()).collect(Collectors.toList())); + properties.put(JPAConfiguration.JPA_CONNECTION_PROPERTIES, Joiner.on(",").join(connectionProperties)); + properties.putAll(jpaConfiguration.getCustomOpenjpaProperties()); + + jpaConfiguration.isMultithreaded() + .ifPresent(isMultiThread -> + properties.put(JPAConfiguration.JPA_MULTITHREADED, jpaConfiguration.isMultithreaded().toString()) + ); + + jpaConfiguration.isAttachmentStorageEnabled() + .ifPresent(isMultiThread -> + properties.put(JPAConfiguration.ATTACHMENT_STORAGE, jpaConfiguration.isAttachmentStorageEnabled().toString()) + ); + + return Persistence.createEntityManagerFactory("Global", properties); + } + + @Provides + @Singleton + JPAConfiguration provideConfiguration(PropertiesProvider propertiesProvider) throws FileNotFoundException, ConfigurationException { + Configuration dataSource = propertiesProvider.getConfiguration("james-database"); + + Map openjpaProperties = getKeysForPrefix(dataSource, "openjpa", false); + Map datasourceProperties = getKeysForPrefix(dataSource, "datasource", true); + + return JPAConfiguration.builder() + .driverName(dataSource.getString("database.driverClassName")) + .driverURL(dataSource.getString("database.url")) + .testOnBorrow(dataSource.getBoolean("datasource.testOnBorrow", false)) + .validationQueryTimeoutSec(dataSource.getInteger("datasource.validationQueryTimeoutSec", null)) + .validationQuery(dataSource.getString("datasource.validationQuery", null)) + .maxConnections(dataSource.getInteger("datasource.maxTotal", null)) + .multithreaded(dataSource.getBoolean(JPAConfiguration.JPA_MULTITHREADED, true)) + .username(dataSource.getString("database.username")) + .password(dataSource.getString("database.password")) + .setCustomOpenjpaProperties(openjpaProperties) + .setCustomDatasourceProperties(datasourceProperties) + .attachmentStorage(dataSource.getBoolean(JPAConfiguration.ATTACHMENT_STORAGE, false)) + .build(); + } + + private static Map getKeysForPrefix(Configuration dataSource, String prefix, boolean stripPrefix) { + Iterator keys = dataSource.getKeys(prefix); + Map properties = new HashMap<>(); + while (keys.hasNext()) { + String key = keys.next(); + String propertyKey = stripPrefix ? key.replace(prefix + ".", "") : key; + properties.put(propertyKey, dataSource.getString(key)); + } + return properties; + } +} diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAMailRepositoryModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAMailRepositoryModule.java new file mode 100644 index 00000000000..bb6a0ffedb7 --- /dev/null +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAMailRepositoryModule.java @@ -0,0 +1,53 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.data; + +import org.apache.commons.configuration2.BaseHierarchicalConfiguration; +import org.apache.james.mailrepository.api.MailRepositoryFactory; +import org.apache.james.mailrepository.api.MailRepositoryUrlStore; +import org.apache.james.mailrepository.api.Protocol; +import org.apache.james.mailrepository.jpa.JPAMailRepository; +import org.apache.james.mailrepository.jpa.JPAMailRepositoryFactory; +import org.apache.james.mailrepository.jpa.JPAMailRepositoryUrlStore; +import org.apache.james.mailrepository.memory.MailRepositoryStoreConfiguration; + +import com.google.common.collect.ImmutableList; +import com.google.inject.AbstractModule; +import com.google.inject.Scopes; +import com.google.inject.multibindings.Multibinder; + +public class JPAMailRepositoryModule extends AbstractModule { + + @Override + protected void configure() { + bind(JPAMailRepositoryUrlStore.class).in(Scopes.SINGLETON); + + bind(MailRepositoryUrlStore.class).to(JPAMailRepositoryUrlStore.class); + + bind(MailRepositoryStoreConfiguration.Item.class) + .toProvider(() -> new MailRepositoryStoreConfiguration.Item( + ImmutableList.of(new Protocol("jpa")), + JPAMailRepository.class.getName(), + new BaseHierarchicalConfiguration())); + + Multibinder.newSetBinder(binder(), MailRepositoryFactory.class) + .addBinding().to(JPAMailRepositoryFactory.class); + } +} diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPARecipientRewriteTableModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPARecipientRewriteTableModule.java new file mode 100644 index 00000000000..f00af56754a --- /dev/null +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPARecipientRewriteTableModule.java @@ -0,0 +1,52 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.modules.data; + +import org.apache.james.rrt.api.AliasReverseResolver; +import org.apache.james.rrt.api.CanSendFrom; +import org.apache.james.rrt.api.RecipientRewriteTable; +import org.apache.james.rrt.jpa.JPARecipientRewriteTable; +import org.apache.james.rrt.lib.AliasReverseResolverImpl; +import org.apache.james.rrt.lib.CanSendFromImpl; +import org.apache.james.server.core.configuration.ConfigurationProvider; +import org.apache.james.utils.InitializationOperation; +import org.apache.james.utils.InitilizationOperationBuilder; + +import com.google.inject.AbstractModule; +import com.google.inject.Scopes; +import com.google.inject.multibindings.ProvidesIntoSet; + +public class JPARecipientRewriteTableModule extends AbstractModule { + @Override + public void configure() { + bind(JPARecipientRewriteTable.class).in(Scopes.SINGLETON); + bind(RecipientRewriteTable.class).to(JPARecipientRewriteTable.class); + bind(AliasReverseResolverImpl.class).in(Scopes.SINGLETON); + bind(AliasReverseResolver.class).to(AliasReverseResolverImpl.class); + bind(CanSendFromImpl.class).in(Scopes.SINGLETON); + bind(CanSendFrom.class).to(CanSendFromImpl.class); + } + + @ProvidesIntoSet + InitializationOperation configureRRT(ConfigurationProvider configurationProvider, JPARecipientRewriteTable recipientRewriteTable) { + return InitilizationOperationBuilder + .forClass(JPARecipientRewriteTable.class) + .init(() -> recipientRewriteTable.configure(configurationProvider.getConfiguration("recipientrewritetable"))); + } +} diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAUsersRepositoryModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAUsersRepositoryModule.java new file mode 100644 index 00000000000..5a719244a4c --- /dev/null +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAUsersRepositoryModule.java @@ -0,0 +1,44 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.modules.data; + +import org.apache.james.server.core.configuration.ConfigurationProvider; +import org.apache.james.user.api.UsersRepository; +import org.apache.james.user.jpa.JPAUsersRepository; +import org.apache.james.utils.InitializationOperation; +import org.apache.james.utils.InitilizationOperationBuilder; + +import com.google.inject.AbstractModule; +import com.google.inject.Scopes; +import com.google.inject.multibindings.ProvidesIntoSet; + +public class JPAUsersRepositoryModule extends AbstractModule { + @Override + public void configure() { + bind(JPAUsersRepository.class).in(Scopes.SINGLETON); + bind(UsersRepository.class).to(JPAUsersRepository.class); + } + + @ProvidesIntoSet + InitializationOperation configureJpaUsers(ConfigurationProvider configurationProvider, JPAUsersRepository usersRepository) { + return InitilizationOperationBuilder + .forClass(JPAUsersRepository.class) + .init(() -> usersRepository.configure(configurationProvider.getConfiguration("usersrepository"))); + } +} diff --git a/server/container/guice/postgres-common/src/test/java/org/apache/james/TestJPAConfigurationModule.java b/server/container/guice/postgres-common/src/test/java/org/apache/james/TestJPAConfigurationModule.java new file mode 100644 index 00000000000..957cddc27db --- /dev/null +++ b/server/container/guice/postgres-common/src/test/java/org/apache/james/TestJPAConfigurationModule.java @@ -0,0 +1,46 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james; + +import javax.inject.Singleton; + +import org.apache.james.backends.jpa.JPAConfiguration; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; + +public class TestJPAConfigurationModule extends AbstractModule { + + private static final String JDBC_EMBEDDED_URL = "jdbc:derby:memory:mailboxintegration;create=true"; + private static final String JDBC_EMBEDDED_DRIVER = org.apache.derby.jdbc.EmbeddedDriver.class.getName(); + + @Override + protected void configure() { + } + + @Provides + @Singleton + JPAConfiguration provideConfiguration() { + return JPAConfiguration.builder() + .driverName(JDBC_EMBEDDED_DRIVER) + .driverURL(JDBC_EMBEDDED_URL) + .build(); + } +} diff --git a/server/container/guice/postgres-common/src/test/java/org/apache/james/TestJPAConfigurationModuleWithSqlValidation.java b/server/container/guice/postgres-common/src/test/java/org/apache/james/TestJPAConfigurationModuleWithSqlValidation.java new file mode 100644 index 00000000000..1cf89b519b4 --- /dev/null +++ b/server/container/guice/postgres-common/src/test/java/org/apache/james/TestJPAConfigurationModuleWithSqlValidation.java @@ -0,0 +1,108 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james; + +import java.sql.CallableStatement; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; + +import javax.inject.Singleton; + +import org.apache.james.backends.jpa.JPAConfiguration; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; + +public interface TestJPAConfigurationModuleWithSqlValidation { + + class NoDatabaseAuthentication extends AbstractModule { + @Override + protected void configure() { + } + + @Provides + @Singleton + JPAConfiguration provideConfiguration() { + return jpaConfigurationBuilder().build(); + } + } + + class WithDatabaseAuthentication extends AbstractModule { + + @Override + protected void configure() { + setupAuthenticationOnDerby(); + } + + @Provides + @Singleton + JPAConfiguration provideConfiguration() { + return jpaConfigurationBuilder() + .username(DATABASE_USERNAME) + .password(DATABASE_PASSWORD) + .build(); + } + + private void setupAuthenticationOnDerby() { + try (Connection conn = DriverManager.getConnection(JDBC_EMBEDDED_URL, DATABASE_USERNAME, DATABASE_PASSWORD)) { + // Setting and Confirming requireAuthentication + setDerbyProperty(conn, "derby.connection.requireAuthentication", "true"); + + // Setting authentication scheme and username password to Derby + setDerbyProperty(conn, "derby.authentication.provider", "BUILTIN"); + setDerbyProperty(conn, "derby.user." + DATABASE_USERNAME + "", DATABASE_PASSWORD); + setDerbyProperty(conn, "derby.database.propertiesOnly", "true"); + + // Setting default connection mode to no access to restrict accesses without authentication information + setDerbyProperty(conn, "derby.database.defaultConnectionMode", "noAccess"); + setDerbyProperty(conn, "derby.database.fullAccessUsers", DATABASE_USERNAME); + setDerbyProperty(conn, "derby.database.propertiesOnly", "false"); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + private void setDerbyProperty(Connection conn, String key, String value) { + try (CallableStatement call = conn.prepareCall("CALL SYSCS_UTIL.SYSCS_SET_DATABASE_PROPERTY(?, ?)")) { + call.setString(1, key); + call.setString(2, value); + call.execute(); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + } + + String DATABASE_USERNAME = "james"; + String DATABASE_PASSWORD = "james-secret"; + String JDBC_EMBEDDED_URL = "jdbc:derby:memory:mailboxintegration;create=true"; + String JDBC_EMBEDDED_DRIVER = org.apache.derby.jdbc.EmbeddedDriver.class.getName(); + String VALIDATION_SQL_QUERY = "VALUES 1"; + + static JPAConfiguration.ReadyToBuild jpaConfigurationBuilder() { + return JPAConfiguration.builder() + .driverName(JDBC_EMBEDDED_DRIVER) + .driverURL(JDBC_EMBEDDED_URL) + .testOnBorrow(true) + .validationQueryTimeoutSec(2) + .validationQuery(VALIDATION_SQL_QUERY); + } +} From 41b4b37cd06d70fd37d8571938d372bfb059774f Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Tue, 31 Oct 2023 10:28:31 +0700 Subject: [PATCH 025/341] JAMES-2586 - Postgres - Init james-serrver-guice-mailbox-postgres --- .../container/guice/mailbox-postgres/pom.xml | 79 +++++++++ .../modules/mailbox/JPAMailboxModule.java | 152 ++++++++++++++++++ .../modules/mailbox/JPAQuotaSearchModule.java | 34 ++++ .../james/modules/mailbox/JpaQuotaModule.java | 62 +++++++ .../mailbox/LuceneSearchMailboxModule.java | 58 +++++++ server/container/guice/pom.xml | 17 ++ 6 files changed, 402 insertions(+) create mode 100644 server/container/guice/mailbox-postgres/pom.xml create mode 100644 server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JPAMailboxModule.java create mode 100644 server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JPAQuotaSearchModule.java create mode 100644 server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JpaQuotaModule.java create mode 100644 server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/LuceneSearchMailboxModule.java diff --git a/server/container/guice/mailbox-postgres/pom.xml b/server/container/guice/mailbox-postgres/pom.xml new file mode 100644 index 00000000000..e07ac357ace --- /dev/null +++ b/server/container/guice/mailbox-postgres/pom.xml @@ -0,0 +1,79 @@ + + + + + 4.0.0 + + + org.apache.james + james-server-guice + 3.9.0-SNAPSHOT + ../pom.xml + + + james-server-guice-mailbox-postgres + jar + Apache James :: Server :: Postgres - Guice injection + + + + ${james.groupId} + apache-james-mailbox-lucene + + + ${james.groupId} + apache-james-mailbox-postgres + + + ${james.groupId} + apache-james-mailbox-quota-search-scanning + + + ${james.groupId} + james-server-data-postgres + + + ${james.groupId} + james-server-guice-mailbox + + + ${james.groupId} + james-server-guice-webadmin-data + + + ${james.groupId} + james-server-mailbox-adapter + + + ${james.groupId} + james-server-postgres-common-guice + + + ${james.groupId} + testing-base + test + + + com.google.inject + guice + + + + diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JPAMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JPAMailboxModule.java new file mode 100644 index 00000000000..230c13e4a38 --- /dev/null +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JPAMailboxModule.java @@ -0,0 +1,152 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.modules.mailbox; + +import static org.apache.james.modules.Names.MAILBOXMANAGER_NAME; + +import javax.inject.Singleton; + +import org.apache.james.adapter.mailbox.ACLUsernameChangeTaskStep; +import org.apache.james.adapter.mailbox.MailboxUserDeletionTaskStep; +import org.apache.james.adapter.mailbox.MailboxUsernameChangeTaskStep; +import org.apache.james.adapter.mailbox.QuotaUsernameChangeTaskStep; +import org.apache.james.adapter.mailbox.UserRepositoryAuthenticator; +import org.apache.james.adapter.mailbox.UserRepositoryAuthorizator; +import org.apache.james.events.EventListener; +import org.apache.james.mailbox.AttachmentContentLoader; +import org.apache.james.mailbox.Authenticator; +import org.apache.james.mailbox.Authorizator; +import org.apache.james.mailbox.MailboxManager; +import org.apache.james.mailbox.MailboxPathLocker; +import org.apache.james.mailbox.SessionProvider; +import org.apache.james.mailbox.SubscriptionManager; +import org.apache.james.mailbox.acl.MailboxACLResolver; +import org.apache.james.mailbox.acl.UnionMailboxACLResolver; +import org.apache.james.mailbox.indexer.ReIndexer; +import org.apache.james.mailbox.jpa.JPAAttachmentContentLoader; +import org.apache.james.mailbox.jpa.JPAId; +import org.apache.james.mailbox.jpa.JPAMailboxSessionMapperFactory; +import org.apache.james.mailbox.jpa.mail.JPAModSeqProvider; +import org.apache.james.mailbox.jpa.mail.JPAUidProvider; +import org.apache.james.mailbox.jpa.openjpa.OpenJPAMailboxManager; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.store.JVMMailboxPathLocker; +import org.apache.james.mailbox.store.MailboxManagerConfiguration; +import org.apache.james.mailbox.store.MailboxSessionMapperFactory; +import org.apache.james.mailbox.store.SessionProviderImpl; +import org.apache.james.mailbox.store.StoreMailboxManager; +import org.apache.james.mailbox.store.StoreSubscriptionManager; +import org.apache.james.mailbox.store.event.MailboxAnnotationListener; +import org.apache.james.mailbox.store.event.MailboxSubscriptionListener; +import org.apache.james.mailbox.store.mail.MailboxMapperFactory; +import org.apache.james.mailbox.store.mail.MessageMapperFactory; +import org.apache.james.mailbox.store.mail.ModSeqProvider; +import org.apache.james.mailbox.store.mail.NaiveThreadIdGuessingAlgorithm; +import org.apache.james.mailbox.store.mail.ThreadIdGuessingAlgorithm; +import org.apache.james.mailbox.store.mail.UidProvider; +import org.apache.james.mailbox.store.mail.model.DefaultMessageId; +import org.apache.james.mailbox.store.user.SubscriptionMapperFactory; +import org.apache.james.modules.data.JPAEntityManagerModule; +import org.apache.james.user.api.DeleteUserDataTaskStep; +import org.apache.james.user.api.UsernameChangeTaskStep; +import org.apache.james.utils.MailboxManagerDefinition; +import org.apache.mailbox.tools.indexer.ReIndexerImpl; + +import com.google.inject.AbstractModule; +import com.google.inject.Inject; +import com.google.inject.Scopes; +import com.google.inject.multibindings.Multibinder; +import com.google.inject.name.Names; + +public class JPAMailboxModule extends AbstractModule { + + @Override + protected void configure() { + install(new JpaQuotaModule()); + install(new JPAQuotaSearchModule()); + install(new JPAEntityManagerModule()); + + bind(JPAMailboxSessionMapperFactory.class).in(Scopes.SINGLETON); + bind(OpenJPAMailboxManager.class).in(Scopes.SINGLETON); + bind(JVMMailboxPathLocker.class).in(Scopes.SINGLETON); + bind(StoreSubscriptionManager.class).in(Scopes.SINGLETON); + bind(JPAModSeqProvider.class).in(Scopes.SINGLETON); + bind(JPAUidProvider.class).in(Scopes.SINGLETON); + bind(UserRepositoryAuthenticator.class).in(Scopes.SINGLETON); + bind(UserRepositoryAuthorizator.class).in(Scopes.SINGLETON); + bind(JPAId.Factory.class).in(Scopes.SINGLETON); + bind(UnionMailboxACLResolver.class).in(Scopes.SINGLETON); + bind(DefaultMessageId.Factory.class).in(Scopes.SINGLETON); + bind(NaiveThreadIdGuessingAlgorithm.class).in(Scopes.SINGLETON); + bind(ReIndexerImpl.class).in(Scopes.SINGLETON); + bind(SessionProviderImpl.class).in(Scopes.SINGLETON); + + bind(SubscriptionMapperFactory.class).to(JPAMailboxSessionMapperFactory.class); + bind(MessageMapperFactory.class).to(JPAMailboxSessionMapperFactory.class); + bind(MailboxMapperFactory.class).to(JPAMailboxSessionMapperFactory.class); + bind(MailboxSessionMapperFactory.class).to(JPAMailboxSessionMapperFactory.class); + bind(MessageId.Factory.class).to(DefaultMessageId.Factory.class); + bind(ThreadIdGuessingAlgorithm.class).to(NaiveThreadIdGuessingAlgorithm.class); + + bind(ModSeqProvider.class).to(JPAModSeqProvider.class); + bind(UidProvider.class).to(JPAUidProvider.class); + bind(SubscriptionManager.class).to(StoreSubscriptionManager.class); + bind(MailboxPathLocker.class).to(JVMMailboxPathLocker.class); + bind(Authenticator.class).to(UserRepositoryAuthenticator.class); + bind(MailboxManager.class).to(OpenJPAMailboxManager.class); + bind(StoreMailboxManager.class).to(OpenJPAMailboxManager.class); + bind(SessionProvider.class).to(SessionProviderImpl.class); + bind(Authorizator.class).to(UserRepositoryAuthorizator.class); + bind(MailboxId.Factory.class).to(JPAId.Factory.class); + bind(MailboxACLResolver.class).to(UnionMailboxACLResolver.class); + bind(AttachmentContentLoader.class).to(JPAAttachmentContentLoader.class); + + bind(ReIndexer.class).to(ReIndexerImpl.class); + + Multibinder.newSetBinder(binder(), MailboxManagerDefinition.class).addBinding().to(JPAMailboxManagerDefinition.class); + + Multibinder.newSetBinder(binder(), EventListener.GroupEventListener.class) + .addBinding() + .to(MailboxAnnotationListener.class); + + Multibinder.newSetBinder(binder(), EventListener.GroupEventListener.class) + .addBinding() + .to(MailboxSubscriptionListener.class); + + bind(MailboxManager.class).annotatedWith(Names.named(MAILBOXMANAGER_NAME)).to(MailboxManager.class); + bind(MailboxManagerConfiguration.class).toInstance(MailboxManagerConfiguration.DEFAULT); + + Multibinder usernameChangeTaskStepMultibinder = Multibinder.newSetBinder(binder(), UsernameChangeTaskStep.class); + usernameChangeTaskStepMultibinder.addBinding().to(MailboxUsernameChangeTaskStep.class); + usernameChangeTaskStepMultibinder.addBinding().to(ACLUsernameChangeTaskStep.class); + usernameChangeTaskStepMultibinder.addBinding().to(QuotaUsernameChangeTaskStep.class); + + Multibinder deleteUserDataTaskStepMultibinder = Multibinder.newSetBinder(binder(), DeleteUserDataTaskStep.class); + deleteUserDataTaskStepMultibinder.addBinding().to(MailboxUserDeletionTaskStep.class); + } + + @Singleton + private static class JPAMailboxManagerDefinition extends MailboxManagerDefinition { + @Inject + private JPAMailboxManagerDefinition(OpenJPAMailboxManager manager) { + super("jpa-mailboxmanager", manager); + } + } +} \ No newline at end of file diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JPAQuotaSearchModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JPAQuotaSearchModule.java new file mode 100644 index 00000000000..dbb0e3f90b7 --- /dev/null +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JPAQuotaSearchModule.java @@ -0,0 +1,34 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.mailbox; + +import org.apache.james.quota.search.QuotaSearcher; +import org.apache.james.quota.search.scanning.ScanningQuotaSearcher; + +import com.google.inject.AbstractModule; +import com.google.inject.Scopes; + +public class JPAQuotaSearchModule extends AbstractModule { + @Override + protected void configure() { + bind(ScanningQuotaSearcher.class).in(Scopes.SINGLETON); + bind(QuotaSearcher.class).to(ScanningQuotaSearcher.class); + } +} diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JpaQuotaModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JpaQuotaModule.java new file mode 100644 index 00000000000..e12ea9b44ad --- /dev/null +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JpaQuotaModule.java @@ -0,0 +1,62 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.mailbox; + +import org.apache.james.events.EventListener; +import org.apache.james.mailbox.jpa.quota.JPAPerUserMaxQuotaManager; +import org.apache.james.mailbox.jpa.quota.JpaCurrentQuotaManager; +import org.apache.james.mailbox.quota.CurrentQuotaManager; +import org.apache.james.mailbox.quota.MaxQuotaManager; +import org.apache.james.mailbox.quota.QuotaManager; +import org.apache.james.mailbox.quota.QuotaRootDeserializer; +import org.apache.james.mailbox.quota.QuotaRootResolver; +import org.apache.james.mailbox.quota.UserQuotaRootResolver; +import org.apache.james.mailbox.store.quota.DefaultUserQuotaRootResolver; +import org.apache.james.mailbox.store.quota.ListeningCurrentQuotaUpdater; +import org.apache.james.mailbox.store.quota.QuotaUpdater; +import org.apache.james.mailbox.store.quota.StoreQuotaManager; + +import com.google.inject.AbstractModule; +import com.google.inject.Scopes; +import com.google.inject.multibindings.Multibinder; + +public class JpaQuotaModule extends AbstractModule { + + @Override + protected void configure() { + bind(DefaultUserQuotaRootResolver.class).in(Scopes.SINGLETON); + bind(JPAPerUserMaxQuotaManager.class).in(Scopes.SINGLETON); + bind(StoreQuotaManager.class).in(Scopes.SINGLETON); + bind(JpaCurrentQuotaManager.class).in(Scopes.SINGLETON); + + bind(UserQuotaRootResolver.class).to(DefaultUserQuotaRootResolver.class); + bind(QuotaRootResolver.class).to(DefaultUserQuotaRootResolver.class); + bind(QuotaRootDeserializer.class).to(DefaultUserQuotaRootResolver.class); + bind(MaxQuotaManager.class).to(JPAPerUserMaxQuotaManager.class); + bind(QuotaManager.class).to(StoreQuotaManager.class); + bind(CurrentQuotaManager.class).to(JpaCurrentQuotaManager.class); + + bind(ListeningCurrentQuotaUpdater.class).in(Scopes.SINGLETON); + bind(QuotaUpdater.class).to(ListeningCurrentQuotaUpdater.class); + Multibinder.newSetBinder(binder(), EventListener.ReactiveGroupEventListener.class) + .addBinding() + .to(ListeningCurrentQuotaUpdater.class); + } +} diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/LuceneSearchMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/LuceneSearchMailboxModule.java new file mode 100644 index 00000000000..71a2bc741ec --- /dev/null +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/LuceneSearchMailboxModule.java @@ -0,0 +1,58 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.mailbox; + +import java.io.IOException; + +import org.apache.james.events.EventListener; +import org.apache.james.filesystem.api.FileSystem; +import org.apache.james.mailbox.lucene.search.LuceneMessageSearchIndex; +import org.apache.james.mailbox.store.search.ListeningMessageSearchIndex; +import org.apache.james.mailbox.store.search.MessageSearchIndex; +import org.apache.lucene.store.Directory; +import org.apache.lucene.store.FSDirectory; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import com.google.inject.Scopes; +import com.google.inject.Singleton; +import com.google.inject.multibindings.Multibinder; + +public class LuceneSearchMailboxModule extends AbstractModule { + + @Override + protected void configure() { + install(new ReIndexingTaskSerializationModule()); + + bind(LuceneMessageSearchIndex.class).in(Scopes.SINGLETON); + bind(MessageSearchIndex.class).to(LuceneMessageSearchIndex.class); + bind(ListeningMessageSearchIndex.class).to(LuceneMessageSearchIndex.class); + + Multibinder.newSetBinder(binder(), EventListener.ReactiveGroupEventListener.class) + .addBinding() + .to(LuceneMessageSearchIndex.class); + } + + @Provides + @Singleton + Directory provideDirectory(FileSystem fileSystem) throws IOException { + return FSDirectory.open(fileSystem.getBasedir()); + } +} diff --git a/server/container/guice/pom.xml b/server/container/guice/pom.xml index e5b21e3df4c..40a437596f7 100644 --- a/server/container/guice/pom.xml +++ b/server/container/guice/pom.xml @@ -49,6 +49,7 @@ mailbox mailbox-jpa mailbox-plugin-deleted-messages-vault + mailbox-postgres mailet mailrepository-blob mailrepository-cassandra @@ -151,6 +152,11 @@ james-server-guice-mailbox-jpa ${project.version} + + ${james.groupId} + james-server-guice-mailbox-postgres + ${project.version} + ${james.groupId} james-server-guice-mailet @@ -247,6 +253,17 @@ ${project.version} test-jar + + ${james.groupId} + james-server-postgres-common-guice + ${project.version} + + + ${james.groupId} + james-server-postgres-common-guice + ${project.version} + test-jar + ${james.groupId} mailrepository-blob From 89e4fe44ae2c1b6f635c2ec473941732e404e1ca Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Tue, 31 Oct 2023 19:04:11 +0700 Subject: [PATCH 026/341] JAMES-2586 - Implement PostgresTableManager --- backends-common/postgres/pom.xml | 5 + .../backends/postgres/PostgresIndex.java | 63 ++++ .../backends/postgres/PostgresModule.java | 129 +++++++ .../backends/postgres/PostgresTable.java | 65 ++++ .../postgres/PostgresTableManager.java | 80 ++++ .../backends/postgres/PostgresFixture.java | 46 +++ .../postgres/PostgresTableManagerTest.java | 343 ++++++++++++++++++ 7 files changed, 731 insertions(+) create mode 100644 backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresIndex.java create mode 100644 backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresModule.java create mode 100644 backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java create mode 100644 backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java create mode 100644 backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresFixture.java create mode 100644 backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java diff --git a/backends-common/postgres/pom.xml b/backends-common/postgres/pom.xml index 90574336a71..7587adcf5c7 100644 --- a/backends-common/postgres/pom.xml +++ b/backends-common/postgres/pom.xml @@ -68,6 +68,11 @@ r2dbc-postgresql ${r2dbc.postgresql.version} + + org.testcontainers + junit-jupiter + test + org.testcontainers postgresql diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresIndex.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresIndex.java new file mode 100644 index 00000000000..db41be4e356 --- /dev/null +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresIndex.java @@ -0,0 +1,63 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres; + +import java.util.function.Function; + +import org.jooq.DDLQuery; +import org.jooq.DSLContext; + +import com.google.common.base.Preconditions; + +public class PostgresIndex { + + @FunctionalInterface + public interface RequireCreateIndexStep { + PostgresIndex createIndexStep(CreateIndexFunction createIndexFunction); + } + + @FunctionalInterface + public interface CreateIndexFunction { + DDLQuery createIndex(DSLContext dsl, String indexName); + } + + public static RequireCreateIndexStep name(String indexName) { + Preconditions.checkNotNull(indexName); + + return createIndexFunction -> new PostgresIndex(indexName, dsl -> createIndexFunction.createIndex(dsl, indexName)); + } + + private final String name; + private final Function createIndexStepFunction; + + private PostgresIndex(String name, Function createIndexStepFunction) { + this.name = name; + this.createIndexStepFunction = createIndexStepFunction; + } + + public String getName() { + return name; + } + + public Function getCreateIndexStepFunction() { + return createIndexStepFunction; + } + +} diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresModule.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresModule.java new file mode 100644 index 00000000000..6df91b894ea --- /dev/null +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresModule.java @@ -0,0 +1,129 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres; + + +import java.util.List; + +import com.google.common.collect.ImmutableList; + +public interface PostgresModule { + + static PostgresModule aggregateModules(PostgresModule... modules) { + return builder() + .modules(modules) + .build(); + } + + static PostgresModule aggregateModules(List modules) { + return builder() + .modules(modules) + .build(); + } + + PostgresModule EMPTY_MODULE = builder().build(); + + List tables(); + + List tableIndexes(); + + class Impl implements PostgresModule { + private final List tables; + private final List tableIndexes; + + private Impl(List tables, List tableIndexes) { + this.tables = tables; + this.tableIndexes = tableIndexes; + } + + @Override + public List tables() { + return tables; + } + + @Override + public List tableIndexes() { + return tableIndexes; + } + } + + class Builder { + private final ImmutableList.Builder tables; + private final ImmutableList.Builder tableIndexes; + + public Builder() { + tables = ImmutableList.builder(); + tableIndexes = ImmutableList.builder(); + } + + public Builder addTable(PostgresTable... table) { + tables.add(table); + return this; + } + + public Builder addIndex(PostgresIndex... index) { + tableIndexes.add(index); + return this; + } + + public Builder addTable(List tables) { + this.tables.addAll(tables); + return this; + } + + public Builder addIndex(List indexes) { + this.tableIndexes.addAll(indexes); + return this; + } + + public Builder modules(List modules) { + modules.forEach(module -> { + addTable(module.tables()); + addIndex(module.tableIndexes()); + }); + return this; + } + + public Builder modules(PostgresModule... modules) { + return modules(ImmutableList.copyOf(modules)); + } + + public PostgresModule build() { + return new Impl(tables.build(), tableIndexes.build()); + } + } + + static Builder builder() { + return new Builder(); + } + + static PostgresModule table(PostgresTable... tables) { + return builder() + .addTable(ImmutableList.copyOf(tables)) + .build(); + } + + static PostgresModule tableIndex(PostgresIndex... tableIndexes) { + return builder() + .addIndex(ImmutableList.copyOf(tableIndexes)) + .build(); + } + +} diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java new file mode 100644 index 00000000000..0e8c22ed43d --- /dev/null +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java @@ -0,0 +1,65 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres; + +import java.util.function.Function; + +import org.jooq.DDLQuery; +import org.jooq.DSLContext; + +import com.google.common.base.Preconditions; + +public class PostgresTable { + + @FunctionalInterface + public interface RequireCreateTableStep { + PostgresTable createTableStep(CreateTableFunction createTableFunction); + } + + + @FunctionalInterface + public interface CreateTableFunction { + DDLQuery createTable(DSLContext dsl, String tableName); + } + + public static RequireCreateTableStep name(String tableName) { + Preconditions.checkNotNull(tableName); + + return createTableFunction -> new PostgresTable(tableName, dsl -> createTableFunction.createTable(dsl, tableName)); + } + + private final String name; + private final Function createTableStepFunction; + + private PostgresTable(String name, Function createTableStepFunction) { + this.name = name; + this.createTableStepFunction = createTableStepFunction; + } + + + public String getName() { + return name; + } + + public Function getCreateTableStepFunction() { + return createTableStepFunction; + } + +} diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java new file mode 100644 index 00000000000..23749fed727 --- /dev/null +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java @@ -0,0 +1,80 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.jooq.exception.DataAccessException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresTableManager { + private static final Logger LOGGER = LoggerFactory.getLogger(PostgresTableManager.class); + private final PostgresExecutor postgresExecutor; + private final PostgresModule module; + + public PostgresTableManager(PostgresExecutor postgresExecutor, PostgresModule module) { + this.postgresExecutor = postgresExecutor; + this.module = module; + } + + public Mono initializeTables() { + return postgresExecutor.dslContext() + .flatMap(dsl -> Flux.fromIterable(module.tables()) + .flatMap(table -> Mono.from(table.getCreateTableStepFunction().apply(dsl)) + .doOnSuccess(any -> LOGGER.info("Table {} created", table.getName())) + .onErrorResume(DataAccessException.class, exception -> { + if (exception.getMessage().contains(String.format("\"%s\" already exists", table.getName()))) { + LOGGER.info("Table {} already exists", table.getName()); + return Mono.empty(); + } + return Mono.error(exception); + }) + .doOnError(e -> LOGGER.error("Error while creating table {}", table.getName(), e))) + .then()); + } + + public Mono truncate() { + return postgresExecutor.dslContext() + .flatMap(dsl -> Flux.fromIterable(module.tables()) + .flatMap(table -> Mono.from(dsl.truncateTable(table.getName())) + .doOnSuccess(any -> LOGGER.info("Table {} truncated", table.getName())) + .doOnError(e -> LOGGER.error("Error while truncating table {}", table.getName(), e))) + .then()); + } + + public Mono initializeTableIndexes() { + return postgresExecutor.dslContext() + .flatMap(dsl -> Flux.fromIterable(module.tableIndexes()) + .concatMap(index -> Mono.from(index.getCreateIndexStepFunction().apply(dsl)) + .doOnSuccess(any -> LOGGER.info("Index {} created", index.getName())) + .onErrorResume(DataAccessException.class, exception -> { + if (exception.getMessage().contains(String.format("\"%s\" already exists", index.getName()))) { + LOGGER.info("Index {} already exists", index.getName()); + return Mono.empty(); + } + return Mono.error(exception); + }) + .doOnError(e -> LOGGER.error("Error while creating index {}", index.getName(), e))) + .then()); + } +} diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresFixture.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresFixture.java new file mode 100644 index 00000000000..813e9d73a3e --- /dev/null +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresFixture.java @@ -0,0 +1,46 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres; + +import static org.testcontainers.containers.PostgreSQLContainer.POSTGRESQL_PORT; + +import java.util.UUID; +import java.util.function.Supplier; + +import org.testcontainers.containers.PostgreSQLContainer; + +public interface PostgresFixture { + + interface Database { + String DB_USER = "james"; + String DB_PASSWORD = "secret1"; + String DB_NAME = "james"; + String SCHEMA = "public"; + } + + String IMAGE = "postgres:16.0"; + Integer PORT = POSTGRESQL_PORT; + + Supplier> PG_CONTAINER = () -> new PostgreSQLContainer<>(IMAGE) + .withDatabaseName(Database.DB_NAME) + .withUsername(Database.DB_USER) + .withPassword(Database.DB_PASSWORD) + .withCreateContainerCmdModifier(cmd -> cmd.withName("james-postgres-test-" + UUID.randomUUID())); +} diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java new file mode 100644 index 00000000000..3a853fbe54d --- /dev/null +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java @@ -0,0 +1,343 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import java.util.List; +import java.util.function.Function; +import java.util.function.Supplier; + +import org.apache.commons.lang3.tuple.Pair; +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import io.r2dbc.postgresql.PostgresqlConnectionConfiguration; +import io.r2dbc.postgresql.PostgresqlConnectionFactory; +import io.r2dbc.postgresql.api.PostgresqlResult; +import io.r2dbc.spi.Connection; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@Testcontainers +public class PostgresTableManagerTest { + + @Container + private static final GenericContainer pgContainer = PostgresFixture.PG_CONTAINER.get(); + + private PostgresqlConnectionFactory connectionFactory; + + @BeforeEach + void beforeAll() { + connectionFactory = new PostgresqlConnectionFactory(PostgresqlConnectionConfiguration.builder() + .host(pgContainer.getHost()) + .port(pgContainer.getMappedPort(PostgresFixture.PORT)) + .username(PostgresFixture.Database.DB_USER) + .password(PostgresFixture.Database.DB_PASSWORD) + .database(PostgresFixture.Database.DB_NAME) + .schema(PostgresFixture.Database.SCHEMA) + .build()); + } + + @AfterEach + void afterEach() { + // clean data + Flux.usingWhen(connectionFactory.create(), + connection -> Mono.from(connection.createStatement("DROP SCHEMA " + PostgresFixture.Database.SCHEMA + " CASCADE").execute()) + .then(Mono.from(connection.createStatement("CREATE SCHEMA " + PostgresFixture.Database.SCHEMA).execute())) + .flatMap(PostgresqlResult::getRowsUpdated), + Connection::close) + .collectList() + .block(); + } + + Function tableManagerFactory = module -> new PostgresTableManager(new PostgresExecutor(connectionFactory.create() + .map(c -> c)), module); + + @Test + void initializeTableShouldSuccessWhenModuleHasSingleTable() { + String tableName = "tableName1"; + + PostgresTable table = PostgresTable.name(tableName) + .createTableStep((dsl, tbn) -> dsl.createTable(tbn) + .column("colum1", SQLDataType.UUID.notNull()) + .column("colum2", SQLDataType.INTEGER) + .column("colum3", SQLDataType.VARCHAR(255).notNull())); + + PostgresModule module = PostgresModule.table(table); + + PostgresTableManager testee = tableManagerFactory.apply(module); + + testee.initializeTables() + .block(); + + assertThat(getColumnNameAndDataType(tableName)) + .containsExactlyInAnyOrder( + Pair.of("colum1", "uuid"), + Pair.of("colum2", "integer"), + Pair.of("colum3", "character varying")); + } + + @Test + void initializeTableShouldSuccessWhenModuleHasMultiTables() { + String tableName1 = "tableName1"; + + PostgresTable table1 = PostgresTable.name(tableName1) + .createTableStep((dsl, tbn) -> dsl.createTable(tbn) + .column("columA", SQLDataType.UUID.notNull())); + + String tableName2 = "tableName2"; + PostgresTable table2 = PostgresTable.name(tableName2) + .createTableStep((dsl, tbn) -> dsl.createTable(tbn) + .column("columB", SQLDataType.INTEGER)); + + PostgresTableManager testee = tableManagerFactory.apply(PostgresModule.table(table1, table2)); + + testee.initializeTables() + .block(); + + assertThat(getColumnNameAndDataType(tableName1)) + .containsExactlyInAnyOrder( + Pair.of("columA", "uuid")); + assertThat(getColumnNameAndDataType(tableName2)) + .containsExactlyInAnyOrder( + Pair.of("columB", "integer")); + } + + @Test + void initializeTableShouldNotThrowWhenTableExists() { + String tableName1 = "tableName1"; + + PostgresTable table1 = PostgresTable.name(tableName1) + .createTableStep((dsl, tbn) -> dsl.createTable(tbn) + .column("columA", SQLDataType.UUID.notNull())); + + PostgresTableManager testee = tableManagerFactory.apply(PostgresModule.table(table1)); + + testee.initializeTables() + .block(); + + assertThatCode(() -> testee.initializeTables().block()) + .doesNotThrowAnyException(); + } + + @Test + void initializeTableShouldNotChangeTableStructureOfExistTable() { + String tableName1 = "tableName1"; + PostgresTable table1 = PostgresTable.name(tableName1) + .createTableStep((dsl, tbn) -> dsl.createTable(tbn) + .column("columA", SQLDataType.UUID.notNull())); + + tableManagerFactory.apply(PostgresModule.table(table1)) + .initializeTables() + .block(); + + PostgresTable table1Changed = PostgresTable.name(tableName1) + .createTableStep((dsl, tbn) -> dsl.createTable(tbn) + .column("columB", SQLDataType.INTEGER)); + + tableManagerFactory.apply(PostgresModule.table(table1Changed)) + .initializeTables() + .block(); + + assertThat(getColumnNameAndDataType(tableName1)) + .containsExactlyInAnyOrder( + Pair.of("columA", "uuid")); + } + + @Test + void initializeIndexShouldSuccessWhenModuleHasSingleIndex() { + String tableName = "tb_test_1"; + + PostgresTable table = PostgresTable.name(tableName) + .createTableStep((dsl, tbn) -> dsl.createTable(tbn) + .column("colum1", SQLDataType.UUID.notNull()) + .column("colum2", SQLDataType.INTEGER) + .column("colum3", SQLDataType.VARCHAR(255).notNull())); + + String indexName = "idx_test_1"; + PostgresIndex index = PostgresIndex.name(indexName) + .createIndexStep((dsl, idn) -> dsl.createIndex(idn) + .on(DSL.table(tableName), DSL.field("colum1").asc())); + + PostgresModule module = PostgresModule.builder() + .addTable(table) + .addIndex(index) + .build(); + + PostgresTableManager testee = tableManagerFactory.apply(module); + + testee.initializeTables().block(); + + testee.initializeTableIndexes().block(); + + List> listIndexes = listIndexes(); + + assertThat(listIndexes) + .contains(Pair.of(indexName, tableName)); + } + + @Test + void initializeIndexShouldSuccessWhenModuleHasMultiIndexes() { + String tableName = "tb_test_1"; + + PostgresTable table = PostgresTable.name(tableName) + .createTableStep((dsl, tbn) -> dsl.createTable(tbn) + .column("colum1", SQLDataType.UUID.notNull()) + .column("colum2", SQLDataType.INTEGER) + .column("colum3", SQLDataType.VARCHAR(255).notNull())); + + String indexName1 = "idx_test_1"; + PostgresIndex index1 = PostgresIndex.name(indexName1) + .createIndexStep((dsl, idn) -> dsl.createIndex(idn) + .on(DSL.table(tableName), DSL.field("colum1").asc())); + + String indexName2 = "idx_test_2"; + PostgresIndex index2 = PostgresIndex.name(indexName2) + .createIndexStep((dsl, idn) -> dsl.createIndex(idn) + .on(DSL.table(tableName), DSL.field("colum2").desc())); + + PostgresModule module = PostgresModule.builder() + .addTable(table) + .addIndex(index1, index2) + .build(); + + PostgresTableManager testee = tableManagerFactory.apply(module); + + testee.initializeTables().block(); + + testee.initializeTableIndexes().block(); + + List> listIndexes = listIndexes(); + + assertThat(listIndexes) + .contains(Pair.of(indexName1, tableName), Pair.of(indexName2, tableName)); + } + + @Test + void initializeIndexShouldNotThrowWhenIndexExists() { + String tableName = "tb_test_1"; + + PostgresTable table = PostgresTable.name(tableName) + .createTableStep((dsl, tbn) -> dsl.createTable(tbn) + .column("colum1", SQLDataType.UUID.notNull()) + .column("colum2", SQLDataType.INTEGER) + .column("colum3", SQLDataType.VARCHAR(255).notNull())); + + String indexName = "idx_test_1"; + PostgresIndex index = PostgresIndex.name(indexName) + .createIndexStep((dsl, idn) -> dsl.createIndex(idn) + .on(DSL.table(tableName), DSL.field("colum1").asc())); + + PostgresModule module = PostgresModule.builder() + .addTable(table) + .addIndex(index) + .build(); + + PostgresTableManager testee = tableManagerFactory.apply(module); + + testee.initializeTables().block(); + + testee.initializeTableIndexes().block(); + + assertThatCode(() -> testee.initializeTableIndexes().block()) + .doesNotThrowAnyException(); + } + + @Test + void truncateShouldEmptyTableData() { + // Given table tbn1 + String tableName1 = "tbn1"; + PostgresTable table1 = PostgresTable.name(tableName1) + .createTableStep((dsl, tbn) -> dsl.createTable(tbn) + .column("column1", SQLDataType.INTEGER.notNull())); + + PostgresTableManager testee = tableManagerFactory.apply(PostgresModule.table(table1)); + testee.initializeTables() + .block(); + + // insert data + Flux.usingWhen(connectionFactory.create(), + connection -> Flux.range(0, 10) + .flatMap(i -> Mono.from(connection.createStatement("INSERT INTO " + tableName1 + " (column1) VALUES ($1);") + .bind("$1", i) + .execute()) + .flatMap(PostgresqlResult::getRowsUpdated)) + .last(), + Connection::close) + .collectList() + .block(); + + + Supplier getTotalRecordInDB = () -> Flux.usingWhen(connectionFactory.create(), + connection -> Mono.from(connection.createStatement("select count(*) FROM " + tableName1) + .execute()) + .flatMapMany(result -> + result.map((row, rowMetadata) -> row.get("count", Long.class))), + Connection::close) + .last() + .block(); + + assertThat(getTotalRecordInDB.get()).isEqualTo(10L); + + // When truncate table + testee.truncate().block(); + + // Then table is empty + assertThat(getTotalRecordInDB.get()).isEqualTo(0L); + } + + + private List> getColumnNameAndDataType(String tableName) { + return Flux.usingWhen(connectionFactory.create(), + connection -> Mono.from(connection.createStatement("SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_name = $1;") + .bind("$1", tableName) + .execute()) + .flatMapMany(result -> + result.map((row, rowMetadata) -> + Pair.of(row.get("column_name", String.class), + row.get("data_type", String.class)))), + Connection::close) + .collectList() + .block(); + } + + // return list> + private List> listIndexes() { + return Flux.usingWhen(connectionFactory.create(), + connection -> Mono.from(connection.createStatement("SELECT indexname, tablename FROM pg_indexes;") + .execute()) + .flatMapMany(result -> + result.map((row, rowMetadata) -> + Pair.of(row.get("indexname", String.class), row.get("tablename", String.class)))), + Connection::close) + .collectList() + .block(); + } + +} From 2e214910ba01bd9d74b55e730f379c5717ecb7cb Mon Sep 17 00:00:00 2001 From: vttran Date: Thu, 2 Nov 2023 10:00:11 +0700 Subject: [PATCH 027/341] JAMES-2586 PostgresTableManager support create table when enable row level security --- .../backends/postgres/PostgresTable.java | 24 ++++++- .../postgres/PostgresTableManager.java | 24 ++++++- .../postgres/utils/PostgresExecutor.java | 4 ++ .../postgres/PostgresTableManagerTest.java | 65 ++++++++++++++++--- 4 files changed, 102 insertions(+), 15 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java index 0e8c22ed43d..331f530ad74 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java @@ -30,7 +30,7 @@ public class PostgresTable { @FunctionalInterface public interface RequireCreateTableStep { - PostgresTable createTableStep(CreateTableFunction createTableFunction); + RequireRowLevelSecurity createTableStep(CreateTableFunction createTableFunction); } @@ -39,17 +39,32 @@ public interface CreateTableFunction { DDLQuery createTable(DSLContext dsl, String tableName); } + @FunctionalInterface + public interface RequireRowLevelSecurity { + PostgresTable enableRLS(boolean enableRowLevelSecurity); + + default PostgresTable noRLS() { + return enableRLS(false); + } + + default PostgresTable enableRLS() { + return enableRLS(true); + } + } + public static RequireCreateTableStep name(String tableName) { Preconditions.checkNotNull(tableName); - return createTableFunction -> new PostgresTable(tableName, dsl -> createTableFunction.createTable(dsl, tableName)); + return createTableFunction -> enableRLS -> new PostgresTable(tableName, enableRLS, dsl -> createTableFunction.createTable(dsl, tableName)); } private final String name; + private final boolean enableRowLevelSecurity; private final Function createTableStepFunction; - private PostgresTable(String name, Function createTableStepFunction) { + private PostgresTable(String name, boolean enableRowLevelSecurity, Function createTableStepFunction) { this.name = name; + this.enableRowLevelSecurity = enableRowLevelSecurity; this.createTableStepFunction = createTableStepFunction; } @@ -62,4 +77,7 @@ public Function getCreateTableStepFunction() { return createTableStepFunction; } + public boolean isEnableRowLevelSecurity() { + return enableRowLevelSecurity; + } } diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java index 23749fed727..c563b5918bb 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java @@ -24,6 +24,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import io.r2dbc.spi.Result; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -41,10 +42,10 @@ public Mono initializeTables() { return postgresExecutor.dslContext() .flatMap(dsl -> Flux.fromIterable(module.tables()) .flatMap(table -> Mono.from(table.getCreateTableStepFunction().apply(dsl)) + .then(alterTableEnableRLSIfNeed(table)) .doOnSuccess(any -> LOGGER.info("Table {} created", table.getName())) .onErrorResume(DataAccessException.class, exception -> { if (exception.getMessage().contains(String.format("\"%s\" already exists", table.getName()))) { - LOGGER.info("Table {} already exists", table.getName()); return Mono.empty(); } return Mono.error(exception); @@ -53,6 +54,26 @@ public Mono initializeTables() { .then()); } + private Mono alterTableEnableRLSIfNeed(PostgresTable table) { + if (table.isEnableRowLevelSecurity()) { + return alterTableEnableRLS(table); + } + return Mono.empty(); + } + + public Mono alterTableEnableRLS(PostgresTable table) { + return postgresExecutor.connection() + .flatMapMany(con -> con.createStatement(getAlterRLSStatement(table.getName())).execute()) + .flatMap(Result::getRowsUpdated) + .then(); + } + + private String getAlterRLSStatement(String tableName) { + return "SET app.current_domain = ''; ALTER TABLE " + tableName + " ADD DOMAIN varchar(255) not null DEFAULT current_setting('app.current_domain')::text;" + + "ALTER TABLE " + tableName + " ENABLE ROW LEVEL SECURITY; " + + "CREATE POLICY DOMAIN_" + tableName + "_POLICY ON " + tableName + " USING (DOMAIN = current_setting('app.current_domain')::text);"; + } + public Mono truncate() { return postgresExecutor.dslContext() .flatMap(dsl -> Flux.fromIterable(module.tables()) @@ -69,7 +90,6 @@ public Mono initializeTableIndexes() { .doOnSuccess(any -> LOGGER.info("Index {} created", index.getName())) .onErrorResume(DataAccessException.class, exception -> { if (exception.getMessage().contains(String.format("\"%s\" already exists", index.getName()))) { - LOGGER.info("Index {} already exists", index.getName()); return Mono.empty(); } return Mono.error(exception); diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index f3a86d41a3d..81a8cc8d2c1 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -44,4 +44,8 @@ public PostgresExecutor(Mono connection) { public Mono dslContext() { return connection.map(con -> DSL.using(con, PGSQL_DIALECT, SETTINGS)); } + + public Mono connection() { + return connection; + } } diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java index 3a853fbe54d..62eae38316f 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java @@ -87,7 +87,8 @@ void initializeTableShouldSuccessWhenModuleHasSingleTable() { .createTableStep((dsl, tbn) -> dsl.createTable(tbn) .column("colum1", SQLDataType.UUID.notNull()) .column("colum2", SQLDataType.INTEGER) - .column("colum3", SQLDataType.VARCHAR(255).notNull())); + .column("colum3", SQLDataType.VARCHAR(255).notNull())) + .noRLS(); PostgresModule module = PostgresModule.table(table); @@ -109,12 +110,12 @@ void initializeTableShouldSuccessWhenModuleHasMultiTables() { PostgresTable table1 = PostgresTable.name(tableName1) .createTableStep((dsl, tbn) -> dsl.createTable(tbn) - .column("columA", SQLDataType.UUID.notNull())); + .column("columA", SQLDataType.UUID.notNull())).noRLS(); String tableName2 = "tableName2"; PostgresTable table2 = PostgresTable.name(tableName2) .createTableStep((dsl, tbn) -> dsl.createTable(tbn) - .column("columB", SQLDataType.INTEGER)); + .column("columB", SQLDataType.INTEGER)).noRLS(); PostgresTableManager testee = tableManagerFactory.apply(PostgresModule.table(table1, table2)); @@ -135,7 +136,7 @@ void initializeTableShouldNotThrowWhenTableExists() { PostgresTable table1 = PostgresTable.name(tableName1) .createTableStep((dsl, tbn) -> dsl.createTable(tbn) - .column("columA", SQLDataType.UUID.notNull())); + .column("columA", SQLDataType.UUID.notNull())).noRLS(); PostgresTableManager testee = tableManagerFactory.apply(PostgresModule.table(table1)); @@ -151,7 +152,7 @@ void initializeTableShouldNotChangeTableStructureOfExistTable() { String tableName1 = "tableName1"; PostgresTable table1 = PostgresTable.name(tableName1) .createTableStep((dsl, tbn) -> dsl.createTable(tbn) - .column("columA", SQLDataType.UUID.notNull())); + .column("columA", SQLDataType.UUID.notNull())).noRLS(); tableManagerFactory.apply(PostgresModule.table(table1)) .initializeTables() @@ -159,7 +160,7 @@ void initializeTableShouldNotChangeTableStructureOfExistTable() { PostgresTable table1Changed = PostgresTable.name(tableName1) .createTableStep((dsl, tbn) -> dsl.createTable(tbn) - .column("columB", SQLDataType.INTEGER)); + .column("columB", SQLDataType.INTEGER)).noRLS(); tableManagerFactory.apply(PostgresModule.table(table1Changed)) .initializeTables() @@ -178,7 +179,8 @@ void initializeIndexShouldSuccessWhenModuleHasSingleIndex() { .createTableStep((dsl, tbn) -> dsl.createTable(tbn) .column("colum1", SQLDataType.UUID.notNull()) .column("colum2", SQLDataType.INTEGER) - .column("colum3", SQLDataType.VARCHAR(255).notNull())); + .column("colum3", SQLDataType.VARCHAR(255).notNull())) + .noRLS(); String indexName = "idx_test_1"; PostgresIndex index = PostgresIndex.name(indexName) @@ -210,7 +212,8 @@ void initializeIndexShouldSuccessWhenModuleHasMultiIndexes() { .createTableStep((dsl, tbn) -> dsl.createTable(tbn) .column("colum1", SQLDataType.UUID.notNull()) .column("colum2", SQLDataType.INTEGER) - .column("colum3", SQLDataType.VARCHAR(255).notNull())); + .column("colum3", SQLDataType.VARCHAR(255).notNull())) + .noRLS(); String indexName1 = "idx_test_1"; PostgresIndex index1 = PostgresIndex.name(indexName1) @@ -247,7 +250,8 @@ void initializeIndexShouldNotThrowWhenIndexExists() { .createTableStep((dsl, tbn) -> dsl.createTable(tbn) .column("colum1", SQLDataType.UUID.notNull()) .column("colum2", SQLDataType.INTEGER) - .column("colum3", SQLDataType.VARCHAR(255).notNull())); + .column("colum3", SQLDataType.VARCHAR(255).notNull())) + .noRLS(); String indexName = "idx_test_1"; PostgresIndex index = PostgresIndex.name(indexName) @@ -275,7 +279,7 @@ void truncateShouldEmptyTableData() { String tableName1 = "tbn1"; PostgresTable table1 = PostgresTable.name(tableName1) .createTableStep((dsl, tbn) -> dsl.createTable(tbn) - .column("column1", SQLDataType.INTEGER.notNull())); + .column("column1", SQLDataType.INTEGER.notNull())).noRLS(); PostgresTableManager testee = tableManagerFactory.apply(PostgresModule.table(table1)); testee.initializeTables() @@ -312,6 +316,47 @@ void truncateShouldEmptyTableData() { assertThat(getTotalRecordInDB.get()).isEqualTo(0L); } + @Test + void createTableShouldSucceedWhenEnableRLS() { + String tableName = "tbn1"; + + PostgresTable table = PostgresTable.name(tableName) + .createTableStep((dsl, tbn) -> dsl.createTable(tbn) + .column("clm1", SQLDataType.UUID.notNull()) + .column("clm2", SQLDataType.VARCHAR(255).notNull())) + .enableRLS(); + + PostgresModule module = PostgresModule.table(table); + + PostgresTableManager testee = tableManagerFactory.apply(module); + + testee.initializeTables() + .block(); + + assertThat(getColumnNameAndDataType(tableName)) + .containsExactlyInAnyOrder( + Pair.of("clm1", "uuid"), + Pair.of("clm2", "character varying"), + Pair.of("domain", "character varying")); + + List> pgClassCheckResult = Flux.usingWhen(connectionFactory.create(), + connection -> Mono.from(connection.createStatement("select relname, relrowsecurity " + + "from pg_class " + + "where oid = 'tbn1'::regclass;;") + .execute()) + .flatMapMany(result -> + result.map((row, rowMetadata) -> + Pair.of(row.get("relname", String.class), + row.get("relrowsecurity", Boolean.class)))), + Connection::close) + .collectList() + .block(); + + assertThat(pgClassCheckResult) + .containsExactlyInAnyOrder( + Pair.of("tbn1", true)); + } + private List> getColumnNameAndDataType(String tableName) { return Flux.usingWhen(connectionFactory.create(), From a1b514901af6134b8208516167d5dfe70ea521de Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Tue, 7 Nov 2023 10:14:46 +0700 Subject: [PATCH 028/341] [CI] Maven runs test on only postgres modules (postgresql branch) --- Jenkinsfile | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 74eee5cbfff..45686501d11 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -38,6 +38,13 @@ pipeline { MVN_SHOW_TIMESTAMPS="-Dorg.slf4j.simpleLogger.showDateTime=true -Dorg.slf4j.simpleLogger.dateTimeFormat=HH:mm:ss,SSS" CI = true LC_CTYPE = 'en_US.UTF-8' + + POSTGRES_MODULES = 'backends-common/postgres,' + + 'mailbox/postgres,' + + 'server/data/data-postgres,' + + 'server/container/guice/postgres-common,' + + 'server/container/guice/mailbox-postgres,' + + 'server/apps/postgres-app' } tools { @@ -94,7 +101,7 @@ pipeline { stage('Stable Tests') { steps { echo 'Running tests' - sh 'mvn -B -e -fae test ${MVN_SHOW_TIMESTAMPS} -P ci-test ${MVN_LOCAL_REPO_OPT} -Dassembly.skipAssembly=true jacoco:report-aggregate@jacoco-report' + sh 'mvn -B -e -fae test ${MVN_SHOW_TIMESTAMPS} -P ci-test ${MVN_LOCAL_REPO_OPT} -pl ${POSTGRES_MODULES} -Dassembly.skipAssembly=true jacoco:report-aggregate@jacoco-report' } post { always { @@ -115,7 +122,7 @@ pipeline { steps { echo 'Running unstable tests' catchError(buildResult: 'SUCCESS', stageResult: 'FAILURE') { - sh 'mvn -B -e -fae test -Punstable-tests ${MVN_SHOW_TIMESTAMPS} -P ci-test ${MVN_LOCAL_REPO_OPT} -Dassembly.skipAssembly=true' + sh 'mvn -B -e -fae test -Punstable-tests ${MVN_SHOW_TIMESTAMPS} -P ci-test ${MVN_LOCAL_REPO_OPT} -pl ${POSTGRES_MODULES} -Dassembly.skipAssembly=true' } } post { From b015445e59790d890306f98625872a6318d01810 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 1 Nov 2023 17:12:49 +0700 Subject: [PATCH 029/341] JAMES-2586 Introduce PostgresExtension --- backends-common/postgres/pom.xml | 6 + .../postgres/utils/PostgresExecutor.java | 7 + .../postgres/DockerPostgresSingleton.java | 39 ++++++ .../postgres/PostgresClusterExtension.java | 67 --------- .../backends/postgres/PostgresExtension.java | 119 ++++++++++++++++ .../postgres/PostgresExtensionTest.java | 102 ++++++++++++++ .../postgres/PostgresTableManagerTest.java | 130 ++++++------------ 7 files changed, 316 insertions(+), 154 deletions(-) create mode 100644 backends-common/postgres/src/test/java/org/apache/james/backends/postgres/DockerPostgresSingleton.java delete mode 100644 backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresClusterExtension.java create mode 100644 backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java create mode 100644 backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtensionTest.java diff --git a/backends-common/postgres/pom.xml b/backends-common/postgres/pom.xml index 7587adcf5c7..3cf5b72327b 100644 --- a/backends-common/postgres/pom.xml +++ b/backends-common/postgres/pom.xml @@ -39,6 +39,12 @@ ${james.groupId} james-core + + ${james.groupId} + james-server-guice-common + test-jar + test + ${james.groupId} james-server-util diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index 81a8cc8d2c1..78636dc186b 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -26,6 +26,8 @@ import org.jooq.conf.Settings; import org.jooq.impl.DSL; +import com.google.common.annotations.VisibleForTesting; + import io.r2dbc.spi.Connection; import reactor.core.publisher.Mono; @@ -48,4 +50,9 @@ public Mono dslContext() { public Mono connection() { return connection; } + + @VisibleForTesting + public Mono dispose() { + return connection.flatMap(con -> Mono.from(con.close())); + } } diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/DockerPostgresSingleton.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/DockerPostgresSingleton.java new file mode 100644 index 00000000000..21046eb72f0 --- /dev/null +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/DockerPostgresSingleton.java @@ -0,0 +1,39 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.containers.output.OutputFrame; + +public class DockerPostgresSingleton { + private static void displayDockerLog(OutputFrame outputFrame) { + LOGGER.info(outputFrame.getUtf8String().trim()); + } + + private static final Logger LOGGER = LoggerFactory.getLogger(DockerPostgresSingleton.class); + public static final PostgreSQLContainer SINGLETON = PostgresFixture.PG_CONTAINER.get() + .withLogConsumer(DockerPostgresSingleton::displayDockerLog); + + static { + SINGLETON.start(); + } +} diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresClusterExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresClusterExtension.java deleted file mode 100644 index bd2be62669c..00000000000 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresClusterExtension.java +++ /dev/null @@ -1,67 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.backends.postgres; - -import org.junit.jupiter.api.extension.AfterAllCallback; -import org.junit.jupiter.api.extension.AfterEachCallback; -import org.junit.jupiter.api.extension.BeforeAllCallback; -import org.junit.jupiter.api.extension.BeforeEachCallback; -import org.junit.jupiter.api.extension.ExtensionContext; -import org.junit.jupiter.api.extension.ParameterContext; -import org.junit.jupiter.api.extension.ParameterResolutionException; -import org.junit.jupiter.api.extension.ParameterResolver; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.containers.PostgreSQLContainer; - -public class PostgresClusterExtension implements BeforeAllCallback, BeforeEachCallback, AfterAllCallback, AfterEachCallback, ParameterResolver { - - // TODO - private GenericContainer container = new PostgreSQLContainer("postgres:11.1"); - - @Override - public void afterAll(ExtensionContext extensionContext) throws Exception { - - } - - @Override - public void afterEach(ExtensionContext extensionContext) throws Exception { - - } - - @Override - public void beforeAll(ExtensionContext extensionContext) throws Exception { - - } - - @Override - public void beforeEach(ExtensionContext extensionContext) throws Exception { - - } - - @Override - public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { - return false; - } - - @Override - public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { - return null; - } -} diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java new file mode 100644 index 00000000000..0e4ab28fa3c --- /dev/null +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -0,0 +1,119 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres; + +import org.apache.james.GuiceModuleTestExtension; +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.junit.jupiter.api.extension.ExtensionContext; + +import com.google.inject.Module; + +import io.r2dbc.postgresql.PostgresqlConnectionConfiguration; +import io.r2dbc.postgresql.PostgresqlConnectionFactory; +import io.r2dbc.spi.Connection; +import reactor.core.publisher.Mono; + +public class PostgresExtension implements GuiceModuleTestExtension { + private final PostgresModule postgresModule; + private PostgresExecutor postgresExecutor; + + public PostgresExtension(PostgresModule postgresModule) { + this.postgresModule = postgresModule; + } + + public PostgresExtension() { + this(PostgresModule.EMPTY_MODULE); + } + + @Override + public void beforeAll(ExtensionContext extensionContext) { + if (!DockerPostgresSingleton.SINGLETON.isRunning()) { + DockerPostgresSingleton.SINGLETON.start(); + } + initPostgresSession(); + } + + private void initPostgresSession() { + PostgresqlConnectionFactory connectionFactory = new PostgresqlConnectionFactory(PostgresqlConnectionConfiguration.builder() + .host(getHost()) + .port(getMappedPort()) + .username(PostgresFixture.Database.DB_USER) + .password(PostgresFixture.Database.DB_PASSWORD) + .database(PostgresFixture.Database.DB_NAME) + .schema(PostgresFixture.Database.SCHEMA) + .build()); + + postgresExecutor = new PostgresExecutor(connectionFactory.create() + .cache() + .cast(Connection.class)); + } + + @Override + public void afterAll(ExtensionContext extensionContext) { + disposePostgresSession(); + } + + private void disposePostgresSession() { + postgresExecutor.dispose().block(); + } + + @Override + public void beforeEach(ExtensionContext extensionContext) { + initTablesAndIndexes(); + } + + @Override + public void afterEach(ExtensionContext extensionContext) { + resetSchema(); + } + + @Override + public Module getModule() { + // TODO: return PostgresConfiguration bean when doing https://github.com/linagora/james-project/issues/4910 + return GuiceModuleTestExtension.super.getModule(); + } + + public String getHost() { + return DockerPostgresSingleton.SINGLETON.getHost(); + } + + public Integer getMappedPort() { + return DockerPostgresSingleton.SINGLETON.getMappedPort(PostgresFixture.PORT); + } + + public Mono getConnection() { + return postgresExecutor.connection(); + } + + private void initTablesAndIndexes() { + PostgresTableManager postgresTableManager = new PostgresTableManager(postgresExecutor, postgresModule); + postgresTableManager.initializeTables().block(); + postgresTableManager.initializeTableIndexes().block(); + } + + private void resetSchema() { + getConnection() + .flatMapMany(connection -> Mono.from(connection.createStatement("DROP SCHEMA " + PostgresFixture.Database.SCHEMA + " CASCADE").execute()) + .then(Mono.from(connection.createStatement("CREATE SCHEMA " + PostgresFixture.Database.SCHEMA).execute())) + .flatMap(result -> Mono.from(result.getRowsUpdated()))) + .collectList() + .block(); + } +} diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtensionTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtensionTest.java new file mode 100644 index 00000000000..f1593fef215 --- /dev/null +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtensionTest.java @@ -0,0 +1,102 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.apache.commons.lang3.tuple.Pair; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +class PostgresExtensionTest { + static PostgresTable TABLE_1 = PostgresTable.name("table1") + .createTableStep((dslContext, tableName) -> dslContext.createTable(tableName) + .column("column1", SQLDataType.UUID.notNull()) + .column("column2", SQLDataType.INTEGER) + .column("column3", SQLDataType.VARCHAR(255).notNull())) + .noRLS(); + + static PostgresIndex INDEX_1 = PostgresIndex.name("index1") + .createIndexStep((dslContext, indexName) -> dslContext.createIndex(indexName) + .on(DSL.table("table1"), DSL.field("column1").asc())); + + static PostgresTable TABLE_2 = PostgresTable.name("table2") + .createTableStep((dslContext, tableName) -> dslContext.createTable(tableName) + .column("column1", SQLDataType.INTEGER)) + .noRLS(); + + static PostgresIndex INDEX_2 = PostgresIndex.name("index2") + .createIndexStep((dslContext, indexName) -> dslContext.createIndex(indexName) + .on(DSL.table("table2"), DSL.field("column1").desc())); + + static PostgresModule POSTGRES_MODULE = PostgresModule.builder() + .addTable(TABLE_1, TABLE_2) + .addIndex(INDEX_1, INDEX_2) + .build(); + + @RegisterExtension + static PostgresExtension postgresExtension = new PostgresExtension(POSTGRES_MODULE); + + @Test + void postgresExtensionShouldProvisionTablesAndIndexes() { + assertThat(getColumnNameAndDataType("table1")) + .containsExactlyInAnyOrder( + Pair.of("column1", "uuid"), + Pair.of("column2", "integer"), + Pair.of("column3", "character varying")); + + assertThat(getColumnNameAndDataType("table2")) + .containsExactlyInAnyOrder(Pair.of("column1", "integer")); + + assertThat(listIndexToTableMappings()) + .contains( + Pair.of("index1", "table1"), + Pair.of("index2", "table2")); + } + + private List> getColumnNameAndDataType(String tableName) { + return postgresExtension.getConnection() + .flatMapMany(connection -> Flux.from(Mono.from(connection.createStatement("SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_name = $1;") + .bind("$1", tableName) + .execute()) + .flatMapMany(result -> result.map((row, rowMetadata) -> + Pair.of(row.get("column_name", String.class), row.get("data_type", String.class)))))) + .collectList() + .block(); + } + + private List> listIndexToTableMappings() { + return postgresExtension.getConnection() + .flatMapMany(connection -> Mono.from(connection.createStatement("SELECT indexname, tablename FROM pg_indexes;") + .execute()) + .flatMapMany(result -> + result.map((row, rowMetadata) -> + Pair.of(row.get("indexname", String.class), row.get("tablename", String.class))))) + .collectList() + .block(); + } +} diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java index 62eae38316f..007ff246a59 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java @@ -30,54 +30,19 @@ import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.jooq.impl.DSL; import org.jooq.impl.SQLDataType; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; - -import io.r2dbc.postgresql.PostgresqlConnectionConfiguration; -import io.r2dbc.postgresql.PostgresqlConnectionFactory; -import io.r2dbc.postgresql.api.PostgresqlResult; -import io.r2dbc.spi.Connection; +import org.junit.jupiter.api.extension.RegisterExtension; + import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -@Testcontainers -public class PostgresTableManagerTest { - - @Container - private static final GenericContainer pgContainer = PostgresFixture.PG_CONTAINER.get(); - - private PostgresqlConnectionFactory connectionFactory; - - @BeforeEach - void beforeAll() { - connectionFactory = new PostgresqlConnectionFactory(PostgresqlConnectionConfiguration.builder() - .host(pgContainer.getHost()) - .port(pgContainer.getMappedPort(PostgresFixture.PORT)) - .username(PostgresFixture.Database.DB_USER) - .password(PostgresFixture.Database.DB_PASSWORD) - .database(PostgresFixture.Database.DB_NAME) - .schema(PostgresFixture.Database.SCHEMA) - .build()); - } +class PostgresTableManagerTest { - @AfterEach - void afterEach() { - // clean data - Flux.usingWhen(connectionFactory.create(), - connection -> Mono.from(connection.createStatement("DROP SCHEMA " + PostgresFixture.Database.SCHEMA + " CASCADE").execute()) - .then(Mono.from(connection.createStatement("CREATE SCHEMA " + PostgresFixture.Database.SCHEMA).execute())) - .flatMap(PostgresqlResult::getRowsUpdated), - Connection::close) - .collectList() - .block(); - } + @RegisterExtension + static PostgresExtension postgresExtension = new PostgresExtension(); - Function tableManagerFactory = module -> new PostgresTableManager(new PostgresExecutor(connectionFactory.create() - .map(c -> c)), module); + Function tableManagerFactory = + module -> new PostgresTableManager(new PostgresExecutor(postgresExtension.getConnection()), module); @Test void initializeTableShouldSuccessWhenModuleHasSingleTable() { @@ -198,7 +163,7 @@ void initializeIndexShouldSuccessWhenModuleHasSingleIndex() { testee.initializeTableIndexes().block(); - List> listIndexes = listIndexes(); + List> listIndexes = listIndexToTableMappings(); assertThat(listIndexes) .contains(Pair.of(indexName, tableName)); @@ -236,7 +201,7 @@ void initializeIndexShouldSuccessWhenModuleHasMultiIndexes() { testee.initializeTableIndexes().block(); - List> listIndexes = listIndexes(); + List> listIndexes = listIndexToTableMappings(); assertThat(listIndexes) .contains(Pair.of(indexName1, tableName), Pair.of(indexName2, tableName)); @@ -286,24 +251,21 @@ void truncateShouldEmptyTableData() { .block(); // insert data - Flux.usingWhen(connectionFactory.create(), - connection -> Flux.range(0, 10) - .flatMap(i -> Mono.from(connection.createStatement("INSERT INTO " + tableName1 + " (column1) VALUES ($1);") - .bind("$1", i) - .execute()) - .flatMap(PostgresqlResult::getRowsUpdated)) - .last(), - Connection::close) + postgresExtension.getConnection() + .flatMapMany(connection -> Flux.range(0, 10) + .flatMap(i -> Mono.from(connection.createStatement("INSERT INTO " + tableName1 + " (column1) VALUES ($1);") + .bind("$1", i) + .execute()) + .flatMap(result -> Mono.from(result.getRowsUpdated()))) + .last()) .collectList() .block(); - - Supplier getTotalRecordInDB = () -> Flux.usingWhen(connectionFactory.create(), - connection -> Mono.from(connection.createStatement("select count(*) FROM " + tableName1) - .execute()) - .flatMapMany(result -> - result.map((row, rowMetadata) -> row.get("count", Long.class))), - Connection::close) + Supplier getTotalRecordInDB = () -> postgresExtension.getConnection() + .flatMapMany(connection -> Mono.from(connection.createStatement("select count(*) FROM " + tableName1) + .execute()) + .flatMapMany(result -> + result.map((row, rowMetadata) -> row.get("count", Long.class)))) .last() .block(); @@ -339,16 +301,15 @@ void createTableShouldSucceedWhenEnableRLS() { Pair.of("clm2", "character varying"), Pair.of("domain", "character varying")); - List> pgClassCheckResult = Flux.usingWhen(connectionFactory.create(), - connection -> Mono.from(connection.createStatement("select relname, relrowsecurity " + - "from pg_class " + - "where oid = 'tbn1'::regclass;;") - .execute()) - .flatMapMany(result -> - result.map((row, rowMetadata) -> - Pair.of(row.get("relname", String.class), - row.get("relrowsecurity", Boolean.class)))), - Connection::close) + List> pgClassCheckResult = postgresExtension.getConnection() + .flatMapMany(connection -> Mono.from(connection.createStatement("select relname, relrowsecurity " + + "from pg_class " + + "where oid = 'tbn1'::regclass;;") + .execute()) + .flatMapMany(result -> + result.map((row, rowMetadata) -> + Pair.of(row.get("relname", String.class), + row.get("relrowsecurity", Boolean.class))))) .collectList() .block(); @@ -357,30 +318,25 @@ void createTableShouldSucceedWhenEnableRLS() { Pair.of("tbn1", true)); } - private List> getColumnNameAndDataType(String tableName) { - return Flux.usingWhen(connectionFactory.create(), - connection -> Mono.from(connection.createStatement("SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_name = $1;") - .bind("$1", tableName) - .execute()) - .flatMapMany(result -> - result.map((row, rowMetadata) -> - Pair.of(row.get("column_name", String.class), - row.get("data_type", String.class)))), - Connection::close) + return postgresExtension.getConnection() + .flatMapMany(connection -> Flux.from(Mono.from(connection.createStatement("SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_name = $1;") + .bind("$1", tableName) + .execute()) + .flatMapMany(result -> result.map((row, rowMetadata) -> + Pair.of(row.get("column_name", String.class), row.get("data_type", String.class)))))) .collectList() .block(); } // return list> - private List> listIndexes() { - return Flux.usingWhen(connectionFactory.create(), - connection -> Mono.from(connection.createStatement("SELECT indexname, tablename FROM pg_indexes;") - .execute()) - .flatMapMany(result -> - result.map((row, rowMetadata) -> - Pair.of(row.get("indexname", String.class), row.get("tablename", String.class)))), - Connection::close) + private List> listIndexToTableMappings() { + return postgresExtension.getConnection() + .flatMapMany(connection -> Mono.from(connection.createStatement("SELECT indexname, tablename FROM pg_indexes;") + .execute()) + .flatMapMany(result -> + result.map((row, rowMetadata) -> + Pair.of(row.get("indexname", String.class), row.get("tablename", String.class))))) .collectList() .block(); } From da2803144b67d2dda910447968181ff78c2d1110 Mon Sep 17 00:00:00 2001 From: vttran Date: Wed, 8 Nov 2023 09:56:20 +0700 Subject: [PATCH 030/341] JAMES-2586 Postgres Subscription mapper (#1775) --- backends-common/postgres/pom.xml | 1 - .../backends/postgres/PostgresTable.java | 10 +-- .../postgres/utils/PostgresExecutor.java | 15 ++++ .../backends/postgres/PostgresExtension.java | 4 ++ .../postgres/PostgresTableManagerTest.java | 2 +- mailbox/postgres/pom.xml | 11 +++ .../jpa/user/PostgresSubscriptionDAO.java | 57 +++++++++++++++ .../jpa/user/PostgresSubscriptionMapper.java | 70 +++++++++++++++++++ .../jpa/user/PostgresSubscriptionModule.java | 45 ++++++++++++ .../jpa/user/PostgresSubscriptionTable.java | 34 +++++++++ .../user/PostgresSubscriptionMapperTest.java | 37 ++++++++++ pom.xml | 5 ++ 12 files changed, 284 insertions(+), 7 deletions(-) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionDAO.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionMapper.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionModule.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionTable.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionMapperTest.java diff --git a/backends-common/postgres/pom.xml b/backends-common/postgres/pom.xml index 3cf5b72327b..b9d89230639 100644 --- a/backends-common/postgres/pom.xml +++ b/backends-common/postgres/pom.xml @@ -82,7 +82,6 @@ org.testcontainers postgresql - 1.19.1 test diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java index 331f530ad74..1956d3c5e8f 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java @@ -41,21 +41,21 @@ public interface CreateTableFunction { @FunctionalInterface public interface RequireRowLevelSecurity { - PostgresTable enableRLS(boolean enableRowLevelSecurity); + PostgresTable enableRowLevelSecurity(boolean enableRowLevelSecurity); default PostgresTable noRLS() { - return enableRLS(false); + return enableRowLevelSecurity(false); } - default PostgresTable enableRLS() { - return enableRLS(true); + default PostgresTable enableRowLevelSecurity() { + return enableRowLevelSecurity(true); } } public static RequireCreateTableStep name(String tableName) { Preconditions.checkNotNull(tableName); - return createTableFunction -> enableRLS -> new PostgresTable(tableName, enableRLS, dsl -> createTableFunction.createTable(dsl, tableName)); + return createTableFunction -> enableRowLevelSecurity -> new PostgresTable(tableName, enableRowLevelSecurity, dsl -> createTableFunction.createTable(dsl, tableName)); } private final String name; diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index 78636dc186b..43b5efa4e10 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -19,9 +19,12 @@ package org.apache.james.backends.postgres.utils; +import java.util.function.Function; + import javax.inject.Inject; import org.jooq.DSLContext; +import org.jooq.Record; import org.jooq.SQLDialect; import org.jooq.conf.Settings; import org.jooq.impl.DSL; @@ -29,6 +32,7 @@ import com.google.common.annotations.VisibleForTesting; import io.r2dbc.spi.Connection; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; public class PostgresExecutor { @@ -47,6 +51,17 @@ public Mono dslContext() { return connection.map(con -> DSL.using(con, PGSQL_DIALECT, SETTINGS)); } + public Mono executeVoid(Function> queryFunction) { + return dslContext() + .flatMap(queryFunction) + .then(); + } + + public Flux executeRows(Function> queryFunction) { + return dslContext() + .flatMapMany(queryFunction); + } + public Mono connection() { return connection; } diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index 0e4ab28fa3c..35606c4f8e5 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -102,6 +102,10 @@ public Mono getConnection() { return postgresExecutor.connection(); } + public PostgresExecutor getPostgresExecutor() { + return postgresExecutor; + } + private void initTablesAndIndexes() { PostgresTableManager postgresTableManager = new PostgresTableManager(postgresExecutor, postgresModule); postgresTableManager.initializeTables().block(); diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java index 007ff246a59..c08a070a489 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java @@ -286,7 +286,7 @@ void createTableShouldSucceedWhenEnableRLS() { .createTableStep((dsl, tbn) -> dsl.createTable(tbn) .column("clm1", SQLDataType.UUID.notNull()) .column("clm2", SQLDataType.VARCHAR(255).notNull())) - .enableRLS(); + .enableRowLevelSecurity(); PostgresModule module = PostgresModule.table(table); diff --git a/mailbox/postgres/pom.xml b/mailbox/postgres/pom.xml index d63b4312817..690389fab59 100644 --- a/mailbox/postgres/pom.xml +++ b/mailbox/postgres/pom.xml @@ -99,6 +99,12 @@ james-server-data-jpa test + + ${james.groupId} + james-server-guice-common + test-jar + test + ${james.groupId} james-server-testing @@ -140,6 +146,11 @@ org.slf4j slf4j-api + + org.testcontainers + postgresql + test + diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionDAO.java new file mode 100644 index 00000000000..a1a903e90d2 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionDAO.java @@ -0,0 +1,57 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.user; + +import static org.apache.james.mailbox.jpa.user.PostgresSubscriptionTable.MAILBOX; +import static org.apache.james.mailbox.jpa.user.PostgresSubscriptionTable.TABLE_NAME; +import static org.apache.james.mailbox.jpa.user.PostgresSubscriptionTable.USER; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresSubscriptionDAO { + protected final PostgresExecutor executor; + + public PostgresSubscriptionDAO(PostgresExecutor executor) { + this.executor = executor; + } + + public Mono save(String username, String mailbox) { + return executor.executeVoid(dsl -> Mono.from(dsl.insertInto(TABLE_NAME, USER, MAILBOX) + .values(username, mailbox) + .onConflict(USER, MAILBOX) + .doNothing() + .returningResult(MAILBOX))); + } + + public Mono delete(String username, String mailbox) { + return executor.executeVoid(dsl -> Mono.from(dsl.deleteFrom(TABLE_NAME) + .where(USER.eq(username)) + .and(MAILBOX.eq(mailbox)))); + } + + public Flux findMailboxByUser(String username) { + return executor.executeRows(dsl -> Flux.from(dsl.selectFrom(TABLE_NAME) + .where(USER.eq(username)))) + .map(record -> record.get(MAILBOX)); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionMapper.java new file mode 100644 index 00000000000..02514fef6a9 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionMapper.java @@ -0,0 +1,70 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.user; + +import java.util.List; + +import org.apache.james.core.Username; +import org.apache.james.mailbox.exception.SubscriptionException; +import org.apache.james.mailbox.store.user.SubscriptionMapper; +import org.apache.james.mailbox.store.user.model.Subscription; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresSubscriptionMapper implements SubscriptionMapper { + + private final PostgresSubscriptionDAO subscriptionDAO; + + public PostgresSubscriptionMapper(PostgresSubscriptionDAO subscriptionDAO) { + this.subscriptionDAO = subscriptionDAO; + } + + @Override + public void save(Subscription subscription) throws SubscriptionException { + saveReactive(subscription).block(); + } + + @Override + public List findSubscriptionsForUser(Username user) throws SubscriptionException { + return findSubscriptionsForUserReactive(user).collectList().block(); + } + + @Override + public void delete(Subscription subscription) throws SubscriptionException { + deleteReactive(subscription).block(); + } + + @Override + public Mono saveReactive(Subscription subscription) { + return subscriptionDAO.save(subscription.getUser().asString(), subscription.getMailbox()); + } + + @Override + public Flux findSubscriptionsForUserReactive(Username user) { + return subscriptionDAO.findMailboxByUser(user.asString()) + .map(mailbox -> new Subscription(user, mailbox)); + } + + @Override + public Mono deleteReactive(Subscription subscription) { + return subscriptionDAO.delete(subscription.getUser().asString(), subscription.getMailbox()); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionModule.java new file mode 100644 index 00000000000..66d5372eeb4 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionModule.java @@ -0,0 +1,45 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.user; + +import static org.apache.james.mailbox.jpa.user.PostgresSubscriptionTable.MAILBOX; +import static org.apache.james.mailbox.jpa.user.PostgresSubscriptionTable.TABLE_NAME; +import static org.apache.james.mailbox.jpa.user.PostgresSubscriptionTable.USER; + +import org.apache.james.backends.postgres.PostgresIndex; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.impl.DSL; + +public interface PostgresSubscriptionModule { + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTable(tableName) + .column(MAILBOX) + .column(USER) + .constraint(DSL.unique(MAILBOX, USER)))) + .enableRowLevelSecurity(); + PostgresIndex INDEX = PostgresIndex.name("subscription_user_index") + .createIndexStep((dsl, indexName) -> dsl.createIndex(indexName) + .on(TABLE_NAME, USER)); + PostgresModule MODULE = PostgresModule.builder() + .addTable(TABLE) + .addIndex(INDEX) + .build(); +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionTable.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionTable.java new file mode 100644 index 00000000000..3cdc2cf1e82 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionTable.java @@ -0,0 +1,34 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.user; + +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresSubscriptionTable { + + Field MAILBOX = DSL.field("mailbox", SQLDataType.VARCHAR(255).notNull()); + Field USER = DSL.field("user_name", SQLDataType.VARCHAR(255).notNull()); + Table TABLE_NAME = DSL.table("subscription"); + +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionMapperTest.java new file mode 100644 index 00000000000..009a900c351 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionMapperTest.java @@ -0,0 +1,37 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.jpa.user; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.mailbox.store.user.SubscriptionMapper; +import org.apache.james.mailbox.store.user.SubscriptionMapperTest; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresSubscriptionMapperTest extends SubscriptionMapperTest { + + @RegisterExtension + static PostgresExtension postgresExtension = new PostgresExtension(PostgresSubscriptionModule.MODULE); + + @Override + protected SubscriptionMapper createSubscriptionMapper() { + PostgresSubscriptionDAO dao = new PostgresSubscriptionDAO(postgresExtension.getPostgresExecutor()); + return new PostgresSubscriptionMapper(dao); + } +} diff --git a/pom.xml b/pom.xml index bdc7681caf7..5b18beece54 100644 --- a/pom.xml +++ b/pom.xml @@ -2946,6 +2946,11 @@ junit-jupiter ${testcontainers.version} + + org.testcontainers + postgresql + 1.19.1 + org.testcontainers pulsar From 97e9d734747ddd118bb04d068aad25001606b032 Mon Sep 17 00:00:00 2001 From: hungphan227 <45198168+hungphan227@users.noreply.github.com> Date: Wed, 8 Nov 2023 13:51:35 +0700 Subject: [PATCH 031/341] JAMES-2586 implement pg connection factory (#1774) --- .../utils/JamesPostgresConnectionFactory.java | 37 +++ .../SimpleJamesPostgresConnectionFactory.java | 83 +++++++ .../postgres/ConnectionThreadSafetyTest.java | 219 ++++++++++++++++++ .../JamesPostgresConnectionFactoryTest.java | 96 ++++++++ .../backends/postgres/PostgresExtension.java | 13 +- ...pleJamesPostgresConnectionFactoryTest.java | 146 ++++++++++++ 6 files changed, 593 insertions(+), 1 deletion(-) create mode 100644 backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/JamesPostgresConnectionFactory.java create mode 100644 backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SimpleJamesPostgresConnectionFactory.java create mode 100644 backends-common/postgres/src/test/java/org/apache/james/backends/postgres/ConnectionThreadSafetyTest.java create mode 100644 backends-common/postgres/src/test/java/org/apache/james/backends/postgres/JamesPostgresConnectionFactoryTest.java create mode 100644 backends-common/postgres/src/test/java/org/apache/james/backends/postgres/SimpleJamesPostgresConnectionFactoryTest.java diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/JamesPostgresConnectionFactory.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/JamesPostgresConnectionFactory.java new file mode 100644 index 00000000000..8d8391e209e --- /dev/null +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/JamesPostgresConnectionFactory.java @@ -0,0 +1,37 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres.utils; + +import java.util.Optional; + +import org.apache.james.core.Domain; + +import io.r2dbc.spi.Connection; +import reactor.core.publisher.Mono; + +public interface JamesPostgresConnectionFactory { + String DOMAIN_ATTRIBUTE = "app.current_domain"; + + default Mono getConnection(Domain domain) { + return getConnection(Optional.ofNullable(domain)); + } + + Mono getConnection(Optional domain); +} diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SimpleJamesPostgresConnectionFactory.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SimpleJamesPostgresConnectionFactory.java new file mode 100644 index 00000000000..edfba85ce9c --- /dev/null +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SimpleJamesPostgresConnectionFactory.java @@ -0,0 +1,83 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres.utils; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.james.core.Domain; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.r2dbc.spi.Connection; +import io.r2dbc.spi.ConnectionFactory; +import reactor.core.publisher.Mono; + +public class SimpleJamesPostgresConnectionFactory implements JamesPostgresConnectionFactory { + private static final Logger LOGGER = LoggerFactory.getLogger(SimpleJamesPostgresConnectionFactory.class); + private static final Domain DEFAULT = Domain.of("default"); + + private final ConnectionFactory connectionFactory; + private final Map mapDomainToConnection = new ConcurrentHashMap<>(); + + public SimpleJamesPostgresConnectionFactory(ConnectionFactory connectionFactory) { + this.connectionFactory = connectionFactory; + } + + public Mono getConnection(Optional maybeDomain) { + return maybeDomain.map(this::getConnectionForDomain) + .orElse(getConnectionForDomain(DEFAULT)); + } + + private Mono getConnectionForDomain(Domain domain) { + return Mono.just(domain) + .flatMap(domainValue -> Mono.fromCallable(() -> mapDomainToConnection.get(domainValue)) + .switchIfEmpty(create(domainValue))); + } + + private Mono create(Domain domain) { + return Mono.from(connectionFactory.create()) + .doOnError(e -> LOGGER.error("Error while creating connection for domain {}", domain, e)) + .flatMap(newConnection -> getAndSetConnection(domain, newConnection)); + } + + private Mono getAndSetConnection(Domain domain, Connection newConnection) { + return Mono.justOrEmpty(mapDomainToConnection.putIfAbsent(domain, newConnection)) + .map(postgresqlConnection -> { + //close redundant connection + Mono.from(newConnection.close()) + .doOnError(e -> LOGGER.error("Error while closing connection for domain {}", domain, e)) + .subscribe(); + return postgresqlConnection; + }).switchIfEmpty(setDomainAttributeForConnection(domain, newConnection)); + } + + private static Mono setDomainAttributeForConnection(Domain domain, Connection newConnection) { + if (DEFAULT.equals(domain)) { + return Mono.just(newConnection); + } else { + return Mono.from(newConnection.createStatement("SET " + DOMAIN_ATTRIBUTE + " TO '" + domain.asString() + "'") // It should be set value via Bind, but it doesn't work + .execute()) + .doOnError(e -> LOGGER.error("Error while setting domain attribute for domain {}", domain, e)) + .then(Mono.just(newConnection)); + } + } +} diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/ConnectionThreadSafetyTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/ConnectionThreadSafetyTest.java new file mode 100644 index 00000000000..20eedcee4dc --- /dev/null +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/ConnectionThreadSafetyTest.java @@ -0,0 +1,219 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.Vector; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; + +import org.apache.james.backends.postgres.utils.SimpleJamesPostgresConnectionFactory; +import org.apache.james.core.Domain; +import org.apache.james.util.concurrency.ConcurrentTestRunner; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; + +import io.r2dbc.postgresql.api.PostgresqlConnection; +import io.r2dbc.postgresql.api.PostgresqlResult; +import io.r2dbc.spi.Connection; +import io.r2dbc.spi.Result; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class ConnectionThreadSafetyTest { + static final int NUMBER_OF_THREAD = 100; + static final String CREATE_TABLE_STATEMENT = "CREATE TABLE IF NOT EXISTS person (\n" + + "\tid serial PRIMARY KEY,\n" + + "\tname VARCHAR ( 50 ) UNIQUE NOT NULL\n" + + ");"; + + @RegisterExtension + static PostgresExtension postgresExtension = new PostgresExtension(); + + private static PostgresqlConnection postgresqlConnection; + private static SimpleJamesPostgresConnectionFactory jamesPostgresConnectionFactory; + + @BeforeAll + static void beforeAll() { + jamesPostgresConnectionFactory = new SimpleJamesPostgresConnectionFactory(postgresExtension.getConnectionFactory()); + postgresqlConnection = (PostgresqlConnection) postgresExtension.getConnection().block(); + } + + @BeforeEach + void beforeEach() { + postgresqlConnection.createStatement(CREATE_TABLE_STATEMENT) + .execute() + .flatMap(PostgresqlResult::getRowsUpdated) + .then() + .block(); + } + + @AfterEach + void afterEach() { + postgresqlConnection.createStatement("DROP TABLE person") + .execute() + .flatMap(PostgresqlResult::getRowsUpdated) + .then() + .block(); + } + + @Test + void connectionShouldWorkWellWhenItIsUsedByMultipleThreadsAndAllQueriesAreSelect() throws Exception { + createData(NUMBER_OF_THREAD); + + Connection connection = jamesPostgresConnectionFactory.getConnection(Domain.of("james")).block(); + + List actual = new Vector<>(); + ConcurrentTestRunner.builder() + .reactorOperation((threadNumber, step) -> getData(connection, threadNumber) + .doOnNext(s -> actual.add(s)) + .then()) + .threadCount(NUMBER_OF_THREAD) + .operationCount(1) + .runSuccessfullyWithin(Duration.ofMinutes(1)); + + Set expected = Stream.iterate(0, i -> i + 1).limit(NUMBER_OF_THREAD).map(i -> i + "|Peter" + i).collect(ImmutableSet.toImmutableSet()); + + assertThat(actual).containsExactlyInAnyOrderElementsOf(expected); + } + + @Test + void connectionShouldWorkWellWhenItIsUsedByMultipleThreadsAndAllQueriesAreInsert() throws Exception { + Connection connection = jamesPostgresConnectionFactory.getConnection(Domain.of("james")).block(); + + ConcurrentTestRunner.builder() + .reactorOperation((threadNumber, step) -> createData(connection, threadNumber)) + .threadCount(NUMBER_OF_THREAD) + .operationCount(1) + .runSuccessfullyWithin(Duration.ofMinutes(1)); + + List actual = getData(0, NUMBER_OF_THREAD); + Set expected = Stream.iterate(0, i -> i + 1).limit(NUMBER_OF_THREAD).map(i -> i + "|Peter" + i).collect(ImmutableSet.toImmutableSet()); + + assertThat(actual).containsExactlyInAnyOrderElementsOf(expected); + } + + @Test + void connectionShouldWorkWellWhenItIsUsedByMultipleThreadsAndInsertQueriesAreDuplicated() throws Exception { + Connection connection = jamesPostgresConnectionFactory.getConnection(Domain.of("james")).block(); + + AtomicInteger numberOfSuccess = new AtomicInteger(0); + AtomicInteger numberOfFail = new AtomicInteger(0); + ConcurrentTestRunner.builder() + .reactorOperation((threadNumber, step) -> createData(connection, threadNumber % 10) + .then(Mono.fromCallable(() -> numberOfSuccess.incrementAndGet())) + .then() + .onErrorResume(throwable -> { + if (throwable.getMessage().contains("duplicate key value violates unique constraint")) { + numberOfFail.incrementAndGet(); + } + return Mono.empty(); + })) + .threadCount(100) + .operationCount(1) + .runSuccessfullyWithin(Duration.ofMinutes(1)); + + List actual = getData(0, 100); + Set expected = Stream.iterate(0, i -> i + 1).limit(10).map(i -> i + "|Peter" + i).collect(ImmutableSet.toImmutableSet()); + + assertThat(actual).containsExactlyInAnyOrderElementsOf(expected); + assertThat(numberOfSuccess.get()).isEqualTo(10); + assertThat(numberOfFail.get()).isEqualTo(90); + } + + @Test + void connectionShouldWorkWellWhenItIsUsedByMultipleThreadsAndQueriesIncludeBothSelectAndInsert() throws Exception { + createData(50); + + Connection connection = jamesPostgresConnectionFactory.getConnection(Optional.empty()).block(); + + List actualSelect = new Vector<>(); + ConcurrentTestRunner.builder() + .reactorOperation((threadNumber, step) -> { + if (threadNumber < 50) { + return getData(connection, threadNumber) + .doOnNext(s -> actualSelect.add(s)) + .then(); + } else { + return createData(connection, threadNumber); + } + }) + .threadCount(NUMBER_OF_THREAD) + .operationCount(1) + .runSuccessfullyWithin(Duration.ofMinutes(1)); + + List actualInsert = getData(50, 100); + + Set expectedSelect = Stream.iterate(0, i -> i + 1).limit(50).map(i -> i + "|Peter" + i).collect(ImmutableSet.toImmutableSet()); + Set expectedInsert = Stream.iterate(50, i -> i + 1).limit(50).map(i -> i + "|Peter" + i).collect(ImmutableSet.toImmutableSet()); + + assertThat(actualSelect).containsExactlyInAnyOrderElementsOf(expectedSelect); + assertThat(actualInsert).containsExactlyInAnyOrderElementsOf(expectedInsert); + } + + private Flux getData(Connection connection, int threadNumber) { + return Flux.from(connection.createStatement("SELECT id, name FROM PERSON WHERE id = $1") + .bind("$1", threadNumber) + .execute()) + .flatMap(result -> result.map((row, rowMetadata) -> row.get("id", Long.class) + "|" + row.get("name", String.class))); + } + + @NotNull + private Mono createData(Connection connection, int threadNumber) { + return Flux.from(connection.createStatement("INSERT INTO person (id, name) VALUES ($1, $2)") + .bind("$1", threadNumber) + .bind("$2", "Peter" + threadNumber) + .execute()) + .flatMap(Result::getRowsUpdated) + .then(); + } + + private List getData(int lowerBound, int upperBound) { + return Flux.from(postgresqlConnection.createStatement("SELECT id, name FROM person WHERE id >= $1 AND id < $2") + .bind("$1", lowerBound) + .bind("$2", upperBound) + .execute()) + .flatMap(result -> result.map((row, rowMetadata) -> row.get("id", Long.class) + "|" + row.get("name", String.class))) + .collect(ImmutableList.toImmutableList()).block(); + } + + private void createData(int upperBound) { + for (int i = 0; i < upperBound; i++) { + postgresqlConnection.createStatement("INSERT INTO person (id, name) VALUES ($1, $2)") + .bind("$1", i) + .bind("$2", "Peter" + i) + .execute().flatMap(PostgresqlResult::getRowsUpdated) + .then() + .block(); + } + } +} diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/JamesPostgresConnectionFactoryTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/JamesPostgresConnectionFactoryTest.java new file mode 100644 index 00000000000..ab68dd611a3 --- /dev/null +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/JamesPostgresConnectionFactoryTest.java @@ -0,0 +1,96 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Optional; + +import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; +import org.apache.james.core.Domain; +import org.junit.jupiter.api.Test; + +import com.google.common.collect.ImmutableList; + +import io.r2dbc.spi.Connection; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public abstract class JamesPostgresConnectionFactoryTest { + + abstract JamesPostgresConnectionFactory jamesPostgresConnectionFactory(); + + @Test + void getConnectionShouldWork() { + Connection connection = jamesPostgresConnectionFactory().getConnection(Optional.empty()).block(); + String actual = Flux.from(connection.createStatement("SELECT 1") + .execute()) + .flatMap(result -> result.map((row, rowMetadata) -> row.get(0, String.class))) + .collect(ImmutableList.toImmutableList()) + .block().get(0); + + assertThat(actual).isEqualTo("1"); + } + + @Test + void getConnectionWithDomainShouldWork() { + Connection connection = jamesPostgresConnectionFactory().getConnection(Domain.of("james")).block(); + String actual = Flux.from(connection.createStatement("SELECT 1") + .execute()) + .flatMap(result -> result.map((row, rowMetadata) -> row.get(0, String.class))) + .collect(ImmutableList.toImmutableList()) + .block().get(0); + + assertThat(actual).isEqualTo("1"); + } + + @Test + void getConnectionShouldSetCurrentDomainAttribute() { + Domain domain = Domain.of("james"); + Connection connection = jamesPostgresConnectionFactory().getConnection(domain).block(); + String actual = getDomainAttributeValue(connection); + + assertThat(actual).isEqualTo(domain.asString()); + } + + @Test + void getConnectionWithoutDomainShouldNotSetCurrentDomainAttribute() { + Connection connection = jamesPostgresConnectionFactory().getConnection(Optional.empty()).block(); + + String message = Flux.from(connection.createStatement("show " + JamesPostgresConnectionFactory.DOMAIN_ATTRIBUTE) + .execute()) + .flatMap(result -> result.map((row, rowMetadata) -> row.get(0, String.class))) + .collect(ImmutableList.toImmutableList()) + .map(strings -> "") + .onErrorResume(throwable -> Mono.just(throwable.getMessage())) + .block(); + + assertThat(message).isEqualTo("unrecognized configuration parameter \"" + JamesPostgresConnectionFactory.DOMAIN_ATTRIBUTE + "\""); + } + + String getDomainAttributeValue(Connection connection) { + return Flux.from(connection.createStatement("show " + JamesPostgresConnectionFactory.DOMAIN_ATTRIBUTE) + .execute()) + .flatMap(result -> result.map((row, rowMetadata) -> row.get(0, String.class))) + .collect(ImmutableList.toImmutableList()) + .block().get(0); + } + +} diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index 35606c4f8e5..fd3d8ef1e1a 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -28,11 +28,13 @@ import io.r2dbc.postgresql.PostgresqlConnectionConfiguration; import io.r2dbc.postgresql.PostgresqlConnectionFactory; import io.r2dbc.spi.Connection; +import io.r2dbc.spi.ConnectionFactory; import reactor.core.publisher.Mono; public class PostgresExtension implements GuiceModuleTestExtension { private final PostgresModule postgresModule; private PostgresExecutor postgresExecutor; + private PostgresqlConnectionFactory connectionFactory; public PostgresExtension(PostgresModule postgresModule) { this.postgresModule = postgresModule; @@ -51,7 +53,7 @@ public void beforeAll(ExtensionContext extensionContext) { } private void initPostgresSession() { - PostgresqlConnectionFactory connectionFactory = new PostgresqlConnectionFactory(PostgresqlConnectionConfiguration.builder() + connectionFactory = new PostgresqlConnectionFactory(PostgresqlConnectionConfiguration.builder() .host(getHost()) .port(getMappedPort()) .username(PostgresFixture.Database.DB_USER) @@ -84,6 +86,12 @@ public void afterEach(ExtensionContext extensionContext) { resetSchema(); } + public void restartContainer() { + DockerPostgresSingleton.SINGLETON.stop(); + DockerPostgresSingleton.SINGLETON.start(); + initPostgresSession(); + } + @Override public Module getModule() { // TODO: return PostgresConfiguration bean when doing https://github.com/linagora/james-project/issues/4910 @@ -105,6 +113,9 @@ public Mono getConnection() { public PostgresExecutor getPostgresExecutor() { return postgresExecutor; } + public ConnectionFactory getConnectionFactory() { + return connectionFactory; + } private void initTablesAndIndexes() { PostgresTableManager postgresTableManager = new PostgresTableManager(postgresExecutor, postgresModule); diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/SimpleJamesPostgresConnectionFactoryTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/SimpleJamesPostgresConnectionFactoryTest.java new file mode 100644 index 00000000000..1ebf19ba35c --- /dev/null +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/SimpleJamesPostgresConnectionFactoryTest.java @@ -0,0 +1,146 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.SimpleJamesPostgresConnectionFactory; +import org.apache.james.core.Domain; +import org.apache.james.util.concurrency.ConcurrentTestRunner; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.google.common.collect.ImmutableList; + +import io.r2dbc.postgresql.api.PostgresqlConnection; +import io.r2dbc.spi.Connection; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class SimpleJamesPostgresConnectionFactoryTest extends JamesPostgresConnectionFactoryTest { + @RegisterExtension + static PostgresExtension postgresExtension = new PostgresExtension(); + + private PostgresqlConnection postgresqlConnection; + private SimpleJamesPostgresConnectionFactory jamesPostgresConnectionFactory; + + JamesPostgresConnectionFactory jamesPostgresConnectionFactory() { + return jamesPostgresConnectionFactory; + } + + @BeforeEach + void beforeEach() { + jamesPostgresConnectionFactory = new SimpleJamesPostgresConnectionFactory(postgresExtension.getConnectionFactory()); + postgresqlConnection = (PostgresqlConnection) postgresExtension.getConnection().block(); + } + + @AfterEach + void afterEach() { + postgresExtension.restartContainer(); + } + + @Test + void factoryShouldCreateCorrectNumberOfConnections() { + Integer previousDbActiveNumberOfConnections = getNumberOfConnections(); + + // create 50 connections + Flux.range(1, 50) + .flatMap(i -> jamesPostgresConnectionFactory.getConnection(Domain.of("james" + i))) + .last() + .block(); + + Integer dbActiveNumberOfConnections = getNumberOfConnections(); + + assertThat(dbActiveNumberOfConnections - previousDbActiveNumberOfConnections).isEqualTo(50); + } + + @Nullable + private Integer getNumberOfConnections() { + return Mono.from(postgresqlConnection.createStatement("SELECT count(*) from pg_stat_activity where usename = $1;") + .bind("$1", PostgresFixture.Database.DB_USER) + .execute()).flatMap(result -> Mono.from(result.map((row, rowMetadata) -> row.get(0, Integer.class)))).block(); + } + + @Test + void factoryShouldNotCreateNewConnectionWhenDomainsAreTheSame() { + Domain domain = Domain.of("james"); + Connection connectionOne = jamesPostgresConnectionFactory.getConnection(domain).block(); + Connection connectionTwo = jamesPostgresConnectionFactory.getConnection(domain).block(); + + assertThat(connectionOne == connectionTwo).isTrue(); + } + + @Test + void factoryShouldCreateNewConnectionWhenDomainsAreDifferent() { + Connection connectionOne = jamesPostgresConnectionFactory.getConnection(Domain.of("james")).block(); + Connection connectionTwo = jamesPostgresConnectionFactory.getConnection(Domain.of("lin")).block(); + + String domainOne = getDomainAttributeValue(connectionOne); + + String domainTwo = Flux.from(connectionTwo.createStatement("show " + JamesPostgresConnectionFactory.DOMAIN_ATTRIBUTE) + .execute()) + .flatMap(result -> result.map((row, rowMetadata) -> row.get(0, String.class))) + .collect(ImmutableList.toImmutableList()) + .block().get(0); + + assertThat(connectionOne).isNotEqualTo(connectionTwo); + assertThat(domainOne).isNotEqualTo(domainTwo); + } + + @Test + void factoryShouldNotCreateNewConnectionWhenDomainsAreTheSameAndRequestsAreFromDifferentThreads() throws Exception { + Set connectionSet = ConcurrentHashMap.newKeySet(); + + ConcurrentTestRunner.builder() + .reactorOperation((threadNumber, step) -> jamesPostgresConnectionFactory.getConnection(Domain.of("james")) + .doOnNext(connectionSet::add) + .then()) + .threadCount(50) + .operationCount(10) + .runSuccessfullyWithin(Duration.ofMinutes(1)); + + assertThat(connectionSet).hasSize(1); + } + + @Test + void factoryShouldCreateOnlyOneDefaultConnection() throws Exception { + Set connectionSet = ConcurrentHashMap.newKeySet(); + + ConcurrentTestRunner.builder() + .reactorOperation((threadNumber, step) -> jamesPostgresConnectionFactory.getConnection(Optional.empty()) + .doOnNext(connectionSet::add) + .then()) + .threadCount(50) + .operationCount(10) + .runSuccessfullyWithin(Duration.ofMinutes(1)); + + assertThat(connectionSet).hasSize(1); + } + +} From a777c9c980d54b9493092e12ac2892b78e155c11 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Mon, 6 Nov 2023 16:47:15 +0700 Subject: [PATCH 032/341] JAMES-2586 Introduce PostgresConfiguration --- backends-common/postgres/pom.xml | 4 + .../postgres/PostgresConfiguration.java | 199 ++++++++++++++++++ .../postgres/PostgresConfigurationTest.java | 110 ++++++++++ 3 files changed, 313 insertions(+) create mode 100644 backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java create mode 100644 backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java diff --git a/backends-common/postgres/pom.xml b/backends-common/postgres/pom.xml index b9d89230639..019e2b5c84b 100644 --- a/backends-common/postgres/pom.xml +++ b/backends-common/postgres/pom.xml @@ -58,6 +58,10 @@ javax.inject javax.inject + + org.apache.commons + commons-configuration2 + org.jooq jooq diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java new file mode 100644 index 00000000000..5938dd992a1 --- /dev/null +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java @@ -0,0 +1,199 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres; + +import java.net.URI; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.apache.commons.configuration2.Configuration; + +import com.google.common.base.Joiner; +import com.google.common.base.Preconditions; +import com.google.common.base.Splitter; +import com.google.common.collect.ImmutableList; + +public class PostgresConfiguration { + public static final String URL = "url"; + public static final String DATABASE_NAME = "database.name"; + public static final String DATABASE_NAME_DEFAULT_VALUE = "postgres"; + public static final String DATABASE_SCHEMA = "database.schema"; + public static final String DATABASE_SCHEMA_DEFAULT_VALUE = "public"; + public static final String RLS_ENABLED = "row.level.security.enabled"; + + static class Credential { + private final String username; + private final String password; + + Credential(String username, String password) { + this.username = username; + this.password = password; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + } + + public static class Builder { + private Optional url = Optional.empty(); + private Optional databaseName = Optional.empty(); + private Optional databaseSchema = Optional.empty(); + private Optional rlsEnabled = Optional.empty(); + + public Builder url(String url) { + this.url = Optional.of(url); + return this; + } + + public Builder databaseName(String databaseName) { + this.databaseName = Optional.of(databaseName); + return this; + } + + public Builder databaseName(Optional databaseName) { + this.databaseName = databaseName; + return this; + } + + public Builder databaseSchema(String databaseSchema) { + this.databaseSchema = Optional.of(databaseSchema); + return this; + } + + public Builder databaseSchema(Optional databaseSchema) { + this.databaseSchema = databaseSchema; + return this; + } + + public Builder rlsEnabled(boolean rlsEnabled) { + this.rlsEnabled = Optional.of(rlsEnabled); + return this; + } + + public Builder rlsEnabled() { + this.rlsEnabled = Optional.of(true); + return this; + } + + public PostgresConfiguration build() { + Preconditions.checkArgument(url.isPresent() && !url.get().isBlank(), "You need to specify Postgres URI"); + URI postgresURI = asURI(url.get()); + + return new PostgresConfiguration(postgresURI, + parseCredential(postgresURI), + databaseName.orElse(DATABASE_NAME_DEFAULT_VALUE), + databaseSchema.orElse(DATABASE_SCHEMA_DEFAULT_VALUE), + rlsEnabled.orElse(false)); + } + + private Credential parseCredential(URI postgresURI) { + Preconditions.checkArgument(postgresURI.getUserInfo() != null, "Postgres URI need to contains user credential"); + Preconditions.checkArgument(postgresURI.getUserInfo().contains(":"), "User info needs a password part"); + + List parts = Splitter.on(':') + .splitToList(postgresURI.getUserInfo()); + ImmutableList passwordParts = parts.stream() + .skip(1) + .collect(ImmutableList.toImmutableList()); + + return new Credential(parts.get(0), Joiner.on(':').join(passwordParts)); + } + + private URI asURI(String uri) { + try { + return URI.create(uri); + } catch (Exception e) { + throw new IllegalArgumentException("You need to specify a valid Postgres URI", e); + } + } + } + + public static Builder builder() { + return new Builder(); + } + + public static PostgresConfiguration from(Configuration propertiesConfiguration) { + return builder() + .url(propertiesConfiguration.getString(URL, null)) + .databaseName(Optional.ofNullable(propertiesConfiguration.getString(DATABASE_NAME))) + .databaseSchema(Optional.ofNullable(propertiesConfiguration.getString(DATABASE_SCHEMA))) + .rlsEnabled(propertiesConfiguration.getBoolean(RLS_ENABLED, false)) + .build(); + } + + private final URI url; + private final Credential credential; + private final String databaseName; + private final String databaseSchema; + private final boolean rlsEnabled; + + private PostgresConfiguration(URI url, Credential credential, String databaseName, String databaseSchema, boolean rlsEnabled) { + this.url = url; + this.credential = credential; + this.databaseName = databaseName; + this.databaseSchema = databaseSchema; + this.rlsEnabled = rlsEnabled; + } + + @Override + public final boolean equals(Object o) { + if (o instanceof PostgresConfiguration) { + PostgresConfiguration that = (PostgresConfiguration) o; + + return Objects.equals(this.rlsEnabled, that.rlsEnabled) + && Objects.equals(this.url, that.url) + && Objects.equals(this.credential, that.credential) + && Objects.equals(this.databaseName, that.databaseName) + && Objects.equals(this.databaseSchema, that.databaseSchema); + } + return false; + } + + @Override + public final int hashCode() { + return Objects.hash(url, credential, databaseName, databaseSchema, rlsEnabled); + } + + public URI getUrl() { + return url; + } + + public Credential getCredential() { + return credential; + } + + public String getDatabaseName() { + return databaseName; + } + + public String getDatabaseSchema() { + return databaseSchema; + } + + public boolean rlsEnabled() { + return rlsEnabled; + } +} diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java new file mode 100644 index 00000000000..c90dc0f35f0 --- /dev/null +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java @@ -0,0 +1,110 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.Test; + +class PostgresConfigurationTest { + + @Test + void shouldThrowWhenMissingPostgresURI() { + assertThatThrownBy(() -> PostgresConfiguration.builder() + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("You need to specify Postgres URI"); + } + + @Test + void shouldThrowWhenInvalidURI() { + assertThatThrownBy(() -> PostgresConfiguration.builder() + .url(":invalid") + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("You need to specify a valid Postgres URI"); + } + + @Test + void shouldThrowWhenURIMissingCredential() { + assertThatThrownBy(() -> PostgresConfiguration.builder() + .url("postgresql://localhost:5432") + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Postgres URI need to contains user credential"); + } + + @Test + void shouldParseValidURI() { + PostgresConfiguration configuration = PostgresConfiguration.builder() + .url("postgresql://username:password@postgreshost:5672") + .build(); + + assertThat(configuration.getUrl().getHost()).isEqualTo("postgreshost"); + assertThat(configuration.getUrl().getPort()).isEqualTo(5672); + assertThat(configuration.getCredential().getUsername()).isEqualTo("username"); + assertThat(configuration.getCredential().getPassword()).isEqualTo("password"); + } + + @Test + void rowLevelSecurityShouldBeDisabledByDefault() { + PostgresConfiguration configuration = PostgresConfiguration.builder() + .url("postgresql://username:password@postgreshost:5672") + .build(); + + assertThat(configuration.rlsEnabled()).isFalse(); + } + + @Test + void databaseNameShouldFallbackToDefaultWhenNotSet() { + PostgresConfiguration configuration = PostgresConfiguration.builder() + .url("postgresql://username:password@postgreshost:5672") + .build(); + + assertThat(configuration.getDatabaseName()).isEqualTo("postgres"); + } + + @Test + void databaseSchemaShouldFallbackToDefaultWhenNotSet() { + PostgresConfiguration configuration = PostgresConfiguration.builder() + .url("postgresql://username:password@postgreshost:5672") + .build(); + + assertThat(configuration.getDatabaseSchema()).isEqualTo("public"); + } + + @Test + void shouldReturnCorrespondingProperties() { + PostgresConfiguration configuration = PostgresConfiguration.builder() + .url("postgresql://username:password@postgreshost:5672") + .rlsEnabled() + .databaseName("databaseName") + .databaseSchema("databaseSchema") + .build(); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(configuration.rlsEnabled()).isEqualTo(true); + softly.assertThat(configuration.getDatabaseName()).isEqualTo("databaseName"); + softly.assertThat(configuration.getDatabaseSchema()).isEqualTo("databaseSchema"); + }); + } +} From 6152937dc8f5dfe2d98797157e1c99588da78b4e Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Mon, 6 Nov 2023 16:50:46 +0700 Subject: [PATCH 033/341] JAMES-2586 Rename postgres-app tests' name: JPA -> Postgres --- .../{JPAJamesServerTest.java => PostgresJamesServerTest.java} | 2 +- ...sJamesServerWithAuthenticatedDatabaseSqlValidationTest.java} | 2 +- ...erverWithNoDatabaseAuthenticaticationSqlValidationTest.java} | 2 +- ...nTest.java => PostgresJamesServerWithSqlValidationTest.java} | 2 +- ...amesServerTest.java => PostgresWithLDAPJamesServerTest.java} | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) rename server/apps/postgres-app/src/test/java/org/apache/james/{JPAJamesServerTest.java => PostgresJamesServerTest.java} (98%) rename server/apps/postgres-app/src/test/java/org/apache/james/{JPAJamesServerWithAuthenticatedDatabaseSqlValidationTest.java => PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest.java} (94%) rename server/apps/postgres-app/src/test/java/org/apache/james/{JPAJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java => PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java} (93%) rename server/apps/postgres-app/src/test/java/org/apache/james/{JPAJamesServerWithSqlValidationTest.java => PostgresJamesServerWithSqlValidationTest.java} (94%) rename server/apps/postgres-app/src/test/java/org/apache/james/{JPAWithLDAPJamesServerTest.java => PostgresWithLDAPJamesServerTest.java} (98%) diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java similarity index 98% rename from server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerTest.java rename to server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java index 4ff4cee67f5..33cf9b24cb4 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java @@ -39,7 +39,7 @@ import com.google.common.base.Strings; -class JPAJamesServerTest implements JamesServerConcreteContract { +class PostgresJamesServerTest implements JamesServerConcreteContract { @RegisterExtension static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> PostgresJamesConfiguration.builder() diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerWithAuthenticatedDatabaseSqlValidationTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest.java similarity index 94% rename from server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerWithAuthenticatedDatabaseSqlValidationTest.java rename to server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest.java index a1345fe7f67..ef77bf6e7bb 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerWithAuthenticatedDatabaseSqlValidationTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest.java @@ -23,7 +23,7 @@ import org.junit.jupiter.api.extension.RegisterExtension; -class JPAJamesServerWithAuthenticatedDatabaseSqlValidationTest extends JPAJamesServerWithSqlValidationTest { +class PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest extends PostgresJamesServerWithSqlValidationTest { @RegisterExtension static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java similarity index 93% rename from server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java rename to server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java index 42e03ee83fc..3aaa9945297 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java @@ -23,7 +23,7 @@ import org.junit.jupiter.api.extension.RegisterExtension; -class JPAJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest extends JPAJamesServerWithSqlValidationTest { +class PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest extends PostgresJamesServerWithSqlValidationTest { @RegisterExtension static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> PostgresJamesConfiguration.builder() diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerWithSqlValidationTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithSqlValidationTest.java similarity index 94% rename from server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerWithSqlValidationTest.java rename to server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithSqlValidationTest.java index 4a0e1f513d6..27643a4f16e 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/JPAJamesServerWithSqlValidationTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithSqlValidationTest.java @@ -21,7 +21,7 @@ import org.junit.jupiter.api.Disabled; -abstract class JPAJamesServerWithSqlValidationTest extends JPAJamesServerTest { +abstract class PostgresJamesServerWithSqlValidationTest extends PostgresJamesServerTest { @Override @Disabled("Failing to create the domain: duplicate with test in JPAJamesServerTest") diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/JPAWithLDAPJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java similarity index 98% rename from server/apps/postgres-app/src/test/java/org/apache/james/JPAWithLDAPJamesServerTest.java rename to server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java index a853cd0b284..9e2eec6c41b 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/JPAWithLDAPJamesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java @@ -32,7 +32,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -class JPAWithLDAPJamesServerTest { +class PostgresWithLDAPJamesServerTest { @RegisterExtension static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> PostgresJamesConfiguration.builder() From c1a02bc149bdcfd9ffc4c9ef9e98d1e703049873 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 7 Nov 2023 11:25:23 +0700 Subject: [PATCH 034/341] JAMES-2586 Guice binding for PostgresConfiguration --- .../backends/postgres/PostgresExtension.java | 41 ++++++++++++++----- server/apps/postgres-app/pom.xml | 12 ++++++ .../apache/james/PostgresJamesServerMain.java | 10 +++-- .../james/JamesCapabilitiesServerTest.java | 2 + .../apache/james/PostgresJamesServerTest.java | 2 + ...uthenticatedDatabaseSqlValidationTest.java | 2 + ...seAuthenticaticationSqlValidationTest.java | 2 + .../PostgresWithLDAPJamesServerTest.java | 2 + .../mailbox/PostgresMailboxModule.java | 32 +++++++++++++++ .../modules/data/PostgresCommonModule.java | 38 +++++++++++++++++ server/data/data-postgres/pom.xml | 10 +++++ 11 files changed, 138 insertions(+), 15 deletions(-) create mode 100644 server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java create mode 100644 server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index fd3d8ef1e1a..54eb64ab490 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -24,6 +24,7 @@ import org.junit.jupiter.api.extension.ExtensionContext; import com.google.inject.Module; +import com.google.inject.util.Modules; import io.r2dbc.postgresql.PostgresqlConnectionConfiguration; import io.r2dbc.postgresql.PostgresqlConnectionFactory; @@ -33,15 +34,26 @@ public class PostgresExtension implements GuiceModuleTestExtension { private final PostgresModule postgresModule; + private final boolean rlsEnabled; + private PostgresConfiguration postgresConfiguration; private PostgresExecutor postgresExecutor; private PostgresqlConnectionFactory connectionFactory; - public PostgresExtension(PostgresModule postgresModule) { + public PostgresExtension(PostgresModule postgresModule, boolean rlsEnabled) { this.postgresModule = postgresModule; + this.rlsEnabled = rlsEnabled; + } + + public PostgresExtension(PostgresModule postgresModule) { + this(postgresModule, false); + } + + public PostgresExtension(boolean rlsEnabled) { + this(PostgresModule.EMPTY_MODULE, rlsEnabled); } public PostgresExtension() { - this(PostgresModule.EMPTY_MODULE); + this(false); } @Override @@ -53,13 +65,20 @@ public void beforeAll(ExtensionContext extensionContext) { } private void initPostgresSession() { - connectionFactory = new PostgresqlConnectionFactory(PostgresqlConnectionConfiguration.builder() - .host(getHost()) - .port(getMappedPort()) - .username(PostgresFixture.Database.DB_USER) - .password(PostgresFixture.Database.DB_PASSWORD) - .database(PostgresFixture.Database.DB_NAME) - .schema(PostgresFixture.Database.SCHEMA) + postgresConfiguration = PostgresConfiguration.builder() + .url(String.format("postgresql://%s:%s@%s:%d", PostgresFixture.Database.DB_USER, PostgresFixture.Database.DB_PASSWORD, getHost(), getMappedPort())) + .databaseName(PostgresFixture.Database.DB_NAME) + .databaseSchema(PostgresFixture.Database.SCHEMA) + .rlsEnabled(rlsEnabled) + .build(); + + PostgresqlConnectionFactory connectionFactory = new PostgresqlConnectionFactory(PostgresqlConnectionConfiguration.builder() + .host(postgresConfiguration.getUrl().getHost()) + .port(postgresConfiguration.getUrl().getPort()) + .username(postgresConfiguration.getCredential().getUsername()) + .password(postgresConfiguration.getCredential().getPassword()) + .database(postgresConfiguration.getDatabaseName()) + .schema(postgresConfiguration.getDatabaseSchema()) .build()); postgresExecutor = new PostgresExecutor(connectionFactory.create() @@ -94,8 +113,8 @@ public void restartContainer() { @Override public Module getModule() { - // TODO: return PostgresConfiguration bean when doing https://github.com/linagora/james-project/issues/4910 - return GuiceModuleTestExtension.super.getModule(); + return Modules.combine(binder -> binder.bind(PostgresConfiguration.class) + .toInstance(postgresConfiguration)); } public String getHost() { diff --git a/server/apps/postgres-app/pom.xml b/server/apps/postgres-app/pom.xml index 7cc20dd92e8..66e4105cfa0 100644 --- a/server/apps/postgres-app/pom.xml +++ b/server/apps/postgres-app/pom.xml @@ -44,6 +44,12 @@ + + ${james.groupId} + apache-james-backends-postgres + test-jar + test + ${james.groupId} apache-james-mailbox-postgres @@ -211,6 +217,12 @@ mockito-core test + + org.testcontainers + postgresql + 1.19.1 + test + diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index 42ce13a20fe..fe536100f79 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -30,6 +30,7 @@ import org.apache.james.modules.mailbox.JPAMailboxModule; import org.apache.james.modules.mailbox.LuceneSearchMailboxModule; import org.apache.james.modules.mailbox.MemoryDeadLetterModule; +import org.apache.james.modules.mailbox.PostgresMailboxModule; import org.apache.james.modules.protocols.IMAPServerModule; import org.apache.james.modules.protocols.LMTPServerModule; import org.apache.james.modules.protocols.ManageSieveServerModule; @@ -77,12 +78,13 @@ public class PostgresJamesServerMain implements JamesServerMain { new SMTPServerModule(), WEBADMIN); - private static final Module JPA_SERVER_MODULE = Modules.combine( + private static final Module POSTGRES_SERVER_MODULE = Modules.combine( new ActiveMQQueueModule(), new NaiveDelegationStoreModule(), new DefaultProcessorsConfigurationProviderModule(), new JPADataModule(), new JPAMailboxModule(), + new PostgresMailboxModule(), new MailboxModule(), new LuceneSearchMailboxModule(), new NoJwtModule(), @@ -92,8 +94,8 @@ public class PostgresJamesServerMain implements JamesServerMain { new TaskManagerModule(), new MemoryDeadLetterModule()); - private static final Module JPA_MODULE_AGGREGATE = Modules.combine( - new MailetProcessingModule(), JPA_SERVER_MODULE, PROTOCOLS); + private static final Module POSTGRES_MODULE_AGGREGATE = Modules.combine( + new MailetProcessingModule(), POSTGRES_SERVER_MODULE, PROTOCOLS); public static void main(String[] args) throws Exception { ExtraProperties.initialize(); @@ -112,7 +114,7 @@ public static void main(String[] args) throws Exception { static GuiceJamesServer createServer(PostgresJamesConfiguration configuration) { return GuiceJamesServer.forConfiguration(configuration) - .combineWith(JPA_MODULE_AGGREGATE) + .combineWith(POSTGRES_MODULE_AGGREGATE) .combineWith(new UsersRepositoryModuleChooser(new JPAUsersRepositoryModule()) .chooseModules(configuration.getUsersRepositoryImplementation())); } diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java index 451eb4d024c..f73e46c3378 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java @@ -24,6 +24,7 @@ import java.util.EnumSet; +import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.mailbox.MailboxManager; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -50,6 +51,7 @@ private static MailboxManager mailboxManager() { .server(configuration -> PostgresJamesServerMain.createServer(configuration) .overrideWith(new TestJPAConfigurationModule()) .overrideWith(binder -> binder.bind(MailboxManager.class).toInstance(mailboxManager()))) + .extension(new PostgresExtension()) .build(); @Test diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java index 33cf9b24cb4..a17e8560f76 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java @@ -24,6 +24,7 @@ import static org.awaitility.Durations.FIVE_HUNDRED_MILLISECONDS; import static org.awaitility.Durations.ONE_MINUTE; +import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.core.quota.QuotaSizeLimit; import org.apache.james.modules.QuotaProbesImpl; import org.apache.james.modules.protocols.ImapGuiceProbe; @@ -49,6 +50,7 @@ class PostgresJamesServerTest implements JamesServerConcreteContract { .build()) .server(configuration -> PostgresJamesServerMain.createServer(configuration) .overrideWith(new TestJPAConfigurationModule())) + .extension(new PostgresExtension()) .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) .build(); diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest.java index ef77bf6e7bb..2f005078e09 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest.java @@ -21,6 +21,7 @@ import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; +import org.apache.james.backends.postgres.PostgresExtension; import org.junit.jupiter.api.extension.RegisterExtension; class PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest extends PostgresJamesServerWithSqlValidationTest { @@ -34,6 +35,7 @@ class PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest extends Post .build()) .server(configuration -> PostgresJamesServerMain.createServer(configuration) .overrideWith(new TestJPAConfigurationModuleWithSqlValidation.WithDatabaseAuthentication())) + .extension(new PostgresExtension()) .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) .build(); } diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java index 3aaa9945297..19fb866d24a 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java @@ -21,6 +21,7 @@ import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; +import org.apache.james.backends.postgres.PostgresExtension; import org.junit.jupiter.api.extension.RegisterExtension; class PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest extends PostgresJamesServerWithSqlValidationTest { @@ -33,6 +34,7 @@ class PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest exten .build()) .server(configuration -> PostgresJamesServerMain.createServer(configuration) .overrideWith(new TestJPAConfigurationModuleWithSqlValidation.NoDatabaseAuthentication())) + .extension(new PostgresExtension()) .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) .build(); } diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java index 9e2eec6c41b..66e4b6fb887 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java @@ -26,6 +26,7 @@ import java.io.IOException; import org.apache.commons.net.imap.IMAPClient; +import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.data.LdapTestExtension; import org.apache.james.modules.protocols.ImapGuiceProbe; import org.apache.james.user.ldap.DockerLdapSingleton; @@ -44,6 +45,7 @@ class PostgresWithLDAPJamesServerTest { .overrideWith(new TestJPAConfigurationModule())) .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) .extension(new LdapTestExtension()) + .extension(new PostgresExtension()) .build(); diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java new file mode 100644 index 00000000000..e09d1d3189b --- /dev/null +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java @@ -0,0 +1,32 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.modules.mailbox; + +import org.apache.james.modules.data.PostgresCommonModule; + +import com.google.inject.AbstractModule; + +public class PostgresMailboxModule extends AbstractModule { + + @Override + protected void configure() { + install(new PostgresCommonModule()); + } + +} \ No newline at end of file diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java new file mode 100644 index 00000000000..0137930f24a --- /dev/null +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -0,0 +1,38 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.modules.data; + +import java.io.FileNotFoundException; + +import javax.inject.Singleton; + +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.james.backends.postgres.PostgresConfiguration; +import org.apache.james.utils.PropertiesProvider; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; + +public class PostgresCommonModule extends AbstractModule { + @Provides + @Singleton + PostgresConfiguration provideConfiguration(PropertiesProvider propertiesProvider) throws FileNotFoundException, ConfigurationException { + return PostgresConfiguration.from(propertiesProvider.getConfiguration("postgres")); + } +} diff --git a/server/data/data-postgres/pom.xml b/server/data/data-postgres/pom.xml index 6d87122bfef..dc021f10756 100644 --- a/server/data/data-postgres/pom.xml +++ b/server/data/data-postgres/pom.xml @@ -22,6 +22,16 @@ test-jar test + + ${james.groupId} + apache-james-backends-postgres + + + ${james.groupId} + apache-james-backends-postgres + test-jar + test + ${james.groupId} james-server-core From 8ce3fdca45b66b22594a89dc9613481940f08595 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 7 Nov 2023 12:17:52 +0700 Subject: [PATCH 035/341] JAMES-2586 Guice binding for JamesPostgresConnectionFactory --- .../postgres/PostgresConfiguration.java | 2 +- .../SimpleJamesPostgresConnectionFactory.java | 3 +++ .../modules/data/PostgresCommonModule.java | 27 +++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java index 5938dd992a1..eb666f959b6 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java @@ -39,7 +39,7 @@ public class PostgresConfiguration { public static final String DATABASE_SCHEMA_DEFAULT_VALUE = "public"; public static final String RLS_ENABLED = "row.level.security.enabled"; - static class Credential { + public static class Credential { private final String username; private final String password; diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SimpleJamesPostgresConnectionFactory.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SimpleJamesPostgresConnectionFactory.java index edfba85ce9c..385f32012cc 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SimpleJamesPostgresConnectionFactory.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SimpleJamesPostgresConnectionFactory.java @@ -23,6 +23,8 @@ import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import javax.inject.Inject; + import org.apache.james.core.Domain; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -38,6 +40,7 @@ public class SimpleJamesPostgresConnectionFactory implements JamesPostgresConnec private final ConnectionFactory connectionFactory; private final Map mapDomainToConnection = new ConcurrentHashMap<>(); + @Inject public SimpleJamesPostgresConnectionFactory(ConnectionFactory connectionFactory) { this.connectionFactory = connectionFactory; } diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index 0137930f24a..b8b1fbcdc19 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -24,15 +24,42 @@ import org.apache.commons.configuration2.ex.ConfigurationException; import org.apache.james.backends.postgres.PostgresConfiguration; +import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.SimpleJamesPostgresConnectionFactory; import org.apache.james.utils.PropertiesProvider; import com.google.inject.AbstractModule; import com.google.inject.Provides; +import com.google.inject.Scopes; + +import io.r2dbc.postgresql.PostgresqlConnectionConfiguration; +import io.r2dbc.postgresql.PostgresqlConnectionFactory; +import io.r2dbc.spi.ConnectionFactory; public class PostgresCommonModule extends AbstractModule { + @Override + public void configure() { + bind(JamesPostgresConnectionFactory.class).to(SimpleJamesPostgresConnectionFactory.class); + + bind(SimpleJamesPostgresConnectionFactory.class).in(Scopes.SINGLETON); + } + @Provides @Singleton PostgresConfiguration provideConfiguration(PropertiesProvider propertiesProvider) throws FileNotFoundException, ConfigurationException { return PostgresConfiguration.from(propertiesProvider.getConfiguration("postgres")); } + + @Provides + @Singleton + ConnectionFactory postgresqlConnectionFactory(PostgresConfiguration postgresConfiguration) { + return new PostgresqlConnectionFactory(PostgresqlConnectionConfiguration.builder() + .host(postgresConfiguration.getUrl().getHost()) + .port(postgresConfiguration.getUrl().getPort()) + .username(postgresConfiguration.getCredential().getUsername()) + .password(postgresConfiguration.getCredential().getPassword()) + .database(postgresConfiguration.getDatabaseName()) + .schema(postgresConfiguration.getDatabaseSchema()) + .build()); + } } From 5f8c6f61d6fe2b76fbed1ead119e3d49c77305d2 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 7 Nov 2023 14:33:30 +0700 Subject: [PATCH 036/341] JAMES-2586 Guice binding for PostgresTableManager tested manually the binding with the subscription module -> create subscription table upon James startup successfully. --- backends-common/postgres/pom.xml | 4 +++ .../backends/postgres/PostgresModule.java | 5 +-- .../postgres/PostgresTableManager.java | 17 +++++++++- .../modules/data/PostgresCommonModule.java | 31 +++++++++++++++++++ 4 files changed, 54 insertions(+), 3 deletions(-) diff --git a/backends-common/postgres/pom.xml b/backends-common/postgres/pom.xml index 019e2b5c84b..499f3b42a71 100644 --- a/backends-common/postgres/pom.xml +++ b/backends-common/postgres/pom.xml @@ -45,6 +45,10 @@ test-jar test + + ${james.groupId} + james-server-lifecycle-api + ${james.groupId} james-server-util diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresModule.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresModule.java index 6df91b894ea..8f1725fe4b3 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresModule.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresModule.java @@ -20,6 +20,7 @@ package org.apache.james.backends.postgres; +import java.util.Collection; import java.util.List; import com.google.common.collect.ImmutableList; @@ -32,7 +33,7 @@ static PostgresModule aggregateModules(PostgresModule... modules) { .build(); } - static PostgresModule aggregateModules(List modules) { + static PostgresModule aggregateModules(Collection modules) { return builder() .modules(modules) .build(); @@ -93,7 +94,7 @@ public Builder addIndex(List indexes) { return this; } - public Builder modules(List modules) { + public Builder modules(Collection modules) { modules.forEach(module -> { addTable(module.tables()); addIndex(module.tableIndexes()); diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java index c563b5918bb..b140a837903 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java @@ -19,20 +19,35 @@ package org.apache.james.backends.postgres; +import java.util.Optional; + +import javax.inject.Inject; + +import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.lifecycle.api.Startable; import org.jooq.exception.DataAccessException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.google.common.annotations.VisibleForTesting; + import io.r2dbc.spi.Result; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -public class PostgresTableManager { +public class PostgresTableManager implements Startable { private static final Logger LOGGER = LoggerFactory.getLogger(PostgresTableManager.class); private final PostgresExecutor postgresExecutor; private final PostgresModule module; + @Inject + public PostgresTableManager(JamesPostgresConnectionFactory postgresConnectionFactory, PostgresModule module) { + this.postgresExecutor = new PostgresExecutor(postgresConnectionFactory.getConnection(Optional.empty())); + this.module = module; + } + + @VisibleForTesting public PostgresTableManager(PostgresExecutor postgresExecutor, PostgresModule module) { this.postgresExecutor = postgresExecutor; this.module = module; diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index b8b1fbcdc19..88119bd8d3d 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -19,18 +19,25 @@ package org.apache.james.modules.data; import java.io.FileNotFoundException; +import java.util.Set; import javax.inject.Singleton; import org.apache.commons.configuration2.ex.ConfigurationException; import org.apache.james.backends.postgres.PostgresConfiguration; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTableManager; import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.SimpleJamesPostgresConnectionFactory; +import org.apache.james.utils.InitializationOperation; +import org.apache.james.utils.InitilizationOperationBuilder; import org.apache.james.utils.PropertiesProvider; import com.google.inject.AbstractModule; import com.google.inject.Provides; import com.google.inject.Scopes; +import com.google.inject.multibindings.Multibinder; +import com.google.inject.multibindings.ProvidesIntoSet; import io.r2dbc.postgresql.PostgresqlConnectionConfiguration; import io.r2dbc.postgresql.PostgresqlConnectionFactory; @@ -41,6 +48,8 @@ public class PostgresCommonModule extends AbstractModule { public void configure() { bind(JamesPostgresConnectionFactory.class).to(SimpleJamesPostgresConnectionFactory.class); + Multibinder.newSetBinder(binder(), PostgresModule.class); + bind(SimpleJamesPostgresConnectionFactory.class).in(Scopes.SINGLETON); } @@ -62,4 +71,26 @@ ConnectionFactory postgresqlConnectionFactory(PostgresConfiguration postgresConf .schema(postgresConfiguration.getDatabaseSchema()) .build()); } + + @Provides + @Singleton + PostgresModule composePostgresDataDefinitions(Set modules) { + return PostgresModule.aggregateModules(modules); + } + + @Provides + @Singleton + PostgresTableManager postgresTableManager(JamesPostgresConnectionFactory jamesPostgresConnectionFactory, + PostgresModule postgresModule) { + return new PostgresTableManager(jamesPostgresConnectionFactory, postgresModule); + } + + @ProvidesIntoSet + InitializationOperation provisionPostgresTablesAndIndexes(PostgresTableManager postgresTableManager) { + return InitilizationOperationBuilder + .forClass(PostgresTableManager.class) + .init(() -> postgresTableManager.initializeTables() + .then(postgresTableManager.initializeTableIndexes()) + .block()); + } } From 88871eb0f6a7180fd68b68eec7a88b8e02408d98 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 7 Nov 2023 15:21:58 +0700 Subject: [PATCH 037/341] JAMES-2586 PostgresTableManager should only create RLS column when general RLS configuration enabled --- .../postgres/PostgresTableManager.java | 11 +++++--- .../backends/postgres/PostgresExtension.java | 2 +- .../postgres/PostgresTableManagerTest.java | 26 +++++++++++++++++-- .../modules/data/PostgresCommonModule.java | 5 ++-- 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java index b140a837903..78eb5170f6d 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java @@ -40,17 +40,22 @@ public class PostgresTableManager implements Startable { private static final Logger LOGGER = LoggerFactory.getLogger(PostgresTableManager.class); private final PostgresExecutor postgresExecutor; private final PostgresModule module; + private final boolean rlsEnabled; @Inject - public PostgresTableManager(JamesPostgresConnectionFactory postgresConnectionFactory, PostgresModule module) { + public PostgresTableManager(JamesPostgresConnectionFactory postgresConnectionFactory, + PostgresModule module, + PostgresConfiguration postgresConfiguration) { this.postgresExecutor = new PostgresExecutor(postgresConnectionFactory.getConnection(Optional.empty())); this.module = module; + this.rlsEnabled = postgresConfiguration.rlsEnabled(); } @VisibleForTesting - public PostgresTableManager(PostgresExecutor postgresExecutor, PostgresModule module) { + public PostgresTableManager(PostgresExecutor postgresExecutor, PostgresModule module, boolean rlsEnabled) { this.postgresExecutor = postgresExecutor; this.module = module; + this.rlsEnabled = rlsEnabled; } public Mono initializeTables() { @@ -70,7 +75,7 @@ public Mono initializeTables() { } private Mono alterTableEnableRLSIfNeed(PostgresTable table) { - if (table.isEnableRowLevelSecurity()) { + if (rlsEnabled && table.isEnableRowLevelSecurity()) { return alterTableEnableRLS(table); } return Mono.empty(); diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index 54eb64ab490..bfc1e04f007 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -137,7 +137,7 @@ public ConnectionFactory getConnectionFactory() { } private void initTablesAndIndexes() { - PostgresTableManager postgresTableManager = new PostgresTableManager(postgresExecutor, postgresModule); + PostgresTableManager postgresTableManager = new PostgresTableManager(postgresExecutor, postgresModule, postgresConfiguration.rlsEnabled()); postgresTableManager.initializeTables().block(); postgresTableManager.initializeTableIndexes().block(); } diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java index c08a070a489..2805c629306 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java @@ -42,7 +42,7 @@ class PostgresTableManagerTest { static PostgresExtension postgresExtension = new PostgresExtension(); Function tableManagerFactory = - module -> new PostgresTableManager(new PostgresExecutor(postgresExtension.getConnection()), module); + module -> new PostgresTableManager(new PostgresExecutor(postgresExtension.getConnection()), module, true); @Test void initializeTableShouldSuccessWhenModuleHasSingleTable() { @@ -279,7 +279,7 @@ void truncateShouldEmptyTableData() { } @Test - void createTableShouldSucceedWhenEnableRLS() { + void createTableShouldCreateRlsColumnWhenEnableRLS() { String tableName = "tbn1"; PostgresTable table = PostgresTable.name(tableName) @@ -318,6 +318,28 @@ void createTableShouldSucceedWhenEnableRLS() { Pair.of("tbn1", true)); } + @Test + void createTableShouldNotCreateRlsColumnWhenDisableRLS() { + String tableName = "tbn1"; + + PostgresTable table = PostgresTable.name(tableName) + .createTableStep((dsl, tbn) -> dsl.createTable(tbn) + .column("clm1", SQLDataType.UUID.notNull()) + .column("clm2", SQLDataType.VARCHAR(255).notNull())) + .enableRowLevelSecurity(); + + PostgresModule module = PostgresModule.table(table); + boolean disabledRLS = false; + PostgresTableManager testee = new PostgresTableManager(new PostgresExecutor(postgresExtension.getConnection()), module, disabledRLS); + + testee.initializeTables() + .block(); + + Pair rlsColumn = Pair.of("domain", "character varying"); + assertThat(getColumnNameAndDataType(tableName)) + .doesNotContain(rlsColumn); + } + private List> getColumnNameAndDataType(String tableName) { return postgresExtension.getConnection() .flatMapMany(connection -> Flux.from(Mono.from(connection.createStatement("SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_name = $1;") diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index 88119bd8d3d..bcc2eef5505 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -81,8 +81,9 @@ PostgresModule composePostgresDataDefinitions(Set modules) { @Provides @Singleton PostgresTableManager postgresTableManager(JamesPostgresConnectionFactory jamesPostgresConnectionFactory, - PostgresModule postgresModule) { - return new PostgresTableManager(jamesPostgresConnectionFactory, postgresModule); + PostgresModule postgresModule, + PostgresConfiguration postgresConfiguration) { + return new PostgresTableManager(jamesPostgresConnectionFactory, postgresModule, postgresConfiguration); } @ProvidesIntoSet From dcc8522e094f6aaafa0e04de82db8ef0b495b655 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 7 Nov 2023 15:50:55 +0700 Subject: [PATCH 038/341] JAMES-2586 Sample docker configuration for postgres.properties --- .../sample-configuration/postgres.properties | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 server/apps/postgres-app/sample-configuration/postgres.properties diff --git a/server/apps/postgres-app/sample-configuration/postgres.properties b/server/apps/postgres-app/sample-configuration/postgres.properties new file mode 100644 index 00000000000..0bfe376f4d8 --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/postgres.properties @@ -0,0 +1,11 @@ +# String. Required. PostgreSQL URI in the format postgresql://username:password@host:port +url=postgresql://james:secret1@postgres:5432 + +# String. Optional, default to 'postgres'. Database name. +database.name=james + +# String. Optional, default to 'public'. Database schema. +database.schema=public + +# Boolean. Optional, default to false. Whether to enable row level security. +row.level.security.enabled=true From fe01233b9e23a8b4cd71f2f74f7d45bc2d2bda53 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 8 Nov 2023 10:03:03 +0700 Subject: [PATCH 039/341] JAMES-2586 Fix review comments --- .../postgres/PostgresConfiguration.java | 14 ++++++------- .../postgres/PostgresConfigurationTest.java | 4 ++-- .../backends/postgres/PostgresExtension.java | 21 +++++++++++++------ ...pleJamesPostgresConnectionFactoryTest.java | 3 ++- .../modules/data/PostgresCommonModule.java | 4 ++-- 5 files changed, 28 insertions(+), 18 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java index eb666f959b6..73dbf36211c 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java @@ -144,14 +144,14 @@ public static PostgresConfiguration from(Configuration propertiesConfiguration) .build(); } - private final URI url; + private final URI uri; private final Credential credential; private final String databaseName; private final String databaseSchema; private final boolean rlsEnabled; - private PostgresConfiguration(URI url, Credential credential, String databaseName, String databaseSchema, boolean rlsEnabled) { - this.url = url; + private PostgresConfiguration(URI uri, Credential credential, String databaseName, String databaseSchema, boolean rlsEnabled) { + this.uri = uri; this.credential = credential; this.databaseName = databaseName; this.databaseSchema = databaseSchema; @@ -164,7 +164,7 @@ public final boolean equals(Object o) { PostgresConfiguration that = (PostgresConfiguration) o; return Objects.equals(this.rlsEnabled, that.rlsEnabled) - && Objects.equals(this.url, that.url) + && Objects.equals(this.uri, that.uri) && Objects.equals(this.credential, that.credential) && Objects.equals(this.databaseName, that.databaseName) && Objects.equals(this.databaseSchema, that.databaseSchema); @@ -174,11 +174,11 @@ public final boolean equals(Object o) { @Override public final int hashCode() { - return Objects.hash(url, credential, databaseName, databaseSchema, rlsEnabled); + return Objects.hash(uri, credential, databaseName, databaseSchema, rlsEnabled); } - public URI getUrl() { - return url; + public URI getUri() { + return uri; } public Credential getCredential() { diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java index c90dc0f35f0..b324ec527af 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java @@ -59,8 +59,8 @@ void shouldParseValidURI() { .url("postgresql://username:password@postgreshost:5672") .build(); - assertThat(configuration.getUrl().getHost()).isEqualTo("postgreshost"); - assertThat(configuration.getUrl().getPort()).isEqualTo(5672); + assertThat(configuration.getUri().getHost()).isEqualTo("postgreshost"); + assertThat(configuration.getUri().getPort()).isEqualTo(5672); assertThat(configuration.getCredential().getUsername()).isEqualTo("username"); assertThat(configuration.getCredential().getPassword()).isEqualTo("password"); } diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index bfc1e04f007..cb8a73d7da4 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -19,6 +19,9 @@ package org.apache.james.backends.postgres; +import java.net.URISyntaxException; + +import org.apache.http.client.utils.URIBuilder; import org.apache.james.GuiceModuleTestExtension; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.junit.jupiter.api.extension.ExtensionContext; @@ -57,24 +60,30 @@ public PostgresExtension() { } @Override - public void beforeAll(ExtensionContext extensionContext) { + public void beforeAll(ExtensionContext extensionContext) throws Exception { if (!DockerPostgresSingleton.SINGLETON.isRunning()) { DockerPostgresSingleton.SINGLETON.start(); } initPostgresSession(); } - private void initPostgresSession() { + private void initPostgresSession() throws URISyntaxException { postgresConfiguration = PostgresConfiguration.builder() - .url(String.format("postgresql://%s:%s@%s:%d", PostgresFixture.Database.DB_USER, PostgresFixture.Database.DB_PASSWORD, getHost(), getMappedPort())) + .url(new URIBuilder() + .setScheme("postgresql") + .setHost(getHost()) + .setPort(getMappedPort()) + .setUserInfo(PostgresFixture.Database.DB_USER, PostgresFixture.Database.DB_PASSWORD) + .build() + .toString()) .databaseName(PostgresFixture.Database.DB_NAME) .databaseSchema(PostgresFixture.Database.SCHEMA) .rlsEnabled(rlsEnabled) .build(); PostgresqlConnectionFactory connectionFactory = new PostgresqlConnectionFactory(PostgresqlConnectionConfiguration.builder() - .host(postgresConfiguration.getUrl().getHost()) - .port(postgresConfiguration.getUrl().getPort()) + .host(postgresConfiguration.getUri().getHost()) + .port(postgresConfiguration.getUri().getPort()) .username(postgresConfiguration.getCredential().getUsername()) .password(postgresConfiguration.getCredential().getPassword()) .database(postgresConfiguration.getDatabaseName()) @@ -105,7 +114,7 @@ public void afterEach(ExtensionContext extensionContext) { resetSchema(); } - public void restartContainer() { + public void restartContainer() throws URISyntaxException { DockerPostgresSingleton.SINGLETON.stop(); DockerPostgresSingleton.SINGLETON.start(); initPostgresSession(); diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/SimpleJamesPostgresConnectionFactoryTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/SimpleJamesPostgresConnectionFactoryTest.java index 1ebf19ba35c..962599473f7 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/SimpleJamesPostgresConnectionFactoryTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/SimpleJamesPostgresConnectionFactoryTest.java @@ -21,6 +21,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.net.URISyntaxException; import java.time.Duration; import java.util.Optional; import java.util.Set; @@ -61,7 +62,7 @@ void beforeEach() { } @AfterEach - void afterEach() { + void afterEach() throws URISyntaxException { postgresExtension.restartContainer(); } diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index bcc2eef5505..b95a1fdf01e 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -63,8 +63,8 @@ PostgresConfiguration provideConfiguration(PropertiesProvider propertiesProvider @Singleton ConnectionFactory postgresqlConnectionFactory(PostgresConfiguration postgresConfiguration) { return new PostgresqlConnectionFactory(PostgresqlConnectionConfiguration.builder() - .host(postgresConfiguration.getUrl().getHost()) - .port(postgresConfiguration.getUrl().getPort()) + .host(postgresConfiguration.getUri().getHost()) + .port(postgresConfiguration.getUri().getPort()) .username(postgresConfiguration.getCredential().getUsername()) .password(postgresConfiguration.getCredential().getPassword()) .database(postgresConfiguration.getDatabaseName()) From a59c75cf0303cffbc489ddea8036f495067ffe2f Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 8 Nov 2023 11:26:23 +0700 Subject: [PATCH 040/341] JAMES-2586 Guice binding for Postgres subscription module --- .../backends/postgres/PostgresExtension.java | 3 +- .../jpa/JPAMailboxSessionMapperFactory.java | 13 +- .../jpa/user/JPASubscriptionMapper.java | 135 ------------------ .../mailbox/jpa/JPAMailboxManagerTest.java | 8 +- .../jpa/JPASubscriptionManagerTest.java | 11 +- .../jpa/JpaMailboxManagerProvider.java | 7 +- .../jpa/JpaMailboxManagerStressTest.java | 8 +- .../JPARecomputeCurrentQuotasServiceTest.java | 12 +- .../mailbox/PostgresMailboxModule.java | 6 + 9 files changed, 56 insertions(+), 147 deletions(-) delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/JPASubscriptionMapper.java diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index cb8a73d7da4..086080b84f8 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -81,7 +81,7 @@ private void initPostgresSession() throws URISyntaxException { .rlsEnabled(rlsEnabled) .build(); - PostgresqlConnectionFactory connectionFactory = new PostgresqlConnectionFactory(PostgresqlConnectionConfiguration.builder() + connectionFactory = new PostgresqlConnectionFactory(PostgresqlConnectionConfiguration.builder() .host(postgresConfiguration.getUri().getHost()) .port(postgresConfiguration.getUri().getPort()) .username(postgresConfiguration.getCredential().getUsername()) @@ -141,6 +141,7 @@ public Mono getConnection() { public PostgresExecutor getPostgresExecutor() { return postgresExecutor; } + public ConnectionFactory getConnectionFactory() { return connectionFactory; } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAMailboxSessionMapperFactory.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAMailboxSessionMapperFactory.java index 670651b13f9..5c91252acbe 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAMailboxSessionMapperFactory.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAMailboxSessionMapperFactory.java @@ -25,6 +25,8 @@ import org.apache.commons.lang3.NotImplementedException; import org.apache.james.backends.jpa.EntityManagerUtils; import org.apache.james.backends.jpa.JPAConfiguration; +import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.mailbox.MailboxSession; import org.apache.james.mailbox.jpa.mail.JPAAnnotationMapper; import org.apache.james.mailbox.jpa.mail.JPAAttachmentMapper; @@ -32,7 +34,8 @@ import org.apache.james.mailbox.jpa.mail.JPAMessageMapper; import org.apache.james.mailbox.jpa.mail.JPAModSeqProvider; import org.apache.james.mailbox.jpa.mail.JPAUidProvider; -import org.apache.james.mailbox.jpa.user.JPASubscriptionMapper; +import org.apache.james.mailbox.jpa.user.PostgresSubscriptionDAO; +import org.apache.james.mailbox.jpa.user.PostgresSubscriptionMapper; import org.apache.james.mailbox.store.MailboxSessionMapperFactory; import org.apache.james.mailbox.store.mail.AnnotationMapper; import org.apache.james.mailbox.store.mail.AttachmentMapper; @@ -55,16 +58,19 @@ public class JPAMailboxSessionMapperFactory extends MailboxSessionMapperFactory private final JPAModSeqProvider modSeqProvider; private final AttachmentMapper attachmentMapper; private final JPAConfiguration jpaConfiguration; + private final JamesPostgresConnectionFactory postgresConnectionFactory; @Inject public JPAMailboxSessionMapperFactory(EntityManagerFactory entityManagerFactory, JPAUidProvider uidProvider, - JPAModSeqProvider modSeqProvider, JPAConfiguration jpaConfiguration) { + JPAModSeqProvider modSeqProvider, JPAConfiguration jpaConfiguration, + JamesPostgresConnectionFactory postgresConnectionFactory) { this.entityManagerFactory = entityManagerFactory; this.uidProvider = uidProvider; this.modSeqProvider = modSeqProvider; EntityManagerUtils.safelyClose(createEntityManager()); this.attachmentMapper = new JPAAttachmentMapper(entityManagerFactory); this.jpaConfiguration = jpaConfiguration; + this.postgresConnectionFactory = postgresConnectionFactory; } @Override @@ -84,7 +90,8 @@ public MessageIdMapper createMessageIdMapper(MailboxSession session) { @Override public SubscriptionMapper createSubscriptionMapper(MailboxSession session) { - return new JPASubscriptionMapper(entityManagerFactory); + return new PostgresSubscriptionMapper(new PostgresSubscriptionDAO(new PostgresExecutor( + postgresConnectionFactory.getConnection(session.getUser().getDomainPart())))); } /** diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/JPASubscriptionMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/JPASubscriptionMapper.java deleted file mode 100644 index d32dd268ca5..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/JPASubscriptionMapper.java +++ /dev/null @@ -1,135 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.mailbox.jpa.user; - -import static org.apache.james.mailbox.jpa.user.model.JPASubscription.FIND_MAILBOX_SUBSCRIPTION_FOR_USER; -import static org.apache.james.mailbox.jpa.user.model.JPASubscription.FIND_SUBSCRIPTIONS_FOR_USER; - -import java.util.List; -import java.util.Optional; - -import javax.persistence.EntityManager; -import javax.persistence.EntityManagerFactory; -import javax.persistence.EntityTransaction; -import javax.persistence.NoResultException; -import javax.persistence.PersistenceException; - -import org.apache.james.core.Username; -import org.apache.james.mailbox.exception.SubscriptionException; -import org.apache.james.mailbox.jpa.JPATransactionalMapper; -import org.apache.james.mailbox.jpa.user.model.JPASubscription; -import org.apache.james.mailbox.store.user.SubscriptionMapper; -import org.apache.james.mailbox.store.user.model.Subscription; - -import com.google.common.collect.ImmutableList; - -/** - * JPA implementation of a {@link SubscriptionMapper}. This class is not thread-safe! - */ -public class JPASubscriptionMapper extends JPATransactionalMapper implements SubscriptionMapper { - - public JPASubscriptionMapper(EntityManagerFactory entityManagerFactory) { - super(entityManagerFactory); - } - - @Override - public void save(Subscription subscription) throws SubscriptionException { - EntityManager entityManager = getEntityManager(); - EntityTransaction transaction = entityManager.getTransaction(); - boolean localTransaction = !transaction.isActive(); - if (localTransaction) { - transaction.begin(); - } - try { - if (!exists(entityManager, subscription)) { - entityManager.persist(new JPASubscription(subscription)); - } - if (localTransaction) { - if (transaction.isActive()) { - transaction.commit(); - } - } - } catch (PersistenceException e) { - if (transaction.isActive()) { - transaction.rollback(); - } - throw new SubscriptionException(e); - } - } - - @Override - public List findSubscriptionsForUser(Username user) throws SubscriptionException { - try { - return getEntityManager().createNamedQuery(FIND_SUBSCRIPTIONS_FOR_USER, JPASubscription.class) - .setParameter("userParam", user.asString()) - .getResultList() - .stream() - .map(JPASubscription::toSubscription) - .collect(ImmutableList.toImmutableList()); - } catch (PersistenceException e) { - throw new SubscriptionException(e); - } - } - - @Override - public void delete(Subscription subscription) throws SubscriptionException { - EntityManager entityManager = getEntityManager(); - EntityTransaction transaction = entityManager.getTransaction(); - boolean localTransaction = !transaction.isActive(); - if (localTransaction) { - transaction.begin(); - } - try { - findJpaSubscription(entityManager, subscription) - .ifPresent(entityManager::remove); - if (localTransaction) { - if (transaction.isActive()) { - transaction.commit(); - } - } - } catch (PersistenceException e) { - if (transaction.isActive()) { - transaction.rollback(); - } - throw new SubscriptionException(e); - } - } - - private Optional findJpaSubscription(EntityManager entityManager, Subscription subscription) { - return entityManager.createNamedQuery(FIND_MAILBOX_SUBSCRIPTION_FOR_USER, JPASubscription.class) - .setParameter("userParam", subscription.getUser().asString()) - .setParameter("mailboxParam", subscription.getMailbox()) - .getResultList() - .stream() - .findFirst(); - } - - private boolean exists(EntityManager entityManager, Subscription subscription) throws SubscriptionException { - try { - return !entityManager.createNamedQuery(FIND_MAILBOX_SUBSCRIPTION_FOR_USER, JPASubscription.class) - .setParameter("userParam", subscription.getUser().asString()) - .setParameter("mailboxParam", subscription.getMailbox()) - .getResultList().isEmpty(); - } catch (NoResultException e) { - return false; - } catch (PersistenceException e) { - throw new SubscriptionException(e); - } - } -} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPAMailboxManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPAMailboxManagerTest.java index b31ce314336..f912607a5d7 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPAMailboxManagerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPAMailboxManagerTest.java @@ -21,15 +21,18 @@ import java.util.Optional; import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.events.EventBus; import org.apache.james.mailbox.MailboxManagerTest; import org.apache.james.mailbox.SubscriptionManager; import org.apache.james.mailbox.jpa.openjpa.OpenJPAMailboxManager; +import org.apache.james.mailbox.jpa.user.PostgresSubscriptionModule; import org.apache.james.mailbox.store.StoreSubscriptionManager; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; class JPAMailboxManagerTest extends MailboxManagerTest { @@ -39,13 +42,16 @@ class JPAMailboxManagerTest extends MailboxManagerTest { class HookTests { } + @RegisterExtension + static PostgresExtension postgresExtension = new PostgresExtension(PostgresSubscriptionModule.MODULE); + static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); Optional openJPAMailboxManager = Optional.empty(); @Override protected OpenJPAMailboxManager provideMailboxManager() { if (!openJPAMailboxManager.isPresent()) { - openJPAMailboxManager = Optional.of(JpaMailboxManagerProvider.provideMailboxManager(JPA_TEST_CLUSTER)); + openJPAMailboxManager = Optional.of(JpaMailboxManagerProvider.provideMailboxManager(JPA_TEST_CLUSTER, postgresExtension)); } return openJPAMailboxManager.get(); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPASubscriptionManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPASubscriptionManagerTest.java index fdc777d31f6..b86888ae00b 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPASubscriptionManagerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPASubscriptionManagerTest.java @@ -22,6 +22,8 @@ import org.apache.james.backends.jpa.JPAConfiguration; import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.utils.SimpleJamesPostgresConnectionFactory; import org.apache.james.events.EventBusTestFixture; import org.apache.james.events.InVMEventBus; import org.apache.james.events.MemoryEventDeadLetters; @@ -30,14 +32,18 @@ import org.apache.james.mailbox.SubscriptionManagerContract; import org.apache.james.mailbox.jpa.mail.JPAModSeqProvider; import org.apache.james.mailbox.jpa.mail.JPAUidProvider; +import org.apache.james.mailbox.jpa.user.PostgresSubscriptionModule; import org.apache.james.mailbox.store.StoreSubscriptionManager; import org.apache.james.metrics.tests.RecordingMetricFactory; - import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; class JPASubscriptionManagerTest implements SubscriptionManagerContract { + @RegisterExtension + static PostgresExtension postgresExtension = new PostgresExtension(PostgresSubscriptionModule.MODULE); + static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); SubscriptionManager subscriptionManager; @@ -59,7 +65,8 @@ void setUp() { JPAMailboxSessionMapperFactory mapperFactory = new JPAMailboxSessionMapperFactory(entityManagerFactory, new JPAUidProvider(entityManagerFactory), new JPAModSeqProvider(entityManagerFactory), - jpaConfiguration); + jpaConfiguration, + new SimpleJamesPostgresConnectionFactory(postgresExtension.getConnectionFactory())); InVMEventBus eventBus = new InVMEventBus(new InVmEventDelivery(new RecordingMetricFactory()), EventBusTestFixture.RETRY_BACKOFF_CONFIGURATION, new MemoryEventDeadLetters()); subscriptionManager = new StoreSubscriptionManager(mapperFactory, mapperFactory, eventBus); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerProvider.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerProvider.java index 770f17dd7e1..616f7adf700 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerProvider.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerProvider.java @@ -25,6 +25,8 @@ import org.apache.james.backends.jpa.JPAConfiguration; import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.utils.SimpleJamesPostgresConnectionFactory; import org.apache.james.events.EventBusTestFixture; import org.apache.james.events.InVMEventBus; import org.apache.james.events.MemoryEventDeadLetters; @@ -54,7 +56,7 @@ public class JpaMailboxManagerProvider { private static final int LIMIT_ANNOTATIONS = 3; private static final int LIMIT_ANNOTATION_SIZE = 30; - public static OpenJPAMailboxManager provideMailboxManager(JpaTestCluster jpaTestCluster) { + public static OpenJPAMailboxManager provideMailboxManager(JpaTestCluster jpaTestCluster, PostgresExtension postgresExtension) { EntityManagerFactory entityManagerFactory = jpaTestCluster.getEntityManagerFactory(); JPAConfiguration jpaConfiguration = JPAConfiguration.builder() @@ -63,7 +65,8 @@ public static OpenJPAMailboxManager provideMailboxManager(JpaTestCluster jpaTest .attachmentStorage(true) .build(); - JPAMailboxSessionMapperFactory mf = new JPAMailboxSessionMapperFactory(entityManagerFactory, new JPAUidProvider(entityManagerFactory), new JPAModSeqProvider(entityManagerFactory), jpaConfiguration); + JPAMailboxSessionMapperFactory mf = new JPAMailboxSessionMapperFactory(entityManagerFactory, new JPAUidProvider(entityManagerFactory), new JPAModSeqProvider(entityManagerFactory), jpaConfiguration, + new SimpleJamesPostgresConnectionFactory(postgresExtension.getConnectionFactory())); MailboxACLResolver aclResolver = new UnionMailboxACLResolver(); MessageParser messageParser = new MessageParser(); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerStressTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerStressTest.java index 69176686d02..06e275398de 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerStressTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerStressTest.java @@ -22,14 +22,20 @@ import java.util.Optional; import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.events.EventBus; import org.apache.james.mailbox.MailboxManagerStressContract; import org.apache.james.mailbox.jpa.openjpa.OpenJPAMailboxManager; +import org.apache.james.mailbox.jpa.user.PostgresSubscriptionModule; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; class JpaMailboxManagerStressTest implements MailboxManagerStressContract { + @RegisterExtension + static PostgresExtension postgresExtension = new PostgresExtension(PostgresSubscriptionModule.MODULE); + static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); Optional openJPAMailboxManager = Optional.empty(); @@ -46,7 +52,7 @@ public EventBus retrieveEventBus() { @BeforeEach void setUp() { if (!openJPAMailboxManager.isPresent()) { - openJPAMailboxManager = Optional.of(JpaMailboxManagerProvider.provideMailboxManager(JPA_TEST_CLUSTER)); + openJPAMailboxManager = Optional.of(JpaMailboxManagerProvider.provideMailboxManager(JPA_TEST_CLUSTER, postgresExtension)); } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/task/JPARecomputeCurrentQuotasServiceTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/task/JPARecomputeCurrentQuotasServiceTest.java index 38fad55face..fe4bb497dac 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/task/JPARecomputeCurrentQuotasServiceTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/task/JPARecomputeCurrentQuotasServiceTest.java @@ -24,6 +24,8 @@ import org.apache.commons.configuration2.BaseHierarchicalConfiguration; import org.apache.james.backends.jpa.JPAConfiguration; import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.utils.SimpleJamesPostgresConnectionFactory; import org.apache.james.domainlist.api.DomainList; import org.apache.james.domainlist.jpa.model.JPADomain; import org.apache.james.mailbox.MailboxManager; @@ -34,6 +36,7 @@ import org.apache.james.mailbox.jpa.mail.JPAModSeqProvider; import org.apache.james.mailbox.jpa.mail.JPAUidProvider; import org.apache.james.mailbox.jpa.quota.JpaCurrentQuotaManager; +import org.apache.james.mailbox.jpa.user.PostgresSubscriptionModule; import org.apache.james.mailbox.quota.CurrentQuotaManager; import org.apache.james.mailbox.quota.UserQuotaRootResolver; import org.apache.james.mailbox.quota.task.RecomputeCurrentQuotasService; @@ -47,12 +50,16 @@ import org.apache.james.user.jpa.model.JPAUser; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; class JPARecomputeCurrentQuotasServiceTest implements RecomputeCurrentQuotasServiceContract { + @RegisterExtension + static PostgresExtension postgresExtension = new PostgresExtension(PostgresSubscriptionModule.MODULE); + static final DomainList NO_DOMAIN_LIST = null; static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(ImmutableList.>builder() @@ -81,7 +88,8 @@ void setUp() throws Exception { JPAMailboxSessionMapperFactory mapperFactory = new JPAMailboxSessionMapperFactory(entityManagerFactory, new JPAUidProvider(entityManagerFactory), new JPAModSeqProvider(entityManagerFactory), - jpaConfiguration); + jpaConfiguration, + new SimpleJamesPostgresConnectionFactory(postgresExtension.getConnectionFactory())); usersRepository = new JPAUsersRepository(NO_DOMAIN_LIST); usersRepository.setEntityManagerFactory(JPA_TEST_CLUSTER.getEntityManagerFactory()); @@ -89,7 +97,7 @@ void setUp() throws Exception { configuration.addProperty("enableVirtualHosting", "false"); usersRepository.configure(configuration); - mailboxManager = JpaMailboxManagerProvider.provideMailboxManager(JPA_TEST_CLUSTER); + mailboxManager = JpaMailboxManagerProvider.provideMailboxManager(JPA_TEST_CLUSTER, postgresExtension); sessionProvider = mailboxManager.getSessionProvider(); currentQuotaManager = new JpaCurrentQuotaManager(entityManagerFactory); diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java index e09d1d3189b..6d937bdb2e0 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java @@ -18,15 +18,21 @@ ****************************************************************/ package org.apache.james.modules.mailbox; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.mailbox.jpa.user.PostgresSubscriptionModule; import org.apache.james.modules.data.PostgresCommonModule; import com.google.inject.AbstractModule; +import com.google.inject.multibindings.Multibinder; public class PostgresMailboxModule extends AbstractModule { @Override protected void configure() { install(new PostgresCommonModule()); + + Multibinder postgresDataDefinitions = Multibinder.newSetBinder(binder(), PostgresModule.class); + postgresDataDefinitions.addBinding().toInstance(PostgresSubscriptionModule.MODULE); } } \ No newline at end of file From 1c701217114e98d620401077a4213f0c86cffdb8 Mon Sep 17 00:00:00 2001 From: vttran Date: Thu, 9 Nov 2023 14:00:06 +0700 Subject: [PATCH 041/341] JAMES-2586 Rename Postgres Subscription --- .../jpa/openjpa/OpenJPAMailboxManager.java | 5 ++--- .../PostgresMailboxSessionMapperFactory.java} | 10 +++++----- .../resources/META-INF/spring/mailbox-jpa.xml | 2 +- .../mailbox/jpa/JpaMailboxManagerProvider.java | 3 ++- .../JPARecomputeCurrentQuotasServiceTest.java | 4 ++-- .../PostgresSubscriptionManagerTest.java} | 7 ++++--- .../james/modules/mailbox/JPAMailboxModule.java | 16 ++++++++-------- 7 files changed, 24 insertions(+), 23 deletions(-) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa/JPAMailboxSessionMapperFactory.java => postgres/PostgresMailboxSessionMapperFactory.java} (91%) rename mailbox/postgres/src/test/java/org/apache/james/mailbox/{jpa/JPASubscriptionManagerTest.java => postgres/PostgresSubscriptionManagerTest.java} (92%) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMailboxManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMailboxManager.java index 5346770f52a..525f294c7db 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMailboxManager.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMailboxManager.java @@ -27,9 +27,9 @@ import org.apache.james.events.EventBus; import org.apache.james.mailbox.MailboxSession; import org.apache.james.mailbox.SessionProvider; -import org.apache.james.mailbox.jpa.JPAMailboxSessionMapperFactory; import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; import org.apache.james.mailbox.store.JVMMailboxPathLocker; import org.apache.james.mailbox.store.MailboxManagerConfiguration; import org.apache.james.mailbox.store.PreDeletionHooks; @@ -44,7 +44,6 @@ /** * OpenJPA implementation of MailboxManager - * */ public class OpenJPAMailboxManager extends StoreMailboxManager { public static final EnumSet MAILBOX_CAPABILITIES = EnumSet.of(MailboxCapabilities.UserFlag, @@ -53,7 +52,7 @@ public class OpenJPAMailboxManager extends StoreMailboxManager { MailboxCapabilities.Annotation); @Inject - public OpenJPAMailboxManager(JPAMailboxSessionMapperFactory mapperFactory, + public OpenJPAMailboxManager(PostgresMailboxSessionMapperFactory mapperFactory, SessionProvider sessionProvider, MessageParser messageParser, MessageId.Factory messageIdFactory, diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAMailboxSessionMapperFactory.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java similarity index 91% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAMailboxSessionMapperFactory.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java index 5c91252acbe..b9d06f0486c 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAMailboxSessionMapperFactory.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa; +package org.apache.james.mailbox.postgres; import javax.inject.Inject; import javax.persistence.EntityManager; @@ -51,7 +51,7 @@ * JPA implementation of {@link MailboxSessionMapperFactory} * */ -public class JPAMailboxSessionMapperFactory extends MailboxSessionMapperFactory implements AttachmentMapperFactory { +public class PostgresMailboxSessionMapperFactory extends MailboxSessionMapperFactory implements AttachmentMapperFactory { private final EntityManagerFactory entityManagerFactory; private final JPAUidProvider uidProvider; @@ -61,9 +61,9 @@ public class JPAMailboxSessionMapperFactory extends MailboxSessionMapperFactory private final JamesPostgresConnectionFactory postgresConnectionFactory; @Inject - public JPAMailboxSessionMapperFactory(EntityManagerFactory entityManagerFactory, JPAUidProvider uidProvider, - JPAModSeqProvider modSeqProvider, JPAConfiguration jpaConfiguration, - JamesPostgresConnectionFactory postgresConnectionFactory) { + public PostgresMailboxSessionMapperFactory(EntityManagerFactory entityManagerFactory, JPAUidProvider uidProvider, + JPAModSeqProvider modSeqProvider, JPAConfiguration jpaConfiguration, + JamesPostgresConnectionFactory postgresConnectionFactory) { this.entityManagerFactory = entityManagerFactory; this.uidProvider = uidProvider; this.modSeqProvider = modSeqProvider; diff --git a/mailbox/postgres/src/main/resources/META-INF/spring/mailbox-jpa.xml b/mailbox/postgres/src/main/resources/META-INF/spring/mailbox-jpa.xml index 30b9d4a6a95..fa6e3ad40fe 100644 --- a/mailbox/postgres/src/main/resources/META-INF/spring/mailbox-jpa.xml +++ b/mailbox/postgres/src/main/resources/META-INF/spring/mailbox-jpa.xml @@ -53,7 +53,7 @@ - + diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerProvider.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerProvider.java index 616f7adf700..b41338e4adc 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerProvider.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerProvider.java @@ -38,6 +38,7 @@ import org.apache.james.mailbox.jpa.mail.JPAModSeqProvider; import org.apache.james.mailbox.jpa.mail.JPAUidProvider; import org.apache.james.mailbox.jpa.openjpa.OpenJPAMailboxManager; +import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; import org.apache.james.mailbox.store.SessionProviderImpl; import org.apache.james.mailbox.store.StoreMailboxAnnotationManager; import org.apache.james.mailbox.store.StoreRightManager; @@ -65,7 +66,7 @@ public static OpenJPAMailboxManager provideMailboxManager(JpaTestCluster jpaTest .attachmentStorage(true) .build(); - JPAMailboxSessionMapperFactory mf = new JPAMailboxSessionMapperFactory(entityManagerFactory, new JPAUidProvider(entityManagerFactory), new JPAModSeqProvider(entityManagerFactory), jpaConfiguration, + PostgresMailboxSessionMapperFactory mf = new PostgresMailboxSessionMapperFactory(entityManagerFactory, new JPAUidProvider(entityManagerFactory), new JPAModSeqProvider(entityManagerFactory), jpaConfiguration, new SimpleJamesPostgresConnectionFactory(postgresExtension.getConnectionFactory())); MailboxACLResolver aclResolver = new UnionMailboxACLResolver(); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/task/JPARecomputeCurrentQuotasServiceTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/task/JPARecomputeCurrentQuotasServiceTest.java index fe4bb497dac..82aad9b3a69 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/task/JPARecomputeCurrentQuotasServiceTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/task/JPARecomputeCurrentQuotasServiceTest.java @@ -31,7 +31,7 @@ import org.apache.james.mailbox.MailboxManager; import org.apache.james.mailbox.SessionProvider; import org.apache.james.mailbox.jpa.JPAMailboxFixture; -import org.apache.james.mailbox.jpa.JPAMailboxSessionMapperFactory; +import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; import org.apache.james.mailbox.jpa.JpaMailboxManagerProvider; import org.apache.james.mailbox.jpa.mail.JPAModSeqProvider; import org.apache.james.mailbox.jpa.mail.JPAUidProvider; @@ -85,7 +85,7 @@ void setUp() throws Exception { .driverURL("driverUrl") .build(); - JPAMailboxSessionMapperFactory mapperFactory = new JPAMailboxSessionMapperFactory(entityManagerFactory, + PostgresMailboxSessionMapperFactory mapperFactory = new PostgresMailboxSessionMapperFactory(entityManagerFactory, new JPAUidProvider(entityManagerFactory), new JPAModSeqProvider(entityManagerFactory), jpaConfiguration, diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPASubscriptionManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresSubscriptionManagerTest.java similarity index 92% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPASubscriptionManagerTest.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresSubscriptionManagerTest.java index b86888ae00b..238de23842a 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPASubscriptionManagerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresSubscriptionManagerTest.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa; +package org.apache.james.mailbox.postgres; import javax.persistence.EntityManagerFactory; @@ -30,6 +30,7 @@ import org.apache.james.events.delivery.InVmEventDelivery; import org.apache.james.mailbox.SubscriptionManager; import org.apache.james.mailbox.SubscriptionManagerContract; +import org.apache.james.mailbox.jpa.JPAMailboxFixture; import org.apache.james.mailbox.jpa.mail.JPAModSeqProvider; import org.apache.james.mailbox.jpa.mail.JPAUidProvider; import org.apache.james.mailbox.jpa.user.PostgresSubscriptionModule; @@ -39,7 +40,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.RegisterExtension; -class JPASubscriptionManagerTest implements SubscriptionManagerContract { +class PostgresSubscriptionManagerTest implements SubscriptionManagerContract { @RegisterExtension static PostgresExtension postgresExtension = new PostgresExtension(PostgresSubscriptionModule.MODULE); @@ -62,7 +63,7 @@ void setUp() { .driverURL("driverUrl") .build(); - JPAMailboxSessionMapperFactory mapperFactory = new JPAMailboxSessionMapperFactory(entityManagerFactory, + PostgresMailboxSessionMapperFactory mapperFactory = new PostgresMailboxSessionMapperFactory(entityManagerFactory, new JPAUidProvider(entityManagerFactory), new JPAModSeqProvider(entityManagerFactory), jpaConfiguration, diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JPAMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JPAMailboxModule.java index 230c13e4a38..0d8b825fd74 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JPAMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JPAMailboxModule.java @@ -41,12 +41,12 @@ import org.apache.james.mailbox.indexer.ReIndexer; import org.apache.james.mailbox.jpa.JPAAttachmentContentLoader; import org.apache.james.mailbox.jpa.JPAId; -import org.apache.james.mailbox.jpa.JPAMailboxSessionMapperFactory; import org.apache.james.mailbox.jpa.mail.JPAModSeqProvider; import org.apache.james.mailbox.jpa.mail.JPAUidProvider; import org.apache.james.mailbox.jpa.openjpa.OpenJPAMailboxManager; import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; import org.apache.james.mailbox.store.JVMMailboxPathLocker; import org.apache.james.mailbox.store.MailboxManagerConfiguration; import org.apache.james.mailbox.store.MailboxSessionMapperFactory; @@ -83,7 +83,7 @@ protected void configure() { install(new JPAQuotaSearchModule()); install(new JPAEntityManagerModule()); - bind(JPAMailboxSessionMapperFactory.class).in(Scopes.SINGLETON); + bind(PostgresMailboxSessionMapperFactory.class).in(Scopes.SINGLETON); bind(OpenJPAMailboxManager.class).in(Scopes.SINGLETON); bind(JVMMailboxPathLocker.class).in(Scopes.SINGLETON); bind(StoreSubscriptionManager.class).in(Scopes.SINGLETON); @@ -98,10 +98,10 @@ protected void configure() { bind(ReIndexerImpl.class).in(Scopes.SINGLETON); bind(SessionProviderImpl.class).in(Scopes.SINGLETON); - bind(SubscriptionMapperFactory.class).to(JPAMailboxSessionMapperFactory.class); - bind(MessageMapperFactory.class).to(JPAMailboxSessionMapperFactory.class); - bind(MailboxMapperFactory.class).to(JPAMailboxSessionMapperFactory.class); - bind(MailboxSessionMapperFactory.class).to(JPAMailboxSessionMapperFactory.class); + bind(SubscriptionMapperFactory.class).to(PostgresMailboxSessionMapperFactory.class); + bind(MessageMapperFactory.class).to(PostgresMailboxSessionMapperFactory.class); + bind(MailboxMapperFactory.class).to(PostgresMailboxSessionMapperFactory.class); + bind(MailboxSessionMapperFactory.class).to(PostgresMailboxSessionMapperFactory.class); bind(MessageId.Factory.class).to(DefaultMessageId.Factory.class); bind(ThreadIdGuessingAlgorithm.class).to(NaiveThreadIdGuessingAlgorithm.class); @@ -119,7 +119,7 @@ protected void configure() { bind(AttachmentContentLoader.class).to(JPAAttachmentContentLoader.class); bind(ReIndexer.class).to(ReIndexerImpl.class); - + Multibinder.newSetBinder(binder(), MailboxManagerDefinition.class).addBinding().to(JPAMailboxManagerDefinition.class); Multibinder.newSetBinder(binder(), EventListener.GroupEventListener.class) @@ -141,7 +141,7 @@ protected void configure() { Multibinder deleteUserDataTaskStepMultibinder = Multibinder.newSetBinder(binder(), DeleteUserDataTaskStep.class); deleteUserDataTaskStepMultibinder.addBinding().to(MailboxUserDeletionTaskStep.class); } - + @Singleton private static class JPAMailboxManagerDefinition extends MailboxManagerDefinition { @Inject From 998a5d0f95911133bf00c829e497a52916bc476e Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Fri, 10 Nov 2023 09:57:03 +0700 Subject: [PATCH 042/341] JAMES-2586 Rename mailbox postgres package --- .../JPAAttachmentContentLoader.java | 2 +- .../mailbox/{jpa => postgres}/JPAId.java | 2 +- .../JPATransactionalMapper.java | 2 +- .../PostgresMailboxSessionMapperFactory.java | 16 +- .../mail/JPAAnnotationMapper.java | 10 +- .../mail/JPAAttachmentMapper.java | 6 +- .../mail/JPAMailboxMapper.java | 8 +- .../mail/JPAMessageMapper.java | 22 +- .../mail/JPAModSeqProvider.java | 6 +- .../mail/JPAUidProvider.java | 6 +- .../{jpa => postgres}/mail/MessageUtils.java | 2 +- .../mail/model/JPAAttachment.java | 2 +- .../mail/model/JPAMailbox.java | 4 +- .../mail/model/JPAMailboxAnnotation.java | 2 +- .../mail/model/JPAMailboxAnnotationId.java | 2 +- .../mail/model/JPAProperty.java | 2 +- .../mail/model/JPAUserFlag.java | 240 +++++++++--------- .../openjpa/AbstractJPAMailboxMessage.java | 10 +- .../model/openjpa/EncryptDecryptHelper.java | 2 +- .../openjpa/JPAEncryptedMailboxMessage.java | 4 +- .../mail/model/openjpa/JPAMailboxMessage.java | 4 +- ...PAMailboxMessageWithAttachmentStorage.java | 6 +- .../openjpa/JPAStreamingMailboxMessage.java | 4 +- .../openjpa/OpenJPAMailboxManager.java | 2 +- .../openjpa/OpenJPAMessageFactory.java | 12 +- .../openjpa/OpenJPAMessageManager.java | 2 +- .../quota/JPAPerUserMaxQuotaDAO.java | 14 +- .../quota/JPAPerUserMaxQuotaManager.java | 2 +- .../quota/JpaCurrentQuotaManager.java | 4 +- .../quota/model/JpaCurrentQuota.java | 2 +- .../quota/model/MaxDomainMessageCount.java | 2 +- .../quota/model/MaxDomainStorage.java | 2 +- .../quota/model/MaxGlobalMessageCount.java | 2 +- .../quota/model/MaxGlobalStorage.java | 2 +- .../quota/model/MaxUserMessageCount.java | 2 +- .../quota/model/MaxUserStorage.java | 2 +- .../user/PostgresSubscriptionDAO.java | 8 +- .../user/PostgresSubscriptionMapper.java | 2 +- .../user/PostgresSubscriptionModule.java | 8 +- .../user/PostgresSubscriptionTable.java | 2 +- .../user/model/JPASubscription.java | 2 +- .../resources/META-INF/spring/mailbox-jpa.xml | 14 +- .../{jpa => postgres}/JPAMailboxFixture.java | 34 +-- .../JPAMailboxManagerTest.java | 6 +- .../JpaMailboxManagerProvider.java | 9 +- .../JpaMailboxManagerStressTest.java | 6 +- .../PostgresSubscriptionManagerTest.java | 7 +- .../mail/JPAAttachmentMapperTest.java | 4 +- .../mail/JPAMapperProvider.java | 4 +- .../JPAMessageWithAttachmentMapperTest.java | 4 +- .../mail/JpaAnnotationMapperTest.java | 6 +- .../mail/JpaMailboxMapperTest.java | 8 +- .../mail/JpaMessageMapperTest.java | 4 +- .../mail/JpaMessageMoveTest.java | 4 +- .../mail/MessageUtilsTest.java | 3 +- .../mail/TransactionalAnnotationMapper.java | 3 +- .../mail/TransactionalAttachmentMapper.java | 3 +- .../mail/TransactionalMailboxMapper.java | 3 +- .../mail/TransactionalMessageMapper.java | 3 +- .../model/openjpa/JPAMailboxMessageTest.java | 3 +- .../JPARecomputeCurrentQuotasServiceTest.java | 14 +- .../quota/JPACurrentQuotaManagerTest.java | 5 +- .../quota/JPAPerUserMaxQuotaTest.java | 6 +- .../user/PostgresSubscriptionMapperTest.java | 5 +- .../src/test/resources/persistence.xml | 34 +-- .../main/resources/META-INF/persistence.xml | 34 +-- .../modules/mailbox/JPAMailboxModule.java | 10 +- .../james/modules/mailbox/JpaQuotaModule.java | 4 +- .../mailbox/PostgresMailboxModule.java | 2 +- 69 files changed, 345 insertions(+), 333 deletions(-) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/JPAAttachmentContentLoader.java (97%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/JPAId.java (98%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/JPATransactionalMapper.java (98%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/mail/JPAAnnotationMapper.java (95%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/mail/JPAAttachmentMapper.java (96%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/mail/JPAMailboxMapper.java (98%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/mail/JPAMessageMapper.java (96%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/mail/JPAModSeqProvider.java (96%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/mail/JPAUidProvider.java (96%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/mail/MessageUtils.java (98%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/mail/model/JPAAttachment.java (99%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/mail/model/JPAMailbox.java (98%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/mail/model/JPAMailboxAnnotation.java (98%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/mail/model/JPAMailboxAnnotationId.java (97%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/mail/model/JPAProperty.java (98%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/mail/model/JPAUserFlag.java (95%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/mail/model/openjpa/AbstractJPAMailboxMessage.java (98%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/mail/model/openjpa/EncryptDecryptHelper.java (97%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/mail/model/openjpa/JPAEncryptedMailboxMessage.java (97%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/mail/model/openjpa/JPAMailboxMessage.java (97%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/mail/model/openjpa/JPAMailboxMessageWithAttachmentStorage.java (96%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/mail/model/openjpa/JPAStreamingMailboxMessage.java (97%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/openjpa/OpenJPAMailboxManager.java (98%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/openjpa/OpenJPAMessageFactory.java (86%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/openjpa/OpenJPAMessageManager.java (99%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/quota/JPAPerUserMaxQuotaDAO.java (95%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/quota/JPAPerUserMaxQuotaManager.java (99%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/quota/JpaCurrentQuotaManager.java (98%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/quota/model/JpaCurrentQuota.java (97%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/quota/model/MaxDomainMessageCount.java (97%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/quota/model/MaxDomainStorage.java (97%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/quota/model/MaxGlobalMessageCount.java (97%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/quota/model/MaxGlobalStorage.java (97%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/quota/model/MaxUserMessageCount.java (97%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/quota/model/MaxUserStorage.java (97%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/user/PostgresSubscriptionDAO.java (88%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/user/PostgresSubscriptionMapper.java (98%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/user/PostgresSubscriptionModule.java (86%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/user/PostgresSubscriptionTable.java (97%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/{jpa => postgres}/user/model/JPASubscription.java (98%) rename mailbox/postgres/src/test/java/org/apache/james/mailbox/{jpa => postgres}/JPAMailboxFixture.java (68%) rename mailbox/postgres/src/test/java/org/apache/james/mailbox/{jpa => postgres}/JPAMailboxManagerTest.java (94%) rename mailbox/postgres/src/test/java/org/apache/james/mailbox/{jpa => postgres}/JpaMailboxManagerProvider.java (94%) rename mailbox/postgres/src/test/java/org/apache/james/mailbox/{jpa => postgres}/JpaMailboxManagerStressTest.java (93%) rename mailbox/postgres/src/test/java/org/apache/james/mailbox/{jpa => postgres}/mail/JPAAttachmentMapperTest.java (97%) rename mailbox/postgres/src/test/java/org/apache/james/mailbox/{jpa => postgres}/mail/JPAMapperProvider.java (97%) rename mailbox/postgres/src/test/java/org/apache/james/mailbox/{jpa => postgres}/mail/JPAMessageWithAttachmentMapperTest.java (98%) rename mailbox/postgres/src/test/java/org/apache/james/mailbox/{jpa => postgres}/mail/JpaAnnotationMapperTest.java (93%) rename mailbox/postgres/src/test/java/org/apache/james/mailbox/{jpa => postgres}/mail/JpaMailboxMapperTest.java (94%) rename mailbox/postgres/src/test/java/org/apache/james/mailbox/{jpa => postgres}/mail/JpaMessageMapperTest.java (98%) rename mailbox/postgres/src/test/java/org/apache/james/mailbox/{jpa => postgres}/mail/JpaMessageMoveTest.java (94%) rename mailbox/postgres/src/test/java/org/apache/james/mailbox/{jpa => postgres}/mail/MessageUtilsTest.java (97%) rename mailbox/postgres/src/test/java/org/apache/james/mailbox/{jpa => postgres}/mail/TransactionalAnnotationMapper.java (96%) rename mailbox/postgres/src/test/java/org/apache/james/mailbox/{jpa => postgres}/mail/TransactionalAttachmentMapper.java (96%) rename mailbox/postgres/src/test/java/org/apache/james/mailbox/{jpa => postgres}/mail/TransactionalMailboxMapper.java (96%) rename mailbox/postgres/src/test/java/org/apache/james/mailbox/{jpa => postgres}/mail/TransactionalMessageMapper.java (98%) rename mailbox/postgres/src/test/java/org/apache/james/mailbox/{jpa => postgres}/mail/model/openjpa/JPAMailboxMessageTest.java (94%) rename mailbox/postgres/src/test/java/org/apache/james/mailbox/{jpa => postgres}/mail/task/JPARecomputeCurrentQuotasServiceTest.java (93%) rename mailbox/postgres/src/test/java/org/apache/james/mailbox/{jpa => postgres}/quota/JPACurrentQuotaManagerTest.java (91%) rename mailbox/postgres/src/test/java/org/apache/james/mailbox/{jpa => postgres}/quota/JPAPerUserMaxQuotaTest.java (88%) rename mailbox/postgres/src/test/java/org/apache/james/mailbox/{jpa => postgres}/user/PostgresSubscriptionMapperTest.java (87%) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAAttachmentContentLoader.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/JPAAttachmentContentLoader.java similarity index 97% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAAttachmentContentLoader.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/JPAAttachmentContentLoader.java index fb9500b5070..02e4bb570d2 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAAttachmentContentLoader.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/JPAAttachmentContentLoader.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa; +package org.apache.james.mailbox.postgres; import java.io.InputStream; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAId.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/JPAId.java similarity index 98% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAId.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/JPAId.java index d613e016fc8..16e20f0cff4 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPAId.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/JPAId.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa; +package org.apache.james.mailbox.postgres; import java.io.Serializable; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPATransactionalMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/JPATransactionalMapper.java similarity index 98% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPATransactionalMapper.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/JPATransactionalMapper.java index 9bfcf8e9f15..d39b31b742f 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/JPATransactionalMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/JPATransactionalMapper.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa; +package org.apache.james.mailbox.postgres; import javax.persistence.EntityManager; import javax.persistence.EntityManagerFactory; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java index b9d06f0486c..9f6a29028bb 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java @@ -28,14 +28,14 @@ import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.mailbox.MailboxSession; -import org.apache.james.mailbox.jpa.mail.JPAAnnotationMapper; -import org.apache.james.mailbox.jpa.mail.JPAAttachmentMapper; -import org.apache.james.mailbox.jpa.mail.JPAMailboxMapper; -import org.apache.james.mailbox.jpa.mail.JPAMessageMapper; -import org.apache.james.mailbox.jpa.mail.JPAModSeqProvider; -import org.apache.james.mailbox.jpa.mail.JPAUidProvider; -import org.apache.james.mailbox.jpa.user.PostgresSubscriptionDAO; -import org.apache.james.mailbox.jpa.user.PostgresSubscriptionMapper; +import org.apache.james.mailbox.postgres.mail.JPAAnnotationMapper; +import org.apache.james.mailbox.postgres.mail.JPAAttachmentMapper; +import org.apache.james.mailbox.postgres.mail.JPAMailboxMapper; +import org.apache.james.mailbox.postgres.mail.JPAMessageMapper; +import org.apache.james.mailbox.postgres.mail.JPAModSeqProvider; +import org.apache.james.mailbox.postgres.mail.JPAUidProvider; +import org.apache.james.mailbox.postgres.user.PostgresSubscriptionDAO; +import org.apache.james.mailbox.postgres.user.PostgresSubscriptionMapper; import org.apache.james.mailbox.store.MailboxSessionMapperFactory; import org.apache.james.mailbox.store.mail.AnnotationMapper; import org.apache.james.mailbox.store.mail.AttachmentMapper; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAAnnotationMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAAnnotationMapper.java similarity index 95% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAAnnotationMapper.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAAnnotationMapper.java index f0cfbe07859..7009fb95cc3 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAAnnotationMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAAnnotationMapper.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail; +package org.apache.james.mailbox.postgres.mail; import java.util.List; import java.util.Optional; @@ -29,13 +29,13 @@ import javax.persistence.NoResultException; import javax.persistence.PersistenceException; -import org.apache.james.mailbox.jpa.JPAId; -import org.apache.james.mailbox.jpa.JPATransactionalMapper; -import org.apache.james.mailbox.jpa.mail.model.JPAMailboxAnnotation; -import org.apache.james.mailbox.jpa.mail.model.JPAMailboxAnnotationId; import org.apache.james.mailbox.model.MailboxAnnotation; import org.apache.james.mailbox.model.MailboxAnnotationKey; import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.postgres.JPAId; +import org.apache.james.mailbox.postgres.JPATransactionalMapper; +import org.apache.james.mailbox.postgres.mail.model.JPAMailboxAnnotation; +import org.apache.james.mailbox.postgres.mail.model.JPAMailboxAnnotationId; import org.apache.james.mailbox.store.mail.AnnotationMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAAttachmentMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAAttachmentMapper.java similarity index 96% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAAttachmentMapper.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAAttachmentMapper.java index 9985cad784c..dc91260fc35 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAAttachmentMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAAttachmentMapper.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail; +package org.apache.james.mailbox.postgres.mail; import java.io.IOException; import java.io.InputStream; @@ -30,13 +30,13 @@ import org.apache.commons.io.IOUtils; import org.apache.james.mailbox.exception.AttachmentNotFoundException; import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.jpa.JPATransactionalMapper; -import org.apache.james.mailbox.jpa.mail.model.JPAAttachment; import org.apache.james.mailbox.model.AttachmentId; import org.apache.james.mailbox.model.AttachmentMetadata; import org.apache.james.mailbox.model.MessageAttachmentMetadata; import org.apache.james.mailbox.model.MessageId; import org.apache.james.mailbox.model.ParsedAttachment; +import org.apache.james.mailbox.postgres.JPATransactionalMapper; +import org.apache.james.mailbox.postgres.mail.model.JPAAttachment; import org.apache.james.mailbox.store.mail.AttachmentMapper; import com.github.fge.lambdas.Throwing; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAMailboxMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAMailboxMapper.java similarity index 98% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAMailboxMapper.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAMailboxMapper.java index f691f5c1c36..810f2388b89 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAMailboxMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAMailboxMapper.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail; +package org.apache.james.mailbox.postgres.mail; import java.util.NoSuchElementException; @@ -33,9 +33,6 @@ import org.apache.james.mailbox.exception.MailboxException; import org.apache.james.mailbox.exception.MailboxExistsException; import org.apache.james.mailbox.exception.MailboxNotFoundException; -import org.apache.james.mailbox.jpa.JPAId; -import org.apache.james.mailbox.jpa.JPATransactionalMapper; -import org.apache.james.mailbox.jpa.mail.model.JPAMailbox; import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MailboxACL; import org.apache.james.mailbox.model.MailboxACL.Right; @@ -43,6 +40,9 @@ import org.apache.james.mailbox.model.MailboxPath; import org.apache.james.mailbox.model.UidValidity; import org.apache.james.mailbox.model.search.MailboxQuery; +import org.apache.james.mailbox.postgres.JPAId; +import org.apache.james.mailbox.postgres.JPATransactionalMapper; +import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; import org.apache.james.mailbox.store.MailboxExpressionBackwardCompatibility; import org.apache.james.mailbox.store.mail.MailboxMapper; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAMessageMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAMessageMapper.java similarity index 96% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAMessageMapper.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAMessageMapper.java index b4e7de4327c..66df840e708 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAMessageMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAMessageMapper.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail; +package org.apache.james.mailbox.postgres.mail; import java.util.HashMap; import java.util.Iterator; @@ -36,16 +36,6 @@ import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.ModSeq; import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.jpa.JPAId; -import org.apache.james.mailbox.jpa.JPATransactionalMapper; -import org.apache.james.mailbox.jpa.mail.MessageUtils.MessageChangedFlags; -import org.apache.james.mailbox.jpa.mail.model.JPAAttachment; -import org.apache.james.mailbox.jpa.mail.model.JPAMailbox; -import org.apache.james.mailbox.jpa.mail.model.openjpa.AbstractJPAMailboxMessage; -import org.apache.james.mailbox.jpa.mail.model.openjpa.JPAEncryptedMailboxMessage; -import org.apache.james.mailbox.jpa.mail.model.openjpa.JPAMailboxMessage; -import org.apache.james.mailbox.jpa.mail.model.openjpa.JPAMailboxMessageWithAttachmentStorage; -import org.apache.james.mailbox.jpa.mail.model.openjpa.JPAStreamingMailboxMessage; import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MailboxCounters; import org.apache.james.mailbox.model.MailboxId; @@ -54,6 +44,16 @@ import org.apache.james.mailbox.model.MessageRange; import org.apache.james.mailbox.model.MessageRange.Type; import org.apache.james.mailbox.model.UpdatedFlags; +import org.apache.james.mailbox.postgres.JPAId; +import org.apache.james.mailbox.postgres.JPATransactionalMapper; +import org.apache.james.mailbox.postgres.mail.MessageUtils.MessageChangedFlags; +import org.apache.james.mailbox.postgres.mail.model.JPAAttachment; +import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; +import org.apache.james.mailbox.postgres.mail.model.openjpa.AbstractJPAMailboxMessage; +import org.apache.james.mailbox.postgres.mail.model.openjpa.JPAEncryptedMailboxMessage; +import org.apache.james.mailbox.postgres.mail.model.openjpa.JPAMailboxMessage; +import org.apache.james.mailbox.postgres.mail.model.openjpa.JPAMailboxMessageWithAttachmentStorage; +import org.apache.james.mailbox.postgres.mail.model.openjpa.JPAStreamingMailboxMessage; import org.apache.james.mailbox.store.FlagsUpdateCalculator; import org.apache.james.mailbox.store.mail.MessageMapper; import org.apache.james.mailbox.store.mail.model.MailboxMessage; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAModSeqProvider.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAModSeqProvider.java similarity index 96% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAModSeqProvider.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAModSeqProvider.java index 5f1414d32cb..bfa16f9ad1f 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAModSeqProvider.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAModSeqProvider.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail; +package org.apache.james.mailbox.postgres.mail; import javax.inject.Inject; import javax.persistence.EntityManager; @@ -26,10 +26,10 @@ import org.apache.james.backends.jpa.EntityManagerUtils; import org.apache.james.mailbox.ModSeq; import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.jpa.JPAId; -import org.apache.james.mailbox.jpa.mail.model.JPAMailbox; import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.postgres.JPAId; +import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; import org.apache.james.mailbox.store.mail.ModSeqProvider; public class JPAModSeqProvider implements ModSeqProvider { diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAUidProvider.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAUidProvider.java similarity index 96% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAUidProvider.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAUidProvider.java index 94e197b4f94..2b778d0e41e 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/JPAUidProvider.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAUidProvider.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail; +package org.apache.james.mailbox.postgres.mail; import java.util.Optional; @@ -28,10 +28,10 @@ import org.apache.james.backends.jpa.EntityManagerUtils; import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.jpa.JPAId; -import org.apache.james.mailbox.jpa.mail.model.JPAMailbox; import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.postgres.JPAId; +import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; import org.apache.james.mailbox.store.mail.UidProvider; public class JPAUidProvider implements UidProvider { diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/MessageUtils.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/MessageUtils.java similarity index 98% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/MessageUtils.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/MessageUtils.java index bd5d513c5cc..ca717a26782 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/MessageUtils.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/MessageUtils.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail; +package org.apache.james.mailbox.postgres.mail; import java.util.Iterator; import java.util.List; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAAttachment.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAAttachment.java similarity index 99% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAAttachment.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAAttachment.java index 60e3e9ad4b5..d45005fce56 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAAttachment.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAAttachment.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail.model; +package org.apache.james.mailbox.postgres.mail.model; import java.io.ByteArrayInputStream; import java.io.InputStream; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAMailbox.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAMailbox.java similarity index 98% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAMailbox.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAMailbox.java index 2bedbe5ac1b..9f0050f6223 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAMailbox.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAMailbox.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail.model; +package org.apache.james.mailbox.postgres.mail.model; import java.util.Objects; @@ -30,10 +30,10 @@ import javax.persistence.Table; import org.apache.james.core.Username; -import org.apache.james.mailbox.jpa.JPAId; import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MailboxPath; import org.apache.james.mailbox.model.UidValidity; +import org.apache.james.mailbox.postgres.JPAId; import com.google.common.annotations.VisibleForTesting; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAMailboxAnnotation.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAMailboxAnnotation.java similarity index 98% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAMailboxAnnotation.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAMailboxAnnotation.java index 6627becbf71..d28080212cd 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAMailboxAnnotation.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAMailboxAnnotation.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail.model; +package org.apache.james.mailbox.postgres.mail.model; import javax.persistence.Basic; import javax.persistence.Column; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAMailboxAnnotationId.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAMailboxAnnotationId.java similarity index 97% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAMailboxAnnotationId.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAMailboxAnnotationId.java index 1fcc71280d3..36e5afbb68f 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAMailboxAnnotationId.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAMailboxAnnotationId.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail.model; +package org.apache.james.mailbox.postgres.mail.model; import java.io.Serializable; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAProperty.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAProperty.java similarity index 98% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAProperty.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAProperty.java index ee7c54e36ce..4724aea04d7 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAProperty.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAProperty.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail.model; +package org.apache.james.mailbox.postgres.mail.model; import java.util.Objects; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAUserFlag.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAUserFlag.java similarity index 95% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAUserFlag.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAUserFlag.java index 318dfa05f4f..3e1736d79d1 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/JPAUserFlag.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAUserFlag.java @@ -1,120 +1,120 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.mailbox.jpa.mail.model; - -import javax.persistence.Basic; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.Id; -import javax.persistence.Table; - -@Entity(name = "UserFlag") -@Table(name = "JAMES_MAIL_USERFLAG") -public class JPAUserFlag { - - - /** The system unique key */ - @Id - @GeneratedValue - @Column(name = "USERFLAG_ID", nullable = true) - private long id; - - /** Local part of the name of this property */ - @Basic(optional = false) - @Column(name = "USERFLAG_NAME", nullable = false, length = 500) - private String name; - - - /** - * @deprecated enhancement only - */ - @Deprecated - public JPAUserFlag() { - - } - - /** - * Constructs a User Flag. - * @param name not null - */ - public JPAUserFlag(String name) { - super(); - this.name = name; - } - - /** - * Constructs a User Flag, cloned from the given. - * @param flag not null - */ - public JPAUserFlag(JPAUserFlag flag) { - this(flag.getName()); - } - - - - /** - * Gets the name. - * @return not null - */ - public String getName() { - return name; - } - - @Override - public int hashCode() { - final int PRIME = 31; - int result = 1; - result = PRIME * result + (int) (id ^ (id >>> 32)); - return result; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null) { - return false; - } - if (getClass() != obj.getClass()) { - return false; - } - final JPAUserFlag other = (JPAUserFlag) obj; - if (id != other.id) { - return false; - } - return true; - } - - /** - * Constructs a String with all attributes - * in name = value format. - * - * @return a String representation - * of this object. - */ - public String toString() { - return "JPAUserFlag ( " - + "id = " + this.id + " " - + "name = " + this.name - + " )"; - } - -} +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.mailbox.postgres.mail.model; + +import javax.persistence.Basic; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import javax.persistence.Table; + +@Entity(name = "UserFlag") +@Table(name = "JAMES_MAIL_USERFLAG") +public class JPAUserFlag { + + + /** The system unique key */ + @Id + @GeneratedValue + @Column(name = "USERFLAG_ID", nullable = true) + private long id; + + /** Local part of the name of this property */ + @Basic(optional = false) + @Column(name = "USERFLAG_NAME", nullable = false, length = 500) + private String name; + + + /** + * @deprecated enhancement only + */ + @Deprecated + public JPAUserFlag() { + + } + + /** + * Constructs a User Flag. + * @param name not null + */ + public JPAUserFlag(String name) { + super(); + this.name = name; + } + + /** + * Constructs a User Flag, cloned from the given. + * @param flag not null + */ + public JPAUserFlag(JPAUserFlag flag) { + this(flag.getName()); + } + + + + /** + * Gets the name. + * @return not null + */ + public String getName() { + return name; + } + + @Override + public int hashCode() { + final int PRIME = 31; + int result = 1; + result = PRIME * result + (int) (id ^ (id >>> 32)); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + final JPAUserFlag other = (JPAUserFlag) obj; + if (id != other.id) { + return false; + } + return true; + } + + /** + * Constructs a String with all attributes + * in name = value format. + * + * @return a String representation + * of this object. + */ + public String toString() { + return "JPAUserFlag ( " + + "id = " + this.id + " " + + "name = " + this.name + + " )"; + } + +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/AbstractJPAMailboxMessage.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/AbstractJPAMailboxMessage.java similarity index 98% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/AbstractJPAMailboxMessage.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/AbstractJPAMailboxMessage.java index 8b9f1fe0a9f..040bf064765 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/AbstractJPAMailboxMessage.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/AbstractJPAMailboxMessage.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail.model.openjpa; +package org.apache.james.mailbox.postgres.mail.model.openjpa; import java.io.IOException; import java.io.InputStream; @@ -45,10 +45,6 @@ import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.ModSeq; import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.jpa.JPAId; -import org.apache.james.mailbox.jpa.mail.model.JPAMailbox; -import org.apache.james.mailbox.jpa.mail.model.JPAProperty; -import org.apache.james.mailbox.jpa.mail.model.JPAUserFlag; import org.apache.james.mailbox.model.AttachmentId; import org.apache.james.mailbox.model.ComposedMessageId; import org.apache.james.mailbox.model.ComposedMessageIdWithMetaData; @@ -56,6 +52,10 @@ import org.apache.james.mailbox.model.MessageId; import org.apache.james.mailbox.model.ParsedAttachment; import org.apache.james.mailbox.model.ThreadId; +import org.apache.james.mailbox.postgres.JPAId; +import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; +import org.apache.james.mailbox.postgres.mail.model.JPAProperty; +import org.apache.james.mailbox.postgres.mail.model.JPAUserFlag; import org.apache.james.mailbox.store.mail.model.DefaultMessageId; import org.apache.james.mailbox.store.mail.model.DelegatingMailboxMessage; import org.apache.james.mailbox.store.mail.model.FlagsFactory; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/EncryptDecryptHelper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/EncryptDecryptHelper.java similarity index 97% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/EncryptDecryptHelper.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/EncryptDecryptHelper.java index 40dfd0e53ef..ef8eb9c4039 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/EncryptDecryptHelper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/EncryptDecryptHelper.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail.model.openjpa; +package org.apache.james.mailbox.postgres.mail.model.openjpa; import org.jasypt.encryption.pbe.StandardPBEByteEncryptor; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAEncryptedMailboxMessage.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAEncryptedMailboxMessage.java similarity index 97% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAEncryptedMailboxMessage.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAEncryptedMailboxMessage.java index 062017947ea..385c4549c14 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAEncryptedMailboxMessage.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAEncryptedMailboxMessage.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail.model.openjpa; +package org.apache.james.mailbox.postgres.mail.model.openjpa; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -36,9 +36,9 @@ import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.ModSeq; import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.jpa.mail.model.JPAMailbox; import org.apache.james.mailbox.model.Content; import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; import org.apache.james.mailbox.store.mail.model.MailboxMessage; import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; import org.apache.openjpa.persistence.Externalizer; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAMailboxMessage.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAMailboxMessage.java similarity index 97% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAMailboxMessage.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAMailboxMessage.java index 9ad9be12ad8..41fa1949c56 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAMailboxMessage.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAMailboxMessage.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail.model.openjpa; +package org.apache.james.mailbox.postgres.mail.model.openjpa; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -36,9 +36,9 @@ import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.ModSeq; import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.jpa.mail.model.JPAMailbox; import org.apache.james.mailbox.model.Content; import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; import org.apache.james.mailbox.store.mail.model.MailboxMessage; import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAMailboxMessageWithAttachmentStorage.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAMailboxMessageWithAttachmentStorage.java similarity index 96% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAMailboxMessageWithAttachmentStorage.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAMailboxMessageWithAttachmentStorage.java index 2e4e1a969e4..85052667d88 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAMailboxMessageWithAttachmentStorage.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAMailboxMessageWithAttachmentStorage.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail.model.openjpa; +package org.apache.james.mailbox.postgres.mail.model.openjpa; import java.io.ByteArrayInputStream; import java.io.IOException; @@ -42,11 +42,11 @@ import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.ModSeq; import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.jpa.mail.model.JPAAttachment; -import org.apache.james.mailbox.jpa.mail.model.JPAMailbox; import org.apache.james.mailbox.model.Content; import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MessageAttachmentMetadata; +import org.apache.james.mailbox.postgres.mail.model.JPAAttachment; +import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; import org.apache.james.mailbox.store.mail.model.MailboxMessage; import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; import org.apache.openjpa.persistence.jdbc.ElementJoinColumn; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAStreamingMailboxMessage.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAStreamingMailboxMessage.java similarity index 97% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAStreamingMailboxMessage.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAStreamingMailboxMessage.java index 8ffbd6090b3..356c8ffff77 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAStreamingMailboxMessage.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAStreamingMailboxMessage.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail.model.openjpa; +package org.apache.james.mailbox.postgres.mail.model.openjpa; import java.io.IOException; import java.io.InputStream; @@ -32,9 +32,9 @@ import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.ModSeq; import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.jpa.mail.model.JPAMailbox; import org.apache.james.mailbox.model.Content; import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; import org.apache.james.mailbox.store.mail.model.MailboxMessage; import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; import org.apache.openjpa.persistence.Persistent; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMailboxManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMailboxManager.java similarity index 98% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMailboxManager.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMailboxManager.java index 525f294c7db..ef172d4fdff 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMailboxManager.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMailboxManager.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.openjpa; +package org.apache.james.mailbox.postgres.openjpa; import java.time.Clock; import java.util.EnumSet; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMessageFactory.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMessageFactory.java similarity index 86% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMessageFactory.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMessageFactory.java index 79b08c492e3..a5696499e46 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMessageFactory.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMessageFactory.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.openjpa; +package org.apache.james.mailbox.postgres.openjpa; import java.util.Date; import java.util.List; @@ -25,16 +25,16 @@ import javax.mail.Flags; import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.jpa.mail.model.JPAMailbox; -import org.apache.james.mailbox.jpa.mail.model.openjpa.AbstractJPAMailboxMessage; -import org.apache.james.mailbox.jpa.mail.model.openjpa.JPAEncryptedMailboxMessage; -import org.apache.james.mailbox.jpa.mail.model.openjpa.JPAMailboxMessage; -import org.apache.james.mailbox.jpa.mail.model.openjpa.JPAStreamingMailboxMessage; import org.apache.james.mailbox.model.Content; import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MessageAttachmentMetadata; import org.apache.james.mailbox.model.MessageId; import org.apache.james.mailbox.model.ThreadId; +import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; +import org.apache.james.mailbox.postgres.mail.model.openjpa.AbstractJPAMailboxMessage; +import org.apache.james.mailbox.postgres.mail.model.openjpa.JPAEncryptedMailboxMessage; +import org.apache.james.mailbox.postgres.mail.model.openjpa.JPAMailboxMessage; +import org.apache.james.mailbox.postgres.mail.model.openjpa.JPAStreamingMailboxMessage; import org.apache.james.mailbox.store.MessageFactory; import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMessageManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMessageManager.java similarity index 99% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMessageManager.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMessageManager.java index 7226fb046ac..a664432b653 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/openjpa/OpenJPAMessageManager.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMessageManager.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.openjpa; +package org.apache.james.mailbox.postgres.openjpa; import java.time.Clock; import java.util.EnumSet; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/JPAPerUserMaxQuotaDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/JPAPerUserMaxQuotaDAO.java similarity index 95% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/JPAPerUserMaxQuotaDAO.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/JPAPerUserMaxQuotaDAO.java index 8b28dbba698..31630798d3e 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/JPAPerUserMaxQuotaDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/JPAPerUserMaxQuotaDAO.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.quota; +package org.apache.james.mailbox.postgres.quota; import java.util.Optional; import java.util.function.Function; @@ -31,13 +31,13 @@ import org.apache.james.core.quota.QuotaCountLimit; import org.apache.james.core.quota.QuotaLimitValue; import org.apache.james.core.quota.QuotaSizeLimit; -import org.apache.james.mailbox.jpa.quota.model.MaxDomainMessageCount; -import org.apache.james.mailbox.jpa.quota.model.MaxDomainStorage; -import org.apache.james.mailbox.jpa.quota.model.MaxGlobalMessageCount; -import org.apache.james.mailbox.jpa.quota.model.MaxGlobalStorage; -import org.apache.james.mailbox.jpa.quota.model.MaxUserMessageCount; -import org.apache.james.mailbox.jpa.quota.model.MaxUserStorage; import org.apache.james.mailbox.model.QuotaRoot; +import org.apache.james.mailbox.postgres.quota.model.MaxDomainMessageCount; +import org.apache.james.mailbox.postgres.quota.model.MaxDomainStorage; +import org.apache.james.mailbox.postgres.quota.model.MaxGlobalMessageCount; +import org.apache.james.mailbox.postgres.quota.model.MaxGlobalStorage; +import org.apache.james.mailbox.postgres.quota.model.MaxUserMessageCount; +import org.apache.james.mailbox.postgres.quota.model.MaxUserStorage; public class JPAPerUserMaxQuotaDAO { diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/JPAPerUserMaxQuotaManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/JPAPerUserMaxQuotaManager.java similarity index 99% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/JPAPerUserMaxQuotaManager.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/JPAPerUserMaxQuotaManager.java index 31658c0c6a3..6572b71ea52 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/JPAPerUserMaxQuotaManager.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/JPAPerUserMaxQuotaManager.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.quota; +package org.apache.james.mailbox.postgres.quota; import java.util.Map; import java.util.Optional; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/JpaCurrentQuotaManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/JpaCurrentQuotaManager.java similarity index 98% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/JpaCurrentQuotaManager.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/JpaCurrentQuotaManager.java index 2f5c5a980d0..626078d1851 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/JpaCurrentQuotaManager.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/JpaCurrentQuotaManager.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.quota; +package org.apache.james.mailbox.postgres.quota; import java.util.Optional; @@ -29,10 +29,10 @@ import org.apache.james.backends.jpa.TransactionRunner; import org.apache.james.core.quota.QuotaCountUsage; import org.apache.james.core.quota.QuotaSizeUsage; -import org.apache.james.mailbox.jpa.quota.model.JpaCurrentQuota; import org.apache.james.mailbox.model.CurrentQuotas; import org.apache.james.mailbox.model.QuotaOperation; import org.apache.james.mailbox.model.QuotaRoot; +import org.apache.james.mailbox.postgres.quota.model.JpaCurrentQuota; import org.apache.james.mailbox.quota.CurrentQuotaManager; import reactor.core.publisher.Mono; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/JpaCurrentQuota.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/JpaCurrentQuota.java similarity index 97% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/JpaCurrentQuota.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/JpaCurrentQuota.java index f058ba0ce90..d9648610c3a 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/JpaCurrentQuota.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/JpaCurrentQuota.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.quota.model; +package org.apache.james.mailbox.postgres.quota.model; import javax.persistence.Column; import javax.persistence.Entity; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxDomainMessageCount.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxDomainMessageCount.java similarity index 97% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxDomainMessageCount.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxDomainMessageCount.java index 9787d6756eb..be4cf2a30a0 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxDomainMessageCount.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxDomainMessageCount.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.quota.model; +package org.apache.james.mailbox.postgres.quota.model; import javax.persistence.Column; import javax.persistence.Entity; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxDomainStorage.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxDomainStorage.java similarity index 97% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxDomainStorage.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxDomainStorage.java index 575f070ecb8..ec668421dcf 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxDomainStorage.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxDomainStorage.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.quota.model; +package org.apache.james.mailbox.postgres.quota.model; import javax.persistence.Column; import javax.persistence.Entity; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxGlobalMessageCount.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxGlobalMessageCount.java similarity index 97% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxGlobalMessageCount.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxGlobalMessageCount.java index 04bc8eec1e1..1041e75533b 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxGlobalMessageCount.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxGlobalMessageCount.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.quota.model; +package org.apache.james.mailbox.postgres.quota.model; import javax.persistence.Column; import javax.persistence.Entity; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxGlobalStorage.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxGlobalStorage.java similarity index 97% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxGlobalStorage.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxGlobalStorage.java index 7f99110d865..59b9a1601c1 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxGlobalStorage.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxGlobalStorage.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.quota.model; +package org.apache.james.mailbox.postgres.quota.model; import javax.persistence.Column; import javax.persistence.Entity; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxUserMessageCount.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxUserMessageCount.java similarity index 97% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxUserMessageCount.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxUserMessageCount.java index 71056e9aa1f..9f31a8ef5ea 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxUserMessageCount.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxUserMessageCount.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.quota.model; +package org.apache.james.mailbox.postgres.quota.model; import javax.persistence.Column; import javax.persistence.Entity; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxUserStorage.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxUserStorage.java similarity index 97% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxUserStorage.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxUserStorage.java index 3e01be8f61e..a4633380d08 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/quota/model/MaxUserStorage.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxUserStorage.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.quota.model; +package org.apache.james.mailbox.postgres.quota.model; import javax.persistence.Column; import javax.persistence.Entity; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionDAO.java similarity index 88% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionDAO.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionDAO.java index a1a903e90d2..9bce0047d08 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionDAO.java @@ -17,11 +17,11 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.user; +package org.apache.james.mailbox.postgres.user; -import static org.apache.james.mailbox.jpa.user.PostgresSubscriptionTable.MAILBOX; -import static org.apache.james.mailbox.jpa.user.PostgresSubscriptionTable.TABLE_NAME; -import static org.apache.james.mailbox.jpa.user.PostgresSubscriptionTable.USER; +import static org.apache.james.mailbox.postgres.user.PostgresSubscriptionTable.MAILBOX; +import static org.apache.james.mailbox.postgres.user.PostgresSubscriptionTable.TABLE_NAME; +import static org.apache.james.mailbox.postgres.user.PostgresSubscriptionTable.USER; import org.apache.james.backends.postgres.utils.PostgresExecutor; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapper.java similarity index 98% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionMapper.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapper.java index 02514fef6a9..1b3182a66e0 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapper.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.user; +package org.apache.james.mailbox.postgres.user; import java.util.List; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java similarity index 86% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionModule.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java index 66d5372eeb4..11ea9a2f3e4 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java @@ -17,11 +17,11 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.user; +package org.apache.james.mailbox.postgres.user; -import static org.apache.james.mailbox.jpa.user.PostgresSubscriptionTable.MAILBOX; -import static org.apache.james.mailbox.jpa.user.PostgresSubscriptionTable.TABLE_NAME; -import static org.apache.james.mailbox.jpa.user.PostgresSubscriptionTable.USER; +import static org.apache.james.mailbox.postgres.user.PostgresSubscriptionTable.MAILBOX; +import static org.apache.james.mailbox.postgres.user.PostgresSubscriptionTable.TABLE_NAME; +import static org.apache.james.mailbox.postgres.user.PostgresSubscriptionTable.USER; import org.apache.james.backends.postgres.PostgresIndex; import org.apache.james.backends.postgres.PostgresModule; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionTable.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionTable.java similarity index 97% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionTable.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionTable.java index 3cdc2cf1e82..ad703e4d268 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionTable.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionTable.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.user; +package org.apache.james.mailbox.postgres.user; import org.jooq.Field; import org.jooq.Record; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/model/JPASubscription.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/model/JPASubscription.java similarity index 98% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/model/JPASubscription.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/model/JPASubscription.java index 951ca15c576..3873871cfad 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/jpa/user/model/JPASubscription.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/model/JPASubscription.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.user.model; +package org.apache.james.mailbox.postgres.user.model; import java.util.Objects; diff --git a/mailbox/postgres/src/main/resources/META-INF/spring/mailbox-jpa.xml b/mailbox/postgres/src/main/resources/META-INF/spring/mailbox-jpa.xml index fa6e3ad40fe..32b128c0896 100644 --- a/mailbox/postgres/src/main/resources/META-INF/spring/mailbox-jpa.xml +++ b/mailbox/postgres/src/main/resources/META-INF/spring/mailbox-jpa.xml @@ -29,9 +29,9 @@ - + - + @@ -59,10 +59,10 @@ - + - + @@ -94,15 +94,15 @@ - + - + - + diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPAMailboxFixture.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxFixture.java similarity index 68% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPAMailboxFixture.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxFixture.java index 25a96d93ca2..16bdec95ad9 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPAMailboxFixture.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxFixture.java @@ -17,26 +17,26 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa; +package org.apache.james.mailbox.postgres; import java.util.List; -import org.apache.james.mailbox.jpa.mail.model.JPAAttachment; -import org.apache.james.mailbox.jpa.mail.model.JPAMailbox; -import org.apache.james.mailbox.jpa.mail.model.JPAMailboxAnnotation; -import org.apache.james.mailbox.jpa.mail.model.JPAProperty; -import org.apache.james.mailbox.jpa.mail.model.JPAUserFlag; -import org.apache.james.mailbox.jpa.mail.model.openjpa.AbstractJPAMailboxMessage; -import org.apache.james.mailbox.jpa.mail.model.openjpa.JPAMailboxMessage; -import org.apache.james.mailbox.jpa.mail.model.openjpa.JPAMailboxMessageWithAttachmentStorage; -import org.apache.james.mailbox.jpa.quota.model.JpaCurrentQuota; -import org.apache.james.mailbox.jpa.quota.model.MaxDomainMessageCount; -import org.apache.james.mailbox.jpa.quota.model.MaxDomainStorage; -import org.apache.james.mailbox.jpa.quota.model.MaxGlobalMessageCount; -import org.apache.james.mailbox.jpa.quota.model.MaxGlobalStorage; -import org.apache.james.mailbox.jpa.quota.model.MaxUserMessageCount; -import org.apache.james.mailbox.jpa.quota.model.MaxUserStorage; -import org.apache.james.mailbox.jpa.user.model.JPASubscription; +import org.apache.james.mailbox.postgres.mail.model.JPAAttachment; +import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; +import org.apache.james.mailbox.postgres.mail.model.JPAMailboxAnnotation; +import org.apache.james.mailbox.postgres.mail.model.JPAProperty; +import org.apache.james.mailbox.postgres.mail.model.JPAUserFlag; +import org.apache.james.mailbox.postgres.mail.model.openjpa.AbstractJPAMailboxMessage; +import org.apache.james.mailbox.postgres.mail.model.openjpa.JPAMailboxMessage; +import org.apache.james.mailbox.postgres.mail.model.openjpa.JPAMailboxMessageWithAttachmentStorage; +import org.apache.james.mailbox.postgres.quota.model.JpaCurrentQuota; +import org.apache.james.mailbox.postgres.quota.model.MaxDomainMessageCount; +import org.apache.james.mailbox.postgres.quota.model.MaxDomainStorage; +import org.apache.james.mailbox.postgres.quota.model.MaxGlobalMessageCount; +import org.apache.james.mailbox.postgres.quota.model.MaxGlobalStorage; +import org.apache.james.mailbox.postgres.quota.model.MaxUserMessageCount; +import org.apache.james.mailbox.postgres.quota.model.MaxUserStorage; +import org.apache.james.mailbox.postgres.user.model.JPASubscription; import com.google.common.collect.ImmutableList; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPAMailboxManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxManagerTest.java similarity index 94% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPAMailboxManagerTest.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxManagerTest.java index f912607a5d7..b6f2db22439 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JPAMailboxManagerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxManagerTest.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa; +package org.apache.james.mailbox.postgres; import java.util.Optional; @@ -25,8 +25,8 @@ import org.apache.james.events.EventBus; import org.apache.james.mailbox.MailboxManagerTest; import org.apache.james.mailbox.SubscriptionManager; -import org.apache.james.mailbox.jpa.openjpa.OpenJPAMailboxManager; -import org.apache.james.mailbox.jpa.user.PostgresSubscriptionModule; +import org.apache.james.mailbox.postgres.openjpa.OpenJPAMailboxManager; +import org.apache.james.mailbox.postgres.user.PostgresSubscriptionModule; import org.apache.james.mailbox.store.StoreSubscriptionManager; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Disabled; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerProvider.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerProvider.java similarity index 94% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerProvider.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerProvider.java index b41338e4adc..9a93f28f23b 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerProvider.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerProvider.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa; +package org.apache.james.mailbox.postgres; import java.time.Instant; @@ -35,9 +35,10 @@ import org.apache.james.mailbox.Authorizator; import org.apache.james.mailbox.acl.MailboxACLResolver; import org.apache.james.mailbox.acl.UnionMailboxACLResolver; -import org.apache.james.mailbox.jpa.mail.JPAModSeqProvider; -import org.apache.james.mailbox.jpa.mail.JPAUidProvider; -import org.apache.james.mailbox.jpa.openjpa.OpenJPAMailboxManager; +import org.apache.james.mailbox.postgres.mail.JPAModSeqProvider; +import org.apache.james.mailbox.postgres.mail.JPAUidProvider; +import org.apache.james.mailbox.postgres.openjpa.OpenJPAMailboxManager; +import org.apache.james.mailbox.postgres.JPAAttachmentContentLoader; import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; import org.apache.james.mailbox.store.SessionProviderImpl; import org.apache.james.mailbox.store.StoreMailboxAnnotationManager; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerStressTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerStressTest.java similarity index 93% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerStressTest.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerStressTest.java index 06e275398de..8f2b2b8e528 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/JpaMailboxManagerStressTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerStressTest.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa; +package org.apache.james.mailbox.postgres; import java.util.Optional; @@ -25,8 +25,8 @@ import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.events.EventBus; import org.apache.james.mailbox.MailboxManagerStressContract; -import org.apache.james.mailbox.jpa.openjpa.OpenJPAMailboxManager; -import org.apache.james.mailbox.jpa.user.PostgresSubscriptionModule; +import org.apache.james.mailbox.postgres.openjpa.OpenJPAMailboxManager; +import org.apache.james.mailbox.postgres.user.PostgresSubscriptionModule; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.RegisterExtension; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresSubscriptionManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresSubscriptionManagerTest.java index 238de23842a..83b9074aa9a 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresSubscriptionManagerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresSubscriptionManagerTest.java @@ -30,10 +30,9 @@ import org.apache.james.events.delivery.InVmEventDelivery; import org.apache.james.mailbox.SubscriptionManager; import org.apache.james.mailbox.SubscriptionManagerContract; -import org.apache.james.mailbox.jpa.JPAMailboxFixture; -import org.apache.james.mailbox.jpa.mail.JPAModSeqProvider; -import org.apache.james.mailbox.jpa.mail.JPAUidProvider; -import org.apache.james.mailbox.jpa.user.PostgresSubscriptionModule; +import org.apache.james.mailbox.postgres.mail.JPAModSeqProvider; +import org.apache.james.mailbox.postgres.mail.JPAUidProvider; +import org.apache.james.mailbox.postgres.user.PostgresSubscriptionModule; import org.apache.james.mailbox.store.StoreSubscriptionManager; import org.apache.james.metrics.tests.RecordingMetricFactory; import org.junit.jupiter.api.AfterEach; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JPAAttachmentMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JPAAttachmentMapperTest.java similarity index 97% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JPAAttachmentMapperTest.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JPAAttachmentMapperTest.java index d4dc4282a5a..e6ffcf1526d 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JPAAttachmentMapperTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JPAAttachmentMapperTest.java @@ -19,12 +19,12 @@ -package org.apache.james.mailbox.jpa.mail; +package org.apache.james.mailbox.postgres.mail; import java.nio.charset.StandardCharsets; import org.apache.james.backends.jpa.JpaTestCluster; -import org.apache.james.mailbox.jpa.JPAMailboxFixture; +import org.apache.james.mailbox.postgres.JPAMailboxFixture; import org.apache.james.mailbox.model.AttachmentMetadata; import org.apache.james.mailbox.model.ContentType; import org.apache.james.mailbox.model.MessageId; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JPAMapperProvider.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JPAMapperProvider.java similarity index 97% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JPAMapperProvider.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JPAMapperProvider.java index c5e054b3bd2..1fab3e4b8b5 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JPAMapperProvider.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JPAMapperProvider.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail; +package org.apache.james.mailbox.postgres.mail; import java.util.List; import java.util.concurrent.ThreadLocalRandom; @@ -30,7 +30,7 @@ import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.ModSeq; import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.jpa.JPAId; +import org.apache.james.mailbox.postgres.JPAId; import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.MessageId; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JPAMessageWithAttachmentMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JPAMessageWithAttachmentMapperTest.java similarity index 98% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JPAMessageWithAttachmentMapperTest.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JPAMessageWithAttachmentMapperTest.java index 7383b55b711..5f9f14ae70e 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JPAMessageWithAttachmentMapperTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JPAMessageWithAttachmentMapperTest.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail; +package org.apache.james.mailbox.postgres.mail; import java.io.IOException; import java.util.Iterator; @@ -25,7 +25,7 @@ import org.apache.james.backends.jpa.JpaTestCluster; import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.jpa.JPAMailboxFixture; +import org.apache.james.mailbox.postgres.JPAMailboxFixture; import org.apache.james.mailbox.model.AttachmentMetadata; import org.apache.james.mailbox.model.MessageAttachmentMetadata; import org.apache.james.mailbox.model.MessageRange; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaAnnotationMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaAnnotationMapperTest.java similarity index 93% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaAnnotationMapperTest.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaAnnotationMapperTest.java index d2826ff3952..667714a800f 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaAnnotationMapperTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaAnnotationMapperTest.java @@ -17,13 +17,13 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail; +package org.apache.james.mailbox.postgres.mail; import java.util.concurrent.atomic.AtomicInteger; import org.apache.james.backends.jpa.JpaTestCluster; -import org.apache.james.mailbox.jpa.JPAId; -import org.apache.james.mailbox.jpa.JPAMailboxFixture; +import org.apache.james.mailbox.postgres.JPAId; +import org.apache.james.mailbox.postgres.JPAMailboxFixture; import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.store.mail.AnnotationMapper; import org.apache.james.mailbox.store.mail.model.AnnotationMapperTest; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaMailboxMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaMailboxMapperTest.java similarity index 94% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaMailboxMapperTest.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaMailboxMapperTest.java index 32aec06b28e..c48dbe4f42f 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaMailboxMapperTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaMailboxMapperTest.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail; +package org.apache.james.mailbox.postgres.mail; import static org.assertj.core.api.Assertions.assertThat; @@ -26,9 +26,9 @@ import javax.persistence.EntityManager; import org.apache.james.backends.jpa.JpaTestCluster; -import org.apache.james.mailbox.jpa.JPAId; -import org.apache.james.mailbox.jpa.JPAMailboxFixture; -import org.apache.james.mailbox.jpa.mail.model.JPAMailbox; +import org.apache.james.mailbox.postgres.JPAId; +import org.apache.james.mailbox.postgres.JPAMailboxFixture; +import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.store.mail.MailboxMapper; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaMessageMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaMessageMapperTest.java similarity index 98% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaMessageMapperTest.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaMessageMapperTest.java index 6a9c7055dd3..5041b743025 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaMessageMapperTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaMessageMapperTest.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail; +package org.apache.james.mailbox.postgres.mail; import static org.assertj.core.api.Assertions.assertThat; @@ -30,7 +30,7 @@ import org.apache.james.mailbox.MessageManager; import org.apache.james.mailbox.ModSeq; import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.jpa.JPAMailboxFixture; +import org.apache.james.mailbox.postgres.JPAMailboxFixture; import org.apache.james.mailbox.model.UpdatedFlags; import org.apache.james.mailbox.store.FlagsUpdateCalculator; import org.apache.james.mailbox.store.mail.model.MapperProvider; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaMessageMoveTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaMessageMoveTest.java similarity index 94% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaMessageMoveTest.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaMessageMoveTest.java index de8a1d30280..a8499468f1d 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/JpaMessageMoveTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaMessageMoveTest.java @@ -17,10 +17,10 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail; +package org.apache.james.mailbox.postgres.mail; import org.apache.james.backends.jpa.JpaTestCluster; -import org.apache.james.mailbox.jpa.JPAMailboxFixture; +import org.apache.james.mailbox.postgres.JPAMailboxFixture; import org.apache.james.mailbox.store.mail.model.MapperProvider; import org.apache.james.mailbox.store.mail.model.MessageMoveTest; import org.junit.jupiter.api.AfterEach; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/MessageUtilsTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/MessageUtilsTest.java similarity index 97% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/MessageUtilsTest.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/MessageUtilsTest.java index ca310e77503..fac4513ed43 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/MessageUtilsTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/MessageUtilsTest.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail; +package org.apache.james.mailbox.postgres.mail; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -35,6 +35,7 @@ import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MessageId; import org.apache.james.mailbox.model.ThreadId; +import org.apache.james.mailbox.postgres.mail.MessageUtils; import org.apache.james.mailbox.store.mail.ModSeqProvider; import org.apache.james.mailbox.store.mail.UidProvider; import org.apache.james.mailbox.store.mail.model.DefaultMessageId; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalAnnotationMapper.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalAnnotationMapper.java similarity index 96% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalAnnotationMapper.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalAnnotationMapper.java index 7a0ff31d272..ff419a36f9b 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalAnnotationMapper.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalAnnotationMapper.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail; +package org.apache.james.mailbox.postgres.mail; import java.util.List; import java.util.Set; @@ -26,6 +26,7 @@ import org.apache.james.mailbox.model.MailboxAnnotation; import org.apache.james.mailbox.model.MailboxAnnotationKey; import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.postgres.mail.JPAAnnotationMapper; import org.apache.james.mailbox.store.mail.AnnotationMapper; import org.apache.james.mailbox.store.transaction.Mapper; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalAttachmentMapper.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalAttachmentMapper.java similarity index 96% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalAttachmentMapper.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalAttachmentMapper.java index ecdd47f8c3f..6fc5b805424 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalAttachmentMapper.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalAttachmentMapper.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail; +package org.apache.james.mailbox.postgres.mail; import java.io.InputStream; import java.util.Collection; @@ -30,6 +30,7 @@ import org.apache.james.mailbox.model.MessageAttachmentMetadata; import org.apache.james.mailbox.model.MessageId; import org.apache.james.mailbox.model.ParsedAttachment; +import org.apache.james.mailbox.postgres.mail.JPAAttachmentMapper; import org.apache.james.mailbox.store.mail.AttachmentMapper; import reactor.core.publisher.Mono; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalMailboxMapper.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalMailboxMapper.java similarity index 96% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalMailboxMapper.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalMailboxMapper.java index eef06dedf91..36608def8db 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalMailboxMapper.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalMailboxMapper.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail; +package org.apache.james.mailbox.postgres.mail; import org.apache.james.core.Username; import org.apache.james.mailbox.acl.ACLDiff; @@ -28,6 +28,7 @@ import org.apache.james.mailbox.model.MailboxPath; import org.apache.james.mailbox.model.UidValidity; import org.apache.james.mailbox.model.search.MailboxQuery; +import org.apache.james.mailbox.postgres.mail.JPAMailboxMapper; import org.apache.james.mailbox.store.mail.MailboxMapper; import reactor.core.publisher.Flux; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalMessageMapper.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalMessageMapper.java similarity index 98% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalMessageMapper.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalMessageMapper.java index ad7e9e56e6d..f779af3c30f 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/TransactionalMessageMapper.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalMessageMapper.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail; +package org.apache.james.mailbox.postgres.mail; import java.util.Iterator; import java.util.List; @@ -34,6 +34,7 @@ import org.apache.james.mailbox.model.MessageMetaData; import org.apache.james.mailbox.model.MessageRange; import org.apache.james.mailbox.model.UpdatedFlags; +import org.apache.james.mailbox.postgres.mail.JPAMessageMapper; import org.apache.james.mailbox.store.FlagsUpdateCalculator; import org.apache.james.mailbox.store.mail.MessageMapper; import org.apache.james.mailbox.store.mail.model.MailboxMessage; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAMailboxMessageTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAMailboxMessageTest.java similarity index 94% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAMailboxMessageTest.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAMailboxMessageTest.java index 31d59a411c7..cc34126ed43 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/model/openjpa/JPAMailboxMessageTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAMailboxMessageTest.java @@ -16,13 +16,14 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail.model.openjpa; +package org.apache.james.mailbox.postgres.mail.model.openjpa; import static org.assertj.core.api.Assertions.assertThat; import java.nio.charset.StandardCharsets; import org.apache.commons.io.IOUtils; +import org.apache.james.mailbox.postgres.mail.model.openjpa.JPAMailboxMessage; import org.junit.jupiter.api.Test; class JPAMailboxMessageTest { diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/task/JPARecomputeCurrentQuotasServiceTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/JPARecomputeCurrentQuotasServiceTest.java similarity index 93% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/task/JPARecomputeCurrentQuotasServiceTest.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/JPARecomputeCurrentQuotasServiceTest.java index 82aad9b3a69..41032b719e4 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/mail/task/JPARecomputeCurrentQuotasServiceTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/JPARecomputeCurrentQuotasServiceTest.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.mail.task; +package org.apache.james.mailbox.postgres.mail.task; import javax.persistence.EntityManagerFactory; @@ -30,13 +30,13 @@ import org.apache.james.domainlist.jpa.model.JPADomain; import org.apache.james.mailbox.MailboxManager; import org.apache.james.mailbox.SessionProvider; -import org.apache.james.mailbox.jpa.JPAMailboxFixture; +import org.apache.james.mailbox.postgres.JPAMailboxFixture; import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; -import org.apache.james.mailbox.jpa.JpaMailboxManagerProvider; -import org.apache.james.mailbox.jpa.mail.JPAModSeqProvider; -import org.apache.james.mailbox.jpa.mail.JPAUidProvider; -import org.apache.james.mailbox.jpa.quota.JpaCurrentQuotaManager; -import org.apache.james.mailbox.jpa.user.PostgresSubscriptionModule; +import org.apache.james.mailbox.postgres.JpaMailboxManagerProvider; +import org.apache.james.mailbox.postgres.mail.JPAModSeqProvider; +import org.apache.james.mailbox.postgres.mail.JPAUidProvider; +import org.apache.james.mailbox.postgres.quota.JpaCurrentQuotaManager; +import org.apache.james.mailbox.postgres.user.PostgresSubscriptionModule; import org.apache.james.mailbox.quota.CurrentQuotaManager; import org.apache.james.mailbox.quota.UserQuotaRootResolver; import org.apache.james.mailbox.quota.task.RecomputeCurrentQuotasService; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/quota/JPACurrentQuotaManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/JPACurrentQuotaManagerTest.java similarity index 91% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/quota/JPACurrentQuotaManagerTest.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/JPACurrentQuotaManagerTest.java index 18975136c77..b4011bb6498 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/quota/JPACurrentQuotaManagerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/JPACurrentQuotaManagerTest.java @@ -17,10 +17,11 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.quota; +package org.apache.james.mailbox.postgres.quota; import org.apache.james.backends.jpa.JpaTestCluster; -import org.apache.james.mailbox.jpa.JPAMailboxFixture; +import org.apache.james.mailbox.postgres.JPAMailboxFixture; +import org.apache.james.mailbox.postgres.quota.JpaCurrentQuotaManager; import org.apache.james.mailbox.quota.CurrentQuotaManager; import org.apache.james.mailbox.store.quota.CurrentQuotaManagerContract; import org.junit.jupiter.api.AfterEach; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/quota/JPAPerUserMaxQuotaTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/JPAPerUserMaxQuotaTest.java similarity index 88% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/quota/JPAPerUserMaxQuotaTest.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/JPAPerUserMaxQuotaTest.java index 8cb8f8be851..6b14d6f83cd 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/quota/JPAPerUserMaxQuotaTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/JPAPerUserMaxQuotaTest.java @@ -17,10 +17,12 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.quota; +package org.apache.james.mailbox.postgres.quota; import org.apache.james.backends.jpa.JpaTestCluster; -import org.apache.james.mailbox.jpa.JPAMailboxFixture; +import org.apache.james.mailbox.postgres.JPAMailboxFixture; +import org.apache.james.mailbox.postgres.quota.JPAPerUserMaxQuotaDAO; +import org.apache.james.mailbox.postgres.quota.JPAPerUserMaxQuotaManager; import org.apache.james.mailbox.quota.MaxQuotaManager; import org.apache.james.mailbox.store.quota.GenericMaxQuotaManagerTest; import org.junit.jupiter.api.AfterEach; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperTest.java similarity index 87% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionMapperTest.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperTest.java index 009a900c351..ac693c9c99d 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/jpa/user/PostgresSubscriptionMapperTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperTest.java @@ -17,9 +17,12 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.jpa.user; +package org.apache.james.mailbox.postgres.user; import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.mailbox.postgres.user.PostgresSubscriptionDAO; +import org.apache.james.mailbox.postgres.user.PostgresSubscriptionMapper; +import org.apache.james.mailbox.postgres.user.PostgresSubscriptionModule; import org.apache.james.mailbox.store.user.SubscriptionMapper; import org.apache.james.mailbox.store.user.SubscriptionMapperTest; import org.junit.jupiter.api.extension.RegisterExtension; diff --git a/mailbox/postgres/src/test/resources/persistence.xml b/mailbox/postgres/src/test/resources/persistence.xml index ae8f4361d0d..31f7a7a7616 100644 --- a/mailbox/postgres/src/test/resources/persistence.xml +++ b/mailbox/postgres/src/test/resources/persistence.xml @@ -24,23 +24,23 @@ version="2.0"> - org.apache.james.mailbox.jpa.mail.model.JPAMailbox - org.apache.james.mailbox.jpa.mail.model.JPAUserFlag - org.apache.james.mailbox.jpa.mail.model.openjpa.AbstractJPAMailboxMessage - org.apache.james.mailbox.jpa.mail.model.openjpa.JPAMailboxMessage - org.apache.james.mailbox.jpa.mail.model.JPAAttachment - org.apache.james.mailbox.jpa.mail.model.JPAProperty - org.apache.james.mailbox.jpa.user.model.JPASubscription - org.apache.james.mailbox.jpa.quota.model.MaxDomainMessageCount - org.apache.james.mailbox.jpa.quota.model.MaxDomainStorage - org.apache.james.mailbox.jpa.quota.model.MaxGlobalMessageCount - org.apache.james.mailbox.jpa.quota.model.MaxGlobalStorage - org.apache.james.mailbox.jpa.quota.model.MaxUserMessageCount - org.apache.james.mailbox.jpa.quota.model.MaxUserStorage - org.apache.james.mailbox.jpa.quota.model.JpaCurrentQuota - org.apache.james.mailbox.jpa.mail.model.JPAMailboxAnnotation - org.apache.james.mailbox.jpa.mail.model.JPAMailboxAnnotationId - org.apache.james.mailbox.jpa.mail.model.openjpa.AbstractJPAMailboxMessage$MailboxIdUidKey + org.apache.james.mailbox.postgres.mail.model.JPAMailbox + org.apache.james.mailbox.postgres.mail.model.JPAUserFlag + org.apache.james.mailbox.postgres.mail.model.openjpa.AbstractJPAMailboxMessage + org.apache.james.mailbox.postgres.mail.model.openjpa.JPAMailboxMessage + org.apache.james.mailbox.postgres.mail.model.JPAAttachment + org.apache.james.mailbox.postgres.mail.model.JPAProperty + org.apache.james.mailbox.postgres.user.model.JPASubscription + org.apache.james.mailbox.postgres.quota.model.MaxDomainMessageCount + org.apache.james.mailbox.postgres.quota.model.MaxDomainStorage + org.apache.james.mailbox.postgres.quota.model.MaxGlobalMessageCount + org.apache.james.mailbox.postgres.quota.model.MaxGlobalStorage + org.apache.james.mailbox.postgres.quota.model.MaxUserMessageCount + org.apache.james.mailbox.postgres.quota.model.MaxUserStorage + org.apache.james.mailbox.postgres.quota.model.JpaCurrentQuota + org.apache.james.mailbox.postgres.mail.model.JPAMailboxAnnotation + org.apache.james.mailbox.postgres.mail.model.JPAMailboxAnnotationId + org.apache.james.mailbox.postgres.mail.model.openjpa.AbstractJPAMailboxMessage$MailboxIdUidKey diff --git a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml index 3c26a90ca2c..bd9ae808b63 100644 --- a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml +++ b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml @@ -24,12 +24,12 @@ version="2.0"> - org.apache.james.mailbox.jpa.mail.model.JPAMailbox - org.apache.james.mailbox.jpa.mail.model.JPAUserFlag - org.apache.james.mailbox.jpa.mail.model.openjpa.AbstractJPAMailboxMessage - org.apache.james.mailbox.jpa.mail.model.openjpa.JPAMailboxMessage - org.apache.james.mailbox.jpa.mail.model.JPAProperty - org.apache.james.mailbox.jpa.user.model.JPASubscription + org.apache.james.mailbox.postgres.mail.model.JPAMailbox + org.apache.james.mailbox.postgres.mail.model.JPAUserFlag + org.apache.james.mailbox.postgres.mail.model.openjpa.AbstractJPAMailboxMessage + org.apache.james.mailbox.postgres.mail.model.openjpa.JPAMailboxMessage + org.apache.james.mailbox.postgres.mail.model.JPAProperty + org.apache.james.mailbox.postgres.user.model.JPASubscription org.apache.james.domainlist.jpa.model.JPADomain org.apache.james.mailrepository.jpa.model.JPAUrl @@ -39,18 +39,18 @@ org.apache.james.sieve.jpa.model.JPASieveQuota org.apache.james.sieve.jpa.model.JPASieveScript - org.apache.james.mailbox.jpa.quota.model.MaxDomainMessageCount - org.apache.james.mailbox.jpa.quota.model.MaxDomainStorage - org.apache.james.mailbox.jpa.quota.model.MaxGlobalMessageCount - org.apache.james.mailbox.jpa.quota.model.MaxGlobalStorage - org.apache.james.mailbox.jpa.quota.model.MaxUserMessageCount - org.apache.james.mailbox.jpa.quota.model.MaxUserStorage - org.apache.james.mailbox.jpa.quota.model.MaxDomainStorage - org.apache.james.mailbox.jpa.quota.model.MaxDomainMessageCount - org.apache.james.mailbox.jpa.quota.model.JpaCurrentQuota + org.apache.james.mailbox.postgres.quota.model.MaxDomainMessageCount + org.apache.james.mailbox.postgres.quota.model.MaxDomainStorage + org.apache.james.mailbox.postgres.quota.model.MaxGlobalMessageCount + org.apache.james.mailbox.postgres.quota.model.MaxGlobalStorage + org.apache.james.mailbox.postgres.quota.model.MaxUserMessageCount + org.apache.james.mailbox.postgres.quota.model.MaxUserStorage + org.apache.james.mailbox.postgres.quota.model.MaxDomainStorage + org.apache.james.mailbox.postgres.quota.model.MaxDomainMessageCount + org.apache.james.mailbox.postgres.quota.model.JpaCurrentQuota - org.apache.james.mailbox.jpa.mail.model.JPAMailboxAnnotation - org.apache.james.mailbox.jpa.mail.model.JPAMailboxAnnotationId + org.apache.james.mailbox.postgres.mail.model.JPAMailboxAnnotation + org.apache.james.mailbox.postgres.mail.model.JPAMailboxAnnotationId diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JPAMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JPAMailboxModule.java index 0d8b825fd74..8f92d0e27cb 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JPAMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JPAMailboxModule.java @@ -39,14 +39,14 @@ import org.apache.james.mailbox.acl.MailboxACLResolver; import org.apache.james.mailbox.acl.UnionMailboxACLResolver; import org.apache.james.mailbox.indexer.ReIndexer; -import org.apache.james.mailbox.jpa.JPAAttachmentContentLoader; -import org.apache.james.mailbox.jpa.JPAId; -import org.apache.james.mailbox.jpa.mail.JPAModSeqProvider; -import org.apache.james.mailbox.jpa.mail.JPAUidProvider; -import org.apache.james.mailbox.jpa.openjpa.OpenJPAMailboxManager; import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.postgres.JPAAttachmentContentLoader; +import org.apache.james.mailbox.postgres.JPAId; import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; +import org.apache.james.mailbox.postgres.mail.JPAModSeqProvider; +import org.apache.james.mailbox.postgres.mail.JPAUidProvider; +import org.apache.james.mailbox.postgres.openjpa.OpenJPAMailboxManager; import org.apache.james.mailbox.store.JVMMailboxPathLocker; import org.apache.james.mailbox.store.MailboxManagerConfiguration; import org.apache.james.mailbox.store.MailboxSessionMapperFactory; diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JpaQuotaModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JpaQuotaModule.java index e12ea9b44ad..49faa418205 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JpaQuotaModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JpaQuotaModule.java @@ -20,8 +20,8 @@ package org.apache.james.modules.mailbox; import org.apache.james.events.EventListener; -import org.apache.james.mailbox.jpa.quota.JPAPerUserMaxQuotaManager; -import org.apache.james.mailbox.jpa.quota.JpaCurrentQuotaManager; +import org.apache.james.mailbox.postgres.quota.JPAPerUserMaxQuotaManager; +import org.apache.james.mailbox.postgres.quota.JpaCurrentQuotaManager; import org.apache.james.mailbox.quota.CurrentQuotaManager; import org.apache.james.mailbox.quota.MaxQuotaManager; import org.apache.james.mailbox.quota.QuotaManager; diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java index 6d937bdb2e0..a8c132a6dca 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java @@ -19,7 +19,7 @@ package org.apache.james.modules.mailbox; import org.apache.james.backends.postgres.PostgresModule; -import org.apache.james.mailbox.jpa.user.PostgresSubscriptionModule; +import org.apache.james.mailbox.postgres.user.PostgresSubscriptionModule; import org.apache.james.modules.data.PostgresCommonModule; import com.google.inject.AbstractModule; From c848549b76be943dcd6d8af5b999a3487646ec4f Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Fri, 10 Nov 2023 11:39:39 +0700 Subject: [PATCH 043/341] JAMES-2586 postgres mailbox - drop JPAStreamingMailboxMessage, JPAEncryptedMailboxMessage, JPAMailboxMessageWithAttachmentStorage --- .../postgres/mail/JPAMessageMapper.java | 34 +--- .../openjpa/JPAEncryptedMailboxMessage.java | 112 ------------- ...PAMailboxMessageWithAttachmentStorage.java | 155 ------------------ .../openjpa/JPAStreamingMailboxMessage.java | 125 -------------- .../openjpa/OpenJPAMessageFactory.java | 13 +- .../mailbox/postgres/JPAMailboxFixture.java | 4 +- .../JPAMessageWithAttachmentMapperTest.java | 132 --------------- 7 files changed, 4 insertions(+), 571 deletions(-) delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAEncryptedMailboxMessage.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAMailboxMessageWithAttachmentStorage.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAStreamingMailboxMessage.java delete mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JPAMessageWithAttachmentMapperTest.java diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAMessageMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAMessageMapper.java index 66df840e708..89c2d3d1d68 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAMessageMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAMessageMapper.java @@ -50,10 +50,7 @@ import org.apache.james.mailbox.postgres.mail.model.JPAAttachment; import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; import org.apache.james.mailbox.postgres.mail.model.openjpa.AbstractJPAMailboxMessage; -import org.apache.james.mailbox.postgres.mail.model.openjpa.JPAEncryptedMailboxMessage; import org.apache.james.mailbox.postgres.mail.model.openjpa.JPAMailboxMessage; -import org.apache.james.mailbox.postgres.mail.model.openjpa.JPAMailboxMessageWithAttachmentStorage; -import org.apache.james.mailbox.postgres.mail.model.openjpa.JPAStreamingMailboxMessage; import org.apache.james.mailbox.store.FlagsUpdateCalculator; import org.apache.james.mailbox.store.mail.MessageMapper; import org.apache.james.mailbox.store.mail.model.MailboxMessage; @@ -363,15 +360,7 @@ private MessageMetaData copy(Mailbox mailbox, MessageUid uid, ModSeq modSeq, Mai MailboxMessage copy; JPAMailbox currentMailbox = JPAMailbox.from(mailbox); - if (original instanceof JPAStreamingMailboxMessage) { - copy = new JPAStreamingMailboxMessage(currentMailbox, uid, modSeq, original); - } else if (original instanceof JPAEncryptedMailboxMessage) { - copy = new JPAEncryptedMailboxMessage(currentMailbox, uid, modSeq, original); - } else if (original instanceof JPAMailboxMessageWithAttachmentStorage) { - copy = new JPAMailboxMessageWithAttachmentStorage(currentMailbox, uid, modSeq, original); - } else { - copy = new JPAMailboxMessage(currentMailbox, uid, modSeq, original); - } + copy = new JPAMailboxMessage(currentMailbox, uid, modSeq, original); return save(mailbox, copy); } @@ -394,26 +383,7 @@ protected MessageMetaData save(Mailbox mailbox, MailboxMessage message) throws M getEntityManager().persist(message); return message.metaData(); - } else if (isAttachmentStorage) { - JPAMailboxMessageWithAttachmentStorage persistData = new JPAMailboxMessageWithAttachmentStorage(currentMailbox, message.getUid(), message.getModSeq(), message); - persistData.setFlags(message.createFlags()); - - if (message.getAttachments().isEmpty()) { - getEntityManager().persist(persistData); - } else { - List attachments = getAttachments(message); - if (attachments.isEmpty()) { - persistData.setAttachments(message.getAttachments().stream() - .map(JPAAttachment::new) - .collect(Collectors.toList())); - getEntityManager().persist(persistData); - } else { - persistData.setAttachments(attachments); - getEntityManager().merge(persistData); - } - } - return persistData.metaData(); - } else { + } else { JPAMailboxMessage persistData = new JPAMailboxMessage(currentMailbox, message.getUid(), message.getModSeq(), message); persistData.setFlags(message.createFlags()); getEntityManager().persist(persistData); diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAEncryptedMailboxMessage.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAEncryptedMailboxMessage.java deleted file mode 100644 index 385c4549c14..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAEncryptedMailboxMessage.java +++ /dev/null @@ -1,112 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.mailbox.postgres.mail.model.openjpa; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.util.Date; - -import javax.mail.Flags; -import javax.persistence.Basic; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.FetchType; -import javax.persistence.Lob; -import javax.persistence.Table; - -import org.apache.commons.io.IOUtils; -import org.apache.commons.io.input.BoundedInputStream; -import org.apache.james.mailbox.MessageUid; -import org.apache.james.mailbox.ModSeq; -import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.model.Content; -import org.apache.james.mailbox.model.Mailbox; -import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; -import org.apache.james.mailbox.store.mail.model.MailboxMessage; -import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; -import org.apache.openjpa.persistence.Externalizer; -import org.apache.openjpa.persistence.Factory; - -@Entity(name = "MailboxMessage") -@Table(name = "JAMES_MAIL") -public class JPAEncryptedMailboxMessage extends AbstractJPAMailboxMessage { - - /** The value for the body field. Lazy loaded */ - /** We use a max length to represent 1gb data. Thats prolly overkill, but who knows */ - @Basic(optional = false, fetch = FetchType.LAZY) - @Column(name = "MAIL_BYTES", length = 1048576000, nullable = false) - @Externalizer("EncryptDecryptHelper.getEncrypted") - @Factory("EncryptDecryptHelper.getDecrypted") - @Lob private byte[] body; - - - /** The value for the header field. Lazy loaded */ - /** We use a max length to represent 1gb data. Thats prolly overkill, but who knows */ - @Basic(optional = false, fetch = FetchType.LAZY) - @Column(name = "HEADER_BYTES", length = 10485760, nullable = false) - @Externalizer("EncryptDecryptHelper.getEncrypted") - @Factory("EncryptDecryptHelper.getDecrypted") - @Lob private byte[] header; - - public JPAEncryptedMailboxMessage(JPAMailbox mailbox, Date internalDate, int size, Flags flags, Content content, int bodyStartOctet, PropertyBuilder propertyBuilder) throws MailboxException { - super(mailbox, internalDate, flags, size, bodyStartOctet, propertyBuilder); - try { - int headerEnd = bodyStartOctet; - if (headerEnd < 0) { - headerEnd = 0; - } - InputStream stream = content.getInputStream(); - this.header = IOUtils.toByteArray(new BoundedInputStream(stream, getBodyStartOctet())); - this.body = IOUtils.toByteArray(stream); - - } catch (IOException e) { - throw new MailboxException("Unable to parse message",e); - } - } - - /** - * Create a copy of the given message - */ - public JPAEncryptedMailboxMessage(JPAMailbox mailbox, MessageUid uid, ModSeq modSeq, MailboxMessage message) throws MailboxException { - super(mailbox, uid, modSeq, message); - try { - this.body = IOUtils.toByteArray(message.getBodyContent()); - this.header = IOUtils.toByteArray(message.getHeaderContent()); - } catch (IOException e) { - throw new MailboxException("Unable to parse message",e); - } - } - - - @Override - public InputStream getBodyContent() throws IOException { - return new ByteArrayInputStream(body); - } - - @Override - public InputStream getHeaderContent() throws IOException { - return new ByteArrayInputStream(header); - } - - @Override - public MailboxMessage copy(Mailbox mailbox) throws MailboxException { - return new JPAEncryptedMailboxMessage(JPAMailbox.from(mailbox), getUid(), getModSeq(), this); - } -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAMailboxMessageWithAttachmentStorage.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAMailboxMessageWithAttachmentStorage.java deleted file mode 100644 index 85052667d88..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAMailboxMessageWithAttachmentStorage.java +++ /dev/null @@ -1,155 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.mailbox.postgres.mail.model.openjpa; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.stream.Collectors; - -import javax.mail.Flags; -import javax.persistence.Basic; -import javax.persistence.CascadeType; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.FetchType; -import javax.persistence.Lob; -import javax.persistence.OneToMany; -import javax.persistence.OrderBy; -import javax.persistence.Table; - -import org.apache.commons.io.IOUtils; -import org.apache.commons.io.input.BoundedInputStream; -import org.apache.james.mailbox.MessageUid; -import org.apache.james.mailbox.ModSeq; -import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.model.Content; -import org.apache.james.mailbox.model.Mailbox; -import org.apache.james.mailbox.model.MessageAttachmentMetadata; -import org.apache.james.mailbox.postgres.mail.model.JPAAttachment; -import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; -import org.apache.james.mailbox.store.mail.model.MailboxMessage; -import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; -import org.apache.openjpa.persistence.jdbc.ElementJoinColumn; -import org.apache.openjpa.persistence.jdbc.ElementJoinColumns; - -@Entity(name = "MailboxMessage") -@Table(name = "JAMES_MAIL") -public class JPAMailboxMessageWithAttachmentStorage extends AbstractJPAMailboxMessage { - - private static final byte[] EMPTY_ARRAY = new byte[] {}; - - /** The value for the body field. Lazy loaded */ - /** We use a max length to represent 1gb data. Thats prolly overkill, but who knows */ - @Basic(optional = false, fetch = FetchType.LAZY) - @Column(name = "MAIL_BYTES", length = 1048576000, nullable = false) - @Lob - private byte[] body; - - /** The value for the header field. Lazy loaded */ - /** We use a max length to represent 10mb data. Thats prolly overkill, but who knows */ - @Basic(optional = false, fetch = FetchType.LAZY) - @Column(name = "HEADER_BYTES", length = 10485760, nullable = false) - @Lob private byte[] header; - - /** - * Metadata for attachments - */ - @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER) - @OrderBy("attachmentId") - @ElementJoinColumns({@ElementJoinColumn(name = "MAILBOX_ID", referencedColumnName = "MAILBOX_ID"), - @ElementJoinColumn(name = "MAIL_UID", referencedColumnName = "MAIL_UID")}) - private List attachments; - - - public JPAMailboxMessageWithAttachmentStorage() { - - } - - public JPAMailboxMessageWithAttachmentStorage(JPAMailbox mailbox, Date internalDate, int size, Flags flags, Content content, int bodyStartOctet, PropertyBuilder propertyBuilder) throws MailboxException { - super(mailbox, internalDate, flags, size, bodyStartOctet, propertyBuilder); - try { - int headerEnd = bodyStartOctet; - if (headerEnd < 0) { - headerEnd = 0; - } - InputStream stream = content.getInputStream(); - this.header = IOUtils.toByteArray(new BoundedInputStream(stream, headerEnd)); - this.body = IOUtils.toByteArray(stream); - - } catch (IOException e) { - throw new MailboxException("Unable to parse message",e); - } - attachments = new ArrayList<>(); - } - - /** - * Create a copy of the given message - */ - public JPAMailboxMessageWithAttachmentStorage(JPAMailbox mailbox, MessageUid uid, ModSeq modSeq, MailboxMessage message) throws MailboxException { - super(mailbox, uid, modSeq, message); - try { - this.body = IOUtils.toByteArray(message.getBodyContent()); - this.header = IOUtils.toByteArray(message.getHeaderContent()); - } catch (IOException e) { - throw new MailboxException("Unable to parse message",e); - } - attachments = new ArrayList<>(); - - } - - @Override - public InputStream getBodyContent() throws IOException { - if (body == null) { - return new ByteArrayInputStream(EMPTY_ARRAY); - } - return new ByteArrayInputStream(body); - } - - @Override - public InputStream getHeaderContent() throws IOException { - if (header == null) { - return new ByteArrayInputStream(EMPTY_ARRAY); - } - return new ByteArrayInputStream(header); - } - - @Override - public MailboxMessage copy(Mailbox mailbox) throws MailboxException { - return new JPAMailboxMessage(JPAMailbox.from(mailbox), getUid(), getModSeq(), this); - } - - /** - * Utility attachments' setter. - */ - public void setAttachments(List attachments) { - this.attachments = attachments; - } - - @Override - public List getAttachments() { - - return this.attachments.stream() - .map(JPAAttachment::toMessageAttachmentMetadata) - .collect(Collectors.toList()); - } -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAStreamingMailboxMessage.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAStreamingMailboxMessage.java deleted file mode 100644 index 356c8ffff77..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAStreamingMailboxMessage.java +++ /dev/null @@ -1,125 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.mailbox.postgres.mail.model.openjpa; - -import java.io.IOException; -import java.io.InputStream; -import java.util.Date; - -import javax.mail.Flags; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.FetchType; -import javax.persistence.Table; - -import org.apache.commons.io.input.BoundedInputStream; -import org.apache.james.mailbox.MessageUid; -import org.apache.james.mailbox.ModSeq; -import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.model.Content; -import org.apache.james.mailbox.model.Mailbox; -import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; -import org.apache.james.mailbox.store.mail.model.MailboxMessage; -import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; -import org.apache.openjpa.persistence.Persistent; - -/** - * JPA implementation of {@link AbstractJPAMailboxMessage} which use openjpas {@link Persistent} type to - * be able to stream the message content without loading it into the memory at all. - * - * This is not supported for all DB's yet. See Additional JPA Mappings - * - * If your DB is not supported by this, use {@link JPAMailboxMessage} - * - * TODO: Fix me! - */ -@Entity(name = "MailboxMessage") -@Table(name = "JAMES_MAIL") -public class JPAStreamingMailboxMessage extends AbstractJPAMailboxMessage { - - @Persistent(optional = false, fetch = FetchType.LAZY) - @Column(name = "MAIL_BYTES", length = 1048576000, nullable = false) - private InputStream body; - - @Persistent(optional = false, fetch = FetchType.LAZY) - @Column(name = "HEADER_BYTES", length = 10485760, nullable = false) - private InputStream header; - - private final Content content; - - public JPAStreamingMailboxMessage(JPAMailbox mailbox, Date internalDate, int size, Flags flags, Content content, int bodyStartOctet, PropertyBuilder propertyBuilder) throws MailboxException { - super(mailbox, internalDate, flags, size, bodyStartOctet, propertyBuilder); - this.content = content; - - try { - this.header = new BoundedInputStream(content.getInputStream(), getBodyStartOctet()); - InputStream bodyStream = content.getInputStream(); - bodyStream.skip(getBodyStartOctet()); - this.body = bodyStream; - - } catch (IOException e) { - throw new MailboxException("Unable to parse message",e); - } - } - - /** - * Create a copy of the given message - */ - public JPAStreamingMailboxMessage(JPAMailbox mailbox, MessageUid uid, ModSeq modSeq, MailboxMessage message) throws MailboxException { - super(mailbox, uid, modSeq, message); - this.content = new Content() { - @Override - public InputStream getInputStream() throws IOException { - return message.getFullContent(); - } - - @Override - public long size() { - return message.getFullContentOctets(); - } - }; - try { - this.header = getHeaderContent(); - this.body = getBodyContent(); - } catch (IOException e) { - throw new MailboxException("Unable to parse message",e); - } - } - - @Override - public InputStream getBodyContent() throws IOException { - InputStream inputStream = content.getInputStream(); - inputStream.skip(getBodyStartOctet()); - return inputStream; - } - - @Override - public InputStream getHeaderContent() throws IOException { - int headerEnd = getBodyStartOctet() - 2; - if (headerEnd < 0) { - headerEnd = 0; - } - return new BoundedInputStream(content.getInputStream(), headerEnd); - } - - @Override - public MailboxMessage copy(Mailbox mailbox) throws MailboxException { - return new JPAStreamingMailboxMessage(JPAMailbox.from(mailbox), getUid(), getModSeq(), this); - } -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMessageFactory.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMessageFactory.java index a5696499e46..56027df0fb8 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMessageFactory.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMessageFactory.java @@ -32,9 +32,7 @@ import org.apache.james.mailbox.model.ThreadId; import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; import org.apache.james.mailbox.postgres.mail.model.openjpa.AbstractJPAMailboxMessage; -import org.apache.james.mailbox.postgres.mail.model.openjpa.JPAEncryptedMailboxMessage; import org.apache.james.mailbox.postgres.mail.model.openjpa.JPAMailboxMessage; -import org.apache.james.mailbox.postgres.mail.model.openjpa.JPAStreamingMailboxMessage; import org.apache.james.mailbox.store.MessageFactory; import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; @@ -53,15 +51,6 @@ public enum AdvancedFeature { @Override public AbstractJPAMailboxMessage createMessage(MessageId messageId, ThreadId threadId, Mailbox mailbox, Date internalDate, Date saveDate, int size, int bodyStartOctet, Content content, Flags flags, PropertyBuilder propertyBuilder, List attachments) throws MailboxException { - switch (feature) { - case Streaming: - return new JPAStreamingMailboxMessage(JPAMailbox.from(mailbox), internalDate, size, flags, content, - bodyStartOctet, propertyBuilder); - case Encryption: - return new JPAEncryptedMailboxMessage(JPAMailbox.from(mailbox), internalDate, size, flags, content, - bodyStartOctet, propertyBuilder); - default: - return new JPAMailboxMessage(JPAMailbox.from(mailbox), internalDate, size, flags, content, bodyStartOctet, propertyBuilder); - } + return new JPAMailboxMessage(JPAMailbox.from(mailbox), internalDate, size, flags, content, bodyStartOctet, propertyBuilder); } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxFixture.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxFixture.java index 16bdec95ad9..3262f4ec7c6 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxFixture.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxFixture.java @@ -28,7 +28,6 @@ import org.apache.james.mailbox.postgres.mail.model.JPAUserFlag; import org.apache.james.mailbox.postgres.mail.model.openjpa.AbstractJPAMailboxMessage; import org.apache.james.mailbox.postgres.mail.model.openjpa.JPAMailboxMessage; -import org.apache.james.mailbox.postgres.mail.model.openjpa.JPAMailboxMessageWithAttachmentStorage; import org.apache.james.mailbox.postgres.quota.model.JpaCurrentQuota; import org.apache.james.mailbox.postgres.quota.model.MaxDomainMessageCount; import org.apache.james.mailbox.postgres.quota.model.MaxDomainStorage; @@ -50,8 +49,7 @@ public interface JPAMailboxFixture { JPAUserFlag.class, JPAMailboxAnnotation.class, JPASubscription.class, - JPAAttachment.class, - JPAMailboxMessageWithAttachmentStorage.class + JPAAttachment.class ); List> QUOTA_PERSISTANCE_CLASSES = ImmutableList.of( diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JPAMessageWithAttachmentMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JPAMessageWithAttachmentMapperTest.java deleted file mode 100644 index 5f9f14ae70e..00000000000 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JPAMessageWithAttachmentMapperTest.java +++ /dev/null @@ -1,132 +0,0 @@ -/************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.mail; - -import java.io.IOException; -import java.util.Iterator; -import java.util.List; - -import org.apache.james.backends.jpa.JpaTestCluster; -import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.postgres.JPAMailboxFixture; -import org.apache.james.mailbox.model.AttachmentMetadata; -import org.apache.james.mailbox.model.MessageAttachmentMetadata; -import org.apache.james.mailbox.model.MessageRange; -import org.apache.james.mailbox.store.mail.MessageMapper; -import org.apache.james.mailbox.store.mail.model.MailboxMessage; -import org.apache.james.mailbox.store.mail.model.MapperProvider; -import org.apache.james.mailbox.store.mail.model.MessageAssert; -import org.apache.james.mailbox.store.mail.model.MessageWithAttachmentMapperTest; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.tuple; - -class JPAMessageWithAttachmentMapperTest extends MessageWithAttachmentMapperTest { - - static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); - - @Override - protected MapperProvider createMapperProvider() { - return new JPAMapperProvider(JPA_TEST_CLUSTER); - } - - @AfterEach - void cleanUp() { - JPA_TEST_CLUSTER.clear(JPAMailboxFixture.MAILBOX_TABLE_NAMES); - } - - @Test - @Override - protected void messagesRetrievedUsingFetchTypeFullShouldHaveAttachmentsLoadedWhenOneAttachment() throws MailboxException { - saveMessages(); - MessageMapper.FetchType fetchType = MessageMapper.FetchType.FULL; - Iterator retrievedMessageIterator = messageMapper.findInMailbox(attachmentsMailbox, MessageRange.one(messageWith1Attachment.getUid()), fetchType, LIMIT); - - AttachmentMetadata attachment = messageWith1Attachment.getAttachments().get(0).getAttachment(); - MessageAttachmentMetadata attachmentMetadata = messageWith1Attachment.getAttachments().get(0); - List messageAttachments = retrievedMessageIterator.next().getAttachments(); - - // JPA does not support MessageId - assertThat(messageAttachments) - .extracting(MessageAttachmentMetadata::getAttachment) - .extracting("attachmentId", "size", "type") - .containsExactlyInAnyOrder( - tuple(attachment.getAttachmentId(), attachment.getSize(), attachment.getType()) - ); - assertThat(messageAttachments) - .extracting( - MessageAttachmentMetadata::getAttachmentId, - MessageAttachmentMetadata::getName, - MessageAttachmentMetadata::getCid, - MessageAttachmentMetadata::isInline - ) - .containsExactlyInAnyOrder( - tuple(attachmentMetadata.getAttachmentId(), attachmentMetadata.getName(), attachmentMetadata.getCid(), attachmentMetadata.isInline()) - ); - } - - @Test - @Override - protected void messagesRetrievedUsingFetchTypeFullShouldHaveAttachmentsLoadedWhenTwoAttachments() throws MailboxException { - saveMessages(); - MessageMapper.FetchType fetchType = MessageMapper.FetchType.FULL; - Iterator retrievedMessageIterator = messageMapper.findInMailbox(attachmentsMailbox, MessageRange.one(messageWith2Attachments.getUid()), fetchType, LIMIT); - - AttachmentMetadata attachment1 = messageWith2Attachments.getAttachments().get(0).getAttachment(); - AttachmentMetadata attachment2 = messageWith2Attachments.getAttachments().get(1).getAttachment(); - MessageAttachmentMetadata attachmentMetadata1 = messageWith2Attachments.getAttachments().get(0); - MessageAttachmentMetadata attachmentMetadata2 = messageWith2Attachments.getAttachments().get(1); - List messageAttachments = retrievedMessageIterator.next().getAttachments(); - - // JPA does not support MessageId - assertThat(messageAttachments) - .extracting(MessageAttachmentMetadata::getAttachment) - .extracting("attachmentId", "size", "type") - .containsExactlyInAnyOrder( - tuple(attachment1.getAttachmentId(), attachment1.getSize(), attachment1.getType()), - tuple(attachment2.getAttachmentId(), attachment2.getSize(), attachment2.getType()) - ); - assertThat(messageAttachments) - .extracting( - MessageAttachmentMetadata::getAttachmentId, - MessageAttachmentMetadata::getName, - MessageAttachmentMetadata::getCid, - MessageAttachmentMetadata::isInline - ) - .containsExactlyInAnyOrder( - tuple(attachmentMetadata1.getAttachmentId(), attachmentMetadata1.getName(), attachmentMetadata1.getCid(), attachmentMetadata1.isInline()), - tuple(attachmentMetadata2.getAttachmentId(), attachmentMetadata2.getName(), attachmentMetadata2.getCid(), attachmentMetadata2.isInline()) - ); - } - - @Test - @Override - protected void messagesCanBeRetrievedInMailboxWithRangeTypeOne() throws MailboxException, IOException { - saveMessages(); - MessageMapper.FetchType fetchType = MessageMapper.FetchType.FULL; - - // JPA does not support MessageId - MessageAssert.assertThat(messageMapper.findInMailbox(attachmentsMailbox, MessageRange.one(messageWith1Attachment.getUid()), fetchType, LIMIT).next()) - .isEqualToWithoutAttachment(messageWith1Attachment, fetchType); - } -} From b9ad2ba17b0bc096b58726db5dcb8ad2caf7e38b Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Fri, 10 Nov 2023 10:36:43 +0100 Subject: [PATCH 044/341] JAMES-2586 Use prepared statements by default --- .../james/backends/postgres/utils/PostgresExecutor.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index 43b5efa4e10..1c92abc1974 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -27,6 +27,7 @@ import org.jooq.Record; import org.jooq.SQLDialect; import org.jooq.conf.Settings; +import org.jooq.conf.StatementType; import org.jooq.impl.DSL; import com.google.common.annotations.VisibleForTesting; @@ -39,7 +40,8 @@ public class PostgresExecutor { private static final SQLDialect PGSQL_DIALECT = SQLDialect.POSTGRES; private static final Settings SETTINGS = new Settings() - .withRenderFormatted(true); + .withRenderFormatted(true) + .withStatementType(StatementType.PREPARED_STATEMENT); private final Mono connection; @Inject From 299bead6ea7da99e92b3dfacb836adc68d013bf8 Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Fri, 10 Nov 2023 10:38:05 +0100 Subject: [PATCH 045/341] JAMES-2586 Polish code style: PostgresSubscriptionMapper --- .../mailbox/postgres/user/PostgresSubscriptionMapper.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapper.java index 1b3182a66e0..e9d06e16606 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapper.java @@ -22,7 +22,6 @@ import java.util.List; import org.apache.james.core.Username; -import org.apache.james.mailbox.exception.SubscriptionException; import org.apache.james.mailbox.store.user.SubscriptionMapper; import org.apache.james.mailbox.store.user.model.Subscription; @@ -38,17 +37,17 @@ public PostgresSubscriptionMapper(PostgresSubscriptionDAO subscriptionDAO) { } @Override - public void save(Subscription subscription) throws SubscriptionException { + public void save(Subscription subscription) { saveReactive(subscription).block(); } @Override - public List findSubscriptionsForUser(Username user) throws SubscriptionException { + public List findSubscriptionsForUser(Username user) { return findSubscriptionsForUserReactive(user).collectList().block(); } @Override - public void delete(Subscription subscription) throws SubscriptionException { + public void delete(Subscription subscription) { deleteReactive(subscription).block(); } From ee3a32072ee79db30f11952b1b417502f90d22c6 Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Fri, 10 Nov 2023 10:44:59 +0100 Subject: [PATCH 046/341] JAMES-2586 Merge PostgresSubscriptionTable and PostgresSubscriptionModule --- .../user/PostgresSubscriptionDAO.java | 6 ++-- .../user/PostgresSubscriptionModule.java | 12 ++++--- .../user/PostgresSubscriptionTable.java | 34 ------------------- 3 files changed, 11 insertions(+), 41 deletions(-) delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionTable.java diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionDAO.java index 9bce0047d08..91b4baa2fe6 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionDAO.java @@ -19,9 +19,9 @@ package org.apache.james.mailbox.postgres.user; -import static org.apache.james.mailbox.postgres.user.PostgresSubscriptionTable.MAILBOX; -import static org.apache.james.mailbox.postgres.user.PostgresSubscriptionTable.TABLE_NAME; -import static org.apache.james.mailbox.postgres.user.PostgresSubscriptionTable.USER; +import static org.apache.james.mailbox.postgres.user.PostgresSubscriptionModule.MAILBOX; +import static org.apache.james.mailbox.postgres.user.PostgresSubscriptionModule.TABLE_NAME; +import static org.apache.james.mailbox.postgres.user.PostgresSubscriptionModule.USER; import org.apache.james.backends.postgres.utils.PostgresExecutor; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java index 11ea9a2f3e4..54ce1cc49d7 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java @@ -19,16 +19,20 @@ package org.apache.james.mailbox.postgres.user; -import static org.apache.james.mailbox.postgres.user.PostgresSubscriptionTable.MAILBOX; -import static org.apache.james.mailbox.postgres.user.PostgresSubscriptionTable.TABLE_NAME; -import static org.apache.james.mailbox.postgres.user.PostgresSubscriptionTable.USER; - import org.apache.james.backends.postgres.PostgresIndex; import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Table; import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; public interface PostgresSubscriptionModule { + + Field MAILBOX = DSL.field("mailbox", SQLDataType.VARCHAR(255).notNull()); + Field USER = DSL.field("user_name", SQLDataType.VARCHAR(255).notNull()); + Table TABLE_NAME = DSL.table("subscription"); PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) .createTableStep(((dsl, tableName) -> dsl.createTable(tableName) .column(MAILBOX) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionTable.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionTable.java deleted file mode 100644 index ad703e4d268..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionTable.java +++ /dev/null @@ -1,34 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.user; - -import org.jooq.Field; -import org.jooq.Record; -import org.jooq.Table; -import org.jooq.impl.DSL; -import org.jooq.impl.SQLDataType; - -public interface PostgresSubscriptionTable { - - Field MAILBOX = DSL.field("mailbox", SQLDataType.VARCHAR(255).notNull()); - Field USER = DSL.field("user_name", SQLDataType.VARCHAR(255).notNull()); - Table TABLE_NAME = DSL.table("subscription"); - -} From 11614fc91df7857b6e893344f82b5e03836948a4 Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Fri, 10 Nov 2023 10:50:16 +0100 Subject: [PATCH 047/341] JAMES-2586 Drop Spring files for mailbox-postgres --- .../resources/META-INF/spring/mailbox-jpa.xml | 109 ------------------ 1 file changed, 109 deletions(-) delete mode 100644 mailbox/postgres/src/main/resources/META-INF/spring/mailbox-jpa.xml diff --git a/mailbox/postgres/src/main/resources/META-INF/spring/mailbox-jpa.xml b/mailbox/postgres/src/main/resources/META-INF/spring/mailbox-jpa.xml deleted file mode 100644 index 32b128c0896..00000000000 --- a/mailbox/postgres/src/main/resources/META-INF/spring/mailbox-jpa.xml +++ /dev/null @@ -1,109 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From c1928cd53d62ffe083e4a773957849f60bfbb95e Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Fri, 10 Nov 2023 10:50:48 +0100 Subject: [PATCH 048/341] JAMES-2586 Drop reporting-site.xml --- mailbox/postgres/src/reporting-site/site.xml | 29 -------------------- 1 file changed, 29 deletions(-) delete mode 100644 mailbox/postgres/src/reporting-site/site.xml diff --git a/mailbox/postgres/src/reporting-site/site.xml b/mailbox/postgres/src/reporting-site/site.xml deleted file mode 100644 index d9191644908..00000000000 --- a/mailbox/postgres/src/reporting-site/site.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - From d21fb282fe5ba64c232af5c4812627a49d75b5ab Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Fri, 10 Nov 2023 10:53:24 +0100 Subject: [PATCH 049/341] JAMES-2586 Drop unused class: EncryptDecryptHelper --- .../model/openjpa/EncryptDecryptHelper.java | 66 ------------------- 1 file changed, 66 deletions(-) delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/EncryptDecryptHelper.java diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/EncryptDecryptHelper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/EncryptDecryptHelper.java deleted file mode 100644 index ef8eb9c4039..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/EncryptDecryptHelper.java +++ /dev/null @@ -1,66 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.mailbox.postgres.mail.model.openjpa; - -import org.jasypt.encryption.pbe.StandardPBEByteEncryptor; - -/** - * Helper class for encrypt and de-crypt data - * - * - */ -public class EncryptDecryptHelper { - - // Use one static instance as it is thread safe - private static final StandardPBEByteEncryptor encryptor = new StandardPBEByteEncryptor(); - - - /** - * Set the password for encrypt / de-crypt. This MUST be done before - * the usage of {@link #getDecrypted(byte[])} and {@link #getEncrypted(byte[])}. - * - * So to be safe its the best to call this in a constructor - * - * @param pass - */ - public static void init(String pass) { - encryptor.setPassword(pass); - } - - /** - * Encrypt the given array and return the encrypted one - * - * @param array - * @return enc-array - */ - public static byte[] getEncrypted(byte[] array) { - return encryptor.encrypt(array); - } - - /** - * Decrypt the given array and return the de-crypted one - * - * @param array - * @return dec-array - */ - public static byte[] getDecrypted(byte[] array) { - return encryptor.decrypt(array); - } - -} From 9ee9e6f0f8e3056a06a0739b3590ea99a6e041fe Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Fri, 10 Nov 2023 10:56:36 +0100 Subject: [PATCH 050/341] JAMES-2586 Drop unused class: JPASubscription --- .../postgres/user/model/JPASubscription.java | 136 ------------------ .../mailbox/postgres/JPAMailboxFixture.java | 3 - .../src/test/resources/persistence.xml | 1 - .../main/resources/META-INF/persistence.xml | 1 - 4 files changed, 141 deletions(-) delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/model/JPASubscription.java diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/model/JPASubscription.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/model/JPASubscription.java deleted file mode 100644 index 3873871cfad..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/model/JPASubscription.java +++ /dev/null @@ -1,136 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.mailbox.postgres.user.model; - -import java.util.Objects; - -import javax.persistence.Basic; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.Id; -import javax.persistence.NamedQueries; -import javax.persistence.NamedQuery; -import javax.persistence.Table; -import javax.persistence.UniqueConstraint; - -import org.apache.james.core.Username; -import org.apache.james.mailbox.store.user.model.Subscription; - -/** - * A subscription to a mailbox by a user. - */ -@Entity(name = "Subscription") -@Table( - name = "JAMES_SUBSCRIPTION", - uniqueConstraints = - @UniqueConstraint( - columnNames = { - "USER_NAME", - "MAILBOX_NAME"}) -) -@NamedQueries({ - @NamedQuery(name = JPASubscription.FIND_MAILBOX_SUBSCRIPTION_FOR_USER, - query = "SELECT subscription FROM Subscription subscription WHERE subscription.username = :userParam AND subscription.mailbox = :mailboxParam"), - @NamedQuery(name = JPASubscription.FIND_SUBSCRIPTIONS_FOR_USER, - query = "SELECT subscription FROM Subscription subscription WHERE subscription.username = :userParam"), - @NamedQuery(name = JPASubscription.DELETE_SUBSCRIPTION, - query = "DELETE subscription FROM Subscription subscription WHERE subscription.username = :userParam AND subscription.mailbox = :mailboxParam") -}) -public class JPASubscription { - public static final String DELETE_SUBSCRIPTION = "deleteSubscription"; - public static final String FIND_SUBSCRIPTIONS_FOR_USER = "findSubscriptionsForUser"; - public static final String FIND_MAILBOX_SUBSCRIPTION_FOR_USER = "findFindMailboxSubscriptionForUser"; - - private static final String TO_STRING_SEPARATOR = " "; - - /** Primary key */ - @GeneratedValue - @Id - @Column(name = "SUBSCRIPTION_ID") - private long id; - - /** Name of the subscribed user */ - @Basic(optional = false) - @Column(name = "USER_NAME", nullable = false, length = 100) - private String username; - - /** Subscribed mailbox */ - @Basic(optional = false) - @Column(name = "MAILBOX_NAME", nullable = false, length = 100) - private String mailbox; - - /** - * Used by JPA - */ - @Deprecated - public JPASubscription() { - - } - - /** - * Constructs a user subscription. - */ - public JPASubscription(Subscription subscription) { - super(); - this.username = subscription.getUser().asString(); - this.mailbox = subscription.getMailbox(); - } - - public String getMailbox() { - return mailbox; - } - - public Username getUser() { - return Username.of(username); - } - - public Subscription toSubscription() { - return new Subscription(Username.of(username), mailbox); - } - - @Override - public final boolean equals(Object o) { - if (o instanceof JPASubscription) { - JPASubscription that = (JPASubscription) o; - - return Objects.equals(this.id, that.id); - } - return false; - } - - @Override - public final int hashCode() { - return Objects.hash(id); - } - - /** - * Renders output suitable for debugging. - * - * @return output suitable for debugging - */ - public String toString() { - return "Subscription ( " - + "id = " + this.id + TO_STRING_SEPARATOR - + "user = " + this.username + TO_STRING_SEPARATOR - + "mailbox = " + this.mailbox + TO_STRING_SEPARATOR - + " )"; - } - -} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxFixture.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxFixture.java index 3262f4ec7c6..c254cc88d89 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxFixture.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxFixture.java @@ -35,7 +35,6 @@ import org.apache.james.mailbox.postgres.quota.model.MaxGlobalStorage; import org.apache.james.mailbox.postgres.quota.model.MaxUserMessageCount; import org.apache.james.mailbox.postgres.quota.model.MaxUserStorage; -import org.apache.james.mailbox.postgres.user.model.JPASubscription; import com.google.common.collect.ImmutableList; @@ -48,7 +47,6 @@ public interface JPAMailboxFixture { JPAProperty.class, JPAUserFlag.class, JPAMailboxAnnotation.class, - JPASubscription.class, JPAAttachment.class ); @@ -68,7 +66,6 @@ public interface JPAMailboxFixture { "JAMES_MAILBOX_ANNOTATION", "JAMES_MAILBOX", "JAMES_MAIL", - "JAMES_SUBSCRIPTION", "JAMES_ATTACHMENT"); List QUOTA_TABLES_NAMES = ImmutableList.of( diff --git a/mailbox/postgres/src/test/resources/persistence.xml b/mailbox/postgres/src/test/resources/persistence.xml index 31f7a7a7616..83201af5261 100644 --- a/mailbox/postgres/src/test/resources/persistence.xml +++ b/mailbox/postgres/src/test/resources/persistence.xml @@ -30,7 +30,6 @@ org.apache.james.mailbox.postgres.mail.model.openjpa.JPAMailboxMessage org.apache.james.mailbox.postgres.mail.model.JPAAttachment org.apache.james.mailbox.postgres.mail.model.JPAProperty - org.apache.james.mailbox.postgres.user.model.JPASubscription org.apache.james.mailbox.postgres.quota.model.MaxDomainMessageCount org.apache.james.mailbox.postgres.quota.model.MaxDomainStorage org.apache.james.mailbox.postgres.quota.model.MaxGlobalMessageCount diff --git a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml index bd9ae808b63..d9e49513f37 100644 --- a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml +++ b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml @@ -29,7 +29,6 @@ org.apache.james.mailbox.postgres.mail.model.openjpa.AbstractJPAMailboxMessage org.apache.james.mailbox.postgres.mail.model.openjpa.JPAMailboxMessage org.apache.james.mailbox.postgres.mail.model.JPAProperty - org.apache.james.mailbox.postgres.user.model.JPASubscription org.apache.james.domainlist.jpa.model.JPADomain org.apache.james.mailrepository.jpa.model.JPAUrl From d0534386ec573f3148a996bddc46c3f42a741982 Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Fri, 10 Nov 2023 11:13:30 +0100 Subject: [PATCH 051/341] JAMES-2586 Implement (failing) tests for Row Level Security applied on Subscriptions --- ...ubscriptionMapperRowLevelSecurityTest.java | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java new file mode 100644 index 00000000000..7f6618933c2 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java @@ -0,0 +1,86 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.user; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.backends.postgres.utils.SimpleJamesPostgresConnectionFactory; +import org.apache.james.core.Username; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.MailboxSessionUtil; +import org.apache.james.mailbox.exception.SubscriptionException; +import org.apache.james.mailbox.store.user.SubscriptionMapper; +import org.apache.james.mailbox.store.user.SubscriptionMapperFactory; +import org.apache.james.mailbox.store.user.SubscriptionMapperTest; +import org.apache.james.mailbox.store.user.model.Subscription; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresSubscriptionMapperRowLevelSecurityTest { + @RegisterExtension + static PostgresExtension postgresExtension = new PostgresExtension(PostgresSubscriptionModule.MODULE, true); + + private SubscriptionMapperFactory subscriptionMapperFactory; + + @BeforeEach + public void setUp() { + subscriptionMapperFactory = session -> new PostgresSubscriptionMapper(new PostgresSubscriptionDAO(new PostgresExecutor( + new SimpleJamesPostgresConnectionFactory(postgresExtension.getConnectionFactory()) + .getConnection(session.getUser().getDomainPart())))); + } + + @Test + void subscriptionsCanBeAccessedAtTheDataLevelByMembersOfTheSameDomain() throws Exception { + Username username = Username.of("bob@domain1"); + Username username2 = Username.of("alice@domain1"); + MailboxSession session = MailboxSessionUtil.create(username); + MailboxSession session2 = MailboxSessionUtil.create(username2); + + Subscription subscription = new Subscription(username, "mailbox1"); + subscriptionMapperFactory.getSubscriptionMapper(session) + .save(subscription); + + assertThat(subscriptionMapperFactory.getSubscriptionMapper(session2) + .findSubscriptionsForUser(username)) + .containsOnly(subscription); + } + + @Disabled("Row level security for subscriptions is not implemented correctly") + @Test + void subscriptionsShouldBeIsolatedByDomain() throws Exception { + Username username = Username.of("bob@domain1"); + Username username2 = Username.of("alice@domain2"); + MailboxSession session = MailboxSessionUtil.create(username); + MailboxSession session2 = MailboxSessionUtil.create(username2); + + Subscription subscription = new Subscription(username, "mailbox1"); + subscriptionMapperFactory.getSubscriptionMapper(session) + .save(subscription); + + assertThat(subscriptionMapperFactory.getSubscriptionMapper(session2) + .findSubscriptionsForUser(username)) + .isEmpty(); + } +} From 82bba1855f3dd03954be2c100bb273d83f13f3a7 Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Fri, 10 Nov 2023 11:27:57 +0100 Subject: [PATCH 052/341] JAMES-2586 Document (link) varchar underlying maximum lengths --- .../mailbox/postgres/user/PostgresSubscriptionModule.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java index 54ce1cc49d7..3f07843eabb 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java @@ -29,8 +29,13 @@ import org.jooq.impl.SQLDataType; public interface PostgresSubscriptionModule { - + /** + * See {@link MailboxManager.MAX_MAILBOX_NAME_LENGTH} + */ Field MAILBOX = DSL.field("mailbox", SQLDataType.VARCHAR(255).notNull()); + /** + * See {@link Username.MAXIMUM_MAIL_ADDRESS_LENGTH} + */ Field USER = DSL.field("user_name", SQLDataType.VARCHAR(255).notNull()); Table TABLE_NAME = DSL.table("subscription"); PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) From 80b016be10d2ed77b25b9266808879cc6ff67784 Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Fri, 10 Nov 2023 13:40:58 +0100 Subject: [PATCH 053/341] JAMES-2586 PostgresExtension: favor factory methods to constructor --- .../postgres/ConnectionThreadSafetyTest.java | 2 +- .../backends/postgres/PostgresExtension.java | 28 ++++++++++--------- .../postgres/PostgresExtensionTest.java | 2 +- .../postgres/PostgresTableManagerTest.java | 2 +- ...pleJamesPostgresConnectionFactoryTest.java | 2 +- .../postgres/JPAMailboxManagerTest.java | 2 +- .../postgres/JpaMailboxManagerStressTest.java | 2 +- .../PostgresSubscriptionManagerTest.java | 2 +- .../JPARecomputeCurrentQuotasServiceTest.java | 2 +- ...ubscriptionMapperRowLevelSecurityTest.java | 2 +- .../user/PostgresSubscriptionMapperTest.java | 2 +- .../james/JamesCapabilitiesServerTest.java | 2 +- .../apache/james/PostgresJamesServerTest.java | 2 +- ...uthenticatedDatabaseSqlValidationTest.java | 2 +- ...seAuthenticaticationSqlValidationTest.java | 2 +- .../PostgresWithLDAPJamesServerTest.java | 2 +- 16 files changed, 30 insertions(+), 28 deletions(-) diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/ConnectionThreadSafetyTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/ConnectionThreadSafetyTest.java index 20eedcee4dc..80b927a5a24 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/ConnectionThreadSafetyTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/ConnectionThreadSafetyTest.java @@ -57,7 +57,7 @@ public class ConnectionThreadSafetyTest { ");"; @RegisterExtension - static PostgresExtension postgresExtension = new PostgresExtension(); + static PostgresExtension postgresExtension = PostgresExtension.empty(); private static PostgresqlConnection postgresqlConnection; private static SimpleJamesPostgresConnectionFactory jamesPostgresConnectionFactory; diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index 086080b84f8..682fc496963 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -36,29 +36,31 @@ import reactor.core.publisher.Mono; public class PostgresExtension implements GuiceModuleTestExtension { + private static final boolean ROW_LEVEL_SECURITY_ENABLED = true; + + public static PostgresExtension withRowLevelSecurity(PostgresModule module) { + return new PostgresExtension(module, ROW_LEVEL_SECURITY_ENABLED); + } + + public static PostgresExtension withoutRowLevelSecurity(PostgresModule module) { + return new PostgresExtension(module, !ROW_LEVEL_SECURITY_ENABLED); + } + + public static PostgresExtension empty() { + return withoutRowLevelSecurity(PostgresModule.EMPTY_MODULE); + } + private final PostgresModule postgresModule; private final boolean rlsEnabled; private PostgresConfiguration postgresConfiguration; private PostgresExecutor postgresExecutor; private PostgresqlConnectionFactory connectionFactory; - public PostgresExtension(PostgresModule postgresModule, boolean rlsEnabled) { + private PostgresExtension(PostgresModule postgresModule, boolean rlsEnabled) { this.postgresModule = postgresModule; this.rlsEnabled = rlsEnabled; } - public PostgresExtension(PostgresModule postgresModule) { - this(postgresModule, false); - } - - public PostgresExtension(boolean rlsEnabled) { - this(PostgresModule.EMPTY_MODULE, rlsEnabled); - } - - public PostgresExtension() { - this(false); - } - @Override public void beforeAll(ExtensionContext extensionContext) throws Exception { if (!DockerPostgresSingleton.SINGLETON.isRunning()) { diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtensionTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtensionTest.java index f1593fef215..406ba4b6bce 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtensionTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtensionTest.java @@ -59,7 +59,7 @@ class PostgresExtensionTest { .build(); @RegisterExtension - static PostgresExtension postgresExtension = new PostgresExtension(POSTGRES_MODULE); + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(POSTGRES_MODULE); @Test void postgresExtensionShouldProvisionTablesAndIndexes() { diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java index 2805c629306..c7d98cb915f 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java @@ -39,7 +39,7 @@ class PostgresTableManagerTest { @RegisterExtension - static PostgresExtension postgresExtension = new PostgresExtension(); + static PostgresExtension postgresExtension = PostgresExtension.empty(); Function tableManagerFactory = module -> new PostgresTableManager(new PostgresExecutor(postgresExtension.getConnection()), module, true); diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/SimpleJamesPostgresConnectionFactoryTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/SimpleJamesPostgresConnectionFactoryTest.java index 962599473f7..cfe457db342 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/SimpleJamesPostgresConnectionFactoryTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/SimpleJamesPostgresConnectionFactoryTest.java @@ -46,7 +46,7 @@ public class SimpleJamesPostgresConnectionFactoryTest extends JamesPostgresConnectionFactoryTest { @RegisterExtension - static PostgresExtension postgresExtension = new PostgresExtension(); + static PostgresExtension postgresExtension = PostgresExtension.empty(); private PostgresqlConnection postgresqlConnection; private SimpleJamesPostgresConnectionFactory jamesPostgresConnectionFactory; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxManagerTest.java index b6f2db22439..53871f68f86 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxManagerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxManagerTest.java @@ -43,7 +43,7 @@ class HookTests { } @RegisterExtension - static PostgresExtension postgresExtension = new PostgresExtension(PostgresSubscriptionModule.MODULE); + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresSubscriptionModule.MODULE); static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); Optional openJPAMailboxManager = Optional.empty(); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerStressTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerStressTest.java index 8f2b2b8e528..2abd96da331 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerStressTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerStressTest.java @@ -34,7 +34,7 @@ class JpaMailboxManagerStressTest implements MailboxManagerStressContract { @RegisterExtension - static PostgresExtension postgresExtension = new PostgresExtension(PostgresSubscriptionModule.MODULE); + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresSubscriptionModule.MODULE); static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); Optional openJPAMailboxManager = Optional.empty(); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresSubscriptionManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresSubscriptionManagerTest.java index 83b9074aa9a..39343b7b1ac 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresSubscriptionManagerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresSubscriptionManagerTest.java @@ -42,7 +42,7 @@ class PostgresSubscriptionManagerTest implements SubscriptionManagerContract { @RegisterExtension - static PostgresExtension postgresExtension = new PostgresExtension(PostgresSubscriptionModule.MODULE); + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresSubscriptionModule.MODULE); static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/JPARecomputeCurrentQuotasServiceTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/JPARecomputeCurrentQuotasServiceTest.java index 41032b719e4..10a76bae46a 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/JPARecomputeCurrentQuotasServiceTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/JPARecomputeCurrentQuotasServiceTest.java @@ -58,7 +58,7 @@ class JPARecomputeCurrentQuotasServiceTest implements RecomputeCurrentQuotasServiceContract { @RegisterExtension - static PostgresExtension postgresExtension = new PostgresExtension(PostgresSubscriptionModule.MODULE); + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresSubscriptionModule.MODULE); static final DomainList NO_DOMAIN_LIST = null; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java index 7f6618933c2..9505cd473e9 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java @@ -40,7 +40,7 @@ public class PostgresSubscriptionMapperRowLevelSecurityTest { @RegisterExtension - static PostgresExtension postgresExtension = new PostgresExtension(PostgresSubscriptionModule.MODULE, true); + static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresSubscriptionModule.MODULE); private SubscriptionMapperFactory subscriptionMapperFactory; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperTest.java index ac693c9c99d..5d05795398f 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperTest.java @@ -30,7 +30,7 @@ public class PostgresSubscriptionMapperTest extends SubscriptionMapperTest { @RegisterExtension - static PostgresExtension postgresExtension = new PostgresExtension(PostgresSubscriptionModule.MODULE); + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresSubscriptionModule.MODULE); @Override protected SubscriptionMapper createSubscriptionMapper() { diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java index f73e46c3378..16568dc9004 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java @@ -51,7 +51,7 @@ private static MailboxManager mailboxManager() { .server(configuration -> PostgresJamesServerMain.createServer(configuration) .overrideWith(new TestJPAConfigurationModule()) .overrideWith(binder -> binder.bind(MailboxManager.class).toInstance(mailboxManager()))) - .extension(new PostgresExtension()) + .extension(PostgresExtension.empty()) .build(); @Test diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java index a17e8560f76..52654ba7b60 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java @@ -50,7 +50,7 @@ class PostgresJamesServerTest implements JamesServerConcreteContract { .build()) .server(configuration -> PostgresJamesServerMain.createServer(configuration) .overrideWith(new TestJPAConfigurationModule())) - .extension(new PostgresExtension()) + .extension(PostgresExtension.empty()) .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) .build(); diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest.java index 2f005078e09..55fd090c497 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest.java @@ -35,7 +35,7 @@ class PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest extends Post .build()) .server(configuration -> PostgresJamesServerMain.createServer(configuration) .overrideWith(new TestJPAConfigurationModuleWithSqlValidation.WithDatabaseAuthentication())) - .extension(new PostgresExtension()) + .extension(PostgresExtension.empty()) .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) .build(); } diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java index 19fb866d24a..44f9620748f 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java @@ -34,7 +34,7 @@ class PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest exten .build()) .server(configuration -> PostgresJamesServerMain.createServer(configuration) .overrideWith(new TestJPAConfigurationModuleWithSqlValidation.NoDatabaseAuthentication())) - .extension(new PostgresExtension()) + .extension(PostgresExtension.empty()) .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) .build(); } diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java index 66e4b6fb887..6bc0e02a95d 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java @@ -45,7 +45,7 @@ class PostgresWithLDAPJamesServerTest { .overrideWith(new TestJPAConfigurationModule())) .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) .extension(new LdapTestExtension()) - .extension(new PostgresExtension()) + .extension(PostgresExtension.empty()) .build(); From a4bfed2e488aaa795d1ff8f049ab80fdb74ba709 Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Fri, 10 Nov 2023 19:46:40 +0100 Subject: [PATCH 054/341] JAMES-2586 Small codestyle refactorings --- .../postgres/PostgresConfiguration.java | 62 +++++++++---------- .../backends/postgres/PostgresTable.java | 24 ++++--- .../postgres/PostgresTableManager.java | 51 ++++++++------- .../postgres/PostgresConfigurationTest.java | 6 +- .../backends/postgres/PostgresExtension.java | 4 +- .../postgres/PostgresExtensionTest.java | 4 +- .../postgres/PostgresTableManagerTest.java | 24 +++---- .../user/PostgresSubscriptionModule.java | 2 +- 8 files changed, 90 insertions(+), 87 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java index 73dbf36211c..7ffeb8be400 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java @@ -61,7 +61,7 @@ public static class Builder { private Optional url = Optional.empty(); private Optional databaseName = Optional.empty(); private Optional databaseSchema = Optional.empty(); - private Optional rlsEnabled = Optional.empty(); + private Optional rowLevelSecurityEnabled = Optional.empty(); public Builder url(String url) { this.url = Optional.of(url); @@ -88,13 +88,13 @@ public Builder databaseSchema(Optional databaseSchema) { return this; } - public Builder rlsEnabled(boolean rlsEnabled) { - this.rlsEnabled = Optional.of(rlsEnabled); + public Builder rowLevelSecurityEnabled(boolean rlsEnabled) { + this.rowLevelSecurityEnabled = Optional.of(rlsEnabled); return this; } - public Builder rlsEnabled() { - this.rlsEnabled = Optional.of(true); + public Builder rowLevelSecurityEnabled() { + this.rowLevelSecurityEnabled = Optional.of(true); return this; } @@ -106,7 +106,7 @@ public PostgresConfiguration build() { parseCredential(postgresURI), databaseName.orElse(DATABASE_NAME_DEFAULT_VALUE), databaseSchema.orElse(DATABASE_SCHEMA_DEFAULT_VALUE), - rlsEnabled.orElse(false)); + rowLevelSecurityEnabled.orElse(false)); } private Credential parseCredential(URI postgresURI) { @@ -140,7 +140,7 @@ public static PostgresConfiguration from(Configuration propertiesConfiguration) .url(propertiesConfiguration.getString(URL, null)) .databaseName(Optional.ofNullable(propertiesConfiguration.getString(DATABASE_NAME))) .databaseSchema(Optional.ofNullable(propertiesConfiguration.getString(DATABASE_SCHEMA))) - .rlsEnabled(propertiesConfiguration.getBoolean(RLS_ENABLED, false)) + .rowLevelSecurityEnabled(propertiesConfiguration.getBoolean(RLS_ENABLED, false)) .build(); } @@ -148,33 +148,14 @@ public static PostgresConfiguration from(Configuration propertiesConfiguration) private final Credential credential; private final String databaseName; private final String databaseSchema; - private final boolean rlsEnabled; + private final boolean rowLevelSecurityEnabled; - private PostgresConfiguration(URI uri, Credential credential, String databaseName, String databaseSchema, boolean rlsEnabled) { + private PostgresConfiguration(URI uri, Credential credential, String databaseName, String databaseSchema, boolean rowLevelSecurityEnabled) { this.uri = uri; this.credential = credential; this.databaseName = databaseName; this.databaseSchema = databaseSchema; - this.rlsEnabled = rlsEnabled; - } - - @Override - public final boolean equals(Object o) { - if (o instanceof PostgresConfiguration) { - PostgresConfiguration that = (PostgresConfiguration) o; - - return Objects.equals(this.rlsEnabled, that.rlsEnabled) - && Objects.equals(this.uri, that.uri) - && Objects.equals(this.credential, that.credential) - && Objects.equals(this.databaseName, that.databaseName) - && Objects.equals(this.databaseSchema, that.databaseSchema); - } - return false; - } - - @Override - public final int hashCode() { - return Objects.hash(uri, credential, databaseName, databaseSchema, rlsEnabled); + this.rowLevelSecurityEnabled = rowLevelSecurityEnabled; } public URI getUri() { @@ -193,7 +174,26 @@ public String getDatabaseSchema() { return databaseSchema; } - public boolean rlsEnabled() { - return rlsEnabled; + public boolean rowLevelSecurityEnabled() { + return rowLevelSecurityEnabled; + } + + @Override + public final boolean equals(Object o) { + if (o instanceof PostgresConfiguration) { + PostgresConfiguration that = (PostgresConfiguration) o; + + return Objects.equals(this.rowLevelSecurityEnabled, that.rowLevelSecurityEnabled) + && Objects.equals(this.uri, that.uri) + && Objects.equals(this.credential, that.credential) + && Objects.equals(this.databaseName, that.databaseName) + && Objects.equals(this.databaseSchema, that.databaseSchema); + } + return false; + } + + @Override + public final int hashCode() { + return Objects.hash(uri, credential, databaseName, databaseSchema, rowLevelSecurityEnabled); } } diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java index 1956d3c5e8f..933a7810df5 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java @@ -27,13 +27,11 @@ import com.google.common.base.Preconditions; public class PostgresTable { - @FunctionalInterface public interface RequireCreateTableStep { RequireRowLevelSecurity createTableStep(CreateTableFunction createTableFunction); } - @FunctionalInterface public interface CreateTableFunction { DDLQuery createTable(DSLContext dsl, String tableName); @@ -41,30 +39,30 @@ public interface CreateTableFunction { @FunctionalInterface public interface RequireRowLevelSecurity { - PostgresTable enableRowLevelSecurity(boolean enableRowLevelSecurity); + PostgresTable supportsRowLevelSecurity(boolean rowLevelSecurityEnabled); - default PostgresTable noRLS() { - return enableRowLevelSecurity(false); + default PostgresTable disableRowLevelSecurity() { + return supportsRowLevelSecurity(false); } - default PostgresTable enableRowLevelSecurity() { - return enableRowLevelSecurity(true); + default PostgresTable supportsRowLevelSecurity() { + return supportsRowLevelSecurity(true); } } public static RequireCreateTableStep name(String tableName) { Preconditions.checkNotNull(tableName); - return createTableFunction -> enableRowLevelSecurity -> new PostgresTable(tableName, enableRowLevelSecurity, dsl -> createTableFunction.createTable(dsl, tableName)); + return createTableFunction -> supportsRowLevelSecurity -> new PostgresTable(tableName, supportsRowLevelSecurity, dsl -> createTableFunction.createTable(dsl, tableName)); } private final String name; - private final boolean enableRowLevelSecurity; + private final boolean supportsRowLevelSecurity; private final Function createTableStepFunction; - private PostgresTable(String name, boolean enableRowLevelSecurity, Function createTableStepFunction) { + private PostgresTable(String name, boolean supportsRowLevelSecurity, Function createTableStepFunction) { this.name = name; - this.enableRowLevelSecurity = enableRowLevelSecurity; + this.supportsRowLevelSecurity = supportsRowLevelSecurity; this.createTableStepFunction = createTableStepFunction; } @@ -77,7 +75,7 @@ public Function getCreateTableStepFunction() { return createTableStepFunction; } - public boolean isEnableRowLevelSecurity() { - return enableRowLevelSecurity; + public boolean supportsRowLevelSecurity() { + return supportsRowLevelSecurity; } } diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java index 78eb5170f6d..eaa4aa79948 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java @@ -40,7 +40,7 @@ public class PostgresTableManager implements Startable { private static final Logger LOGGER = LoggerFactory.getLogger(PostgresTableManager.class); private final PostgresExecutor postgresExecutor; private final PostgresModule module; - private final boolean rlsEnabled; + private final boolean rowLevelSecurityEnabled; @Inject public PostgresTableManager(JamesPostgresConnectionFactory postgresConnectionFactory, @@ -48,34 +48,36 @@ public PostgresTableManager(JamesPostgresConnectionFactory postgresConnectionFac PostgresConfiguration postgresConfiguration) { this.postgresExecutor = new PostgresExecutor(postgresConnectionFactory.getConnection(Optional.empty())); this.module = module; - this.rlsEnabled = postgresConfiguration.rlsEnabled(); + this.rowLevelSecurityEnabled = postgresConfiguration.rowLevelSecurityEnabled(); } @VisibleForTesting - public PostgresTableManager(PostgresExecutor postgresExecutor, PostgresModule module, boolean rlsEnabled) { + public PostgresTableManager(PostgresExecutor postgresExecutor, PostgresModule module, boolean rowLevelSecurityEnabled) { this.postgresExecutor = postgresExecutor; this.module = module; - this.rlsEnabled = rlsEnabled; + this.rowLevelSecurityEnabled = rowLevelSecurityEnabled; } public Mono initializeTables() { return postgresExecutor.dslContext() .flatMap(dsl -> Flux.fromIterable(module.tables()) .flatMap(table -> Mono.from(table.getCreateTableStepFunction().apply(dsl)) - .then(alterTableEnableRLSIfNeed(table)) + .then(alterTableIfNeeded(table)) .doOnSuccess(any -> LOGGER.info("Table {} created", table.getName())) - .onErrorResume(DataAccessException.class, exception -> { - if (exception.getMessage().contains(String.format("\"%s\" already exists", table.getName()))) { - return Mono.empty(); - } - return Mono.error(exception); - }) - .doOnError(e -> LOGGER.error("Error while creating table {}", table.getName(), e))) + .onErrorResume(exception -> handleTableCreationException(table, exception))) .then()); } - private Mono alterTableEnableRLSIfNeed(PostgresTable table) { - if (rlsEnabled && table.isEnableRowLevelSecurity()) { + private Mono handleTableCreationException(PostgresTable table, Throwable e) { + if (e instanceof DataAccessException && e.getMessage().contains(String.format("\"%s\" already exists", table.getName()))) { + return Mono.empty(); + } + LOGGER.error("Error while creating table {}", table.getName(), e); + return Mono.error(e); + } + + private Mono alterTableIfNeeded(PostgresTable table) { + if (rowLevelSecurityEnabled && table.supportsRowLevelSecurity()) { return alterTableEnableRLS(table); } return Mono.empty(); @@ -83,12 +85,13 @@ private Mono alterTableEnableRLSIfNeed(PostgresTable table) { public Mono alterTableEnableRLS(PostgresTable table) { return postgresExecutor.connection() - .flatMapMany(con -> con.createStatement(getAlterRLSStatement(table.getName())).execute()) + .flatMapMany(connection -> connection.createStatement(rowLevelSecurityAlterStatement(table.getName())) + .execute()) .flatMap(Result::getRowsUpdated) .then(); } - private String getAlterRLSStatement(String tableName) { + private String rowLevelSecurityAlterStatement(String tableName) { return "SET app.current_domain = ''; ALTER TABLE " + tableName + " ADD DOMAIN varchar(255) not null DEFAULT current_setting('app.current_domain')::text;" + "ALTER TABLE " + tableName + " ENABLE ROW LEVEL SECURITY; " + "CREATE POLICY DOMAIN_" + tableName + "_POLICY ON " + tableName + " USING (DOMAIN = current_setting('app.current_domain')::text);"; @@ -108,13 +111,15 @@ public Mono initializeTableIndexes() { .flatMap(dsl -> Flux.fromIterable(module.tableIndexes()) .concatMap(index -> Mono.from(index.getCreateIndexStepFunction().apply(dsl)) .doOnSuccess(any -> LOGGER.info("Index {} created", index.getName())) - .onErrorResume(DataAccessException.class, exception -> { - if (exception.getMessage().contains(String.format("\"%s\" already exists", index.getName()))) { - return Mono.empty(); - } - return Mono.error(exception); - }) - .doOnError(e -> LOGGER.error("Error while creating index {}", index.getName(), e))) + .onErrorResume(e -> handleIndexCreationException(index, e))) .then()); } + + private Mono handleIndexCreationException(PostgresIndex index, Throwable e) { + if (e instanceof DataAccessException && e.getMessage().contains(String.format("\"%s\" already exists", index.getName()))) { + return Mono.empty(); + } + LOGGER.error("Error while creating index {}", index.getName(), e); + return Mono.error(e); + } } diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java index b324ec527af..248eb0dd662 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java @@ -71,7 +71,7 @@ void rowLevelSecurityShouldBeDisabledByDefault() { .url("postgresql://username:password@postgreshost:5672") .build(); - assertThat(configuration.rlsEnabled()).isFalse(); + assertThat(configuration.rowLevelSecurityEnabled()).isFalse(); } @Test @@ -96,13 +96,13 @@ void databaseSchemaShouldFallbackToDefaultWhenNotSet() { void shouldReturnCorrespondingProperties() { PostgresConfiguration configuration = PostgresConfiguration.builder() .url("postgresql://username:password@postgreshost:5672") - .rlsEnabled() + .rowLevelSecurityEnabled() .databaseName("databaseName") .databaseSchema("databaseSchema") .build(); SoftAssertions.assertSoftly(softly -> { - softly.assertThat(configuration.rlsEnabled()).isEqualTo(true); + softly.assertThat(configuration.rowLevelSecurityEnabled()).isEqualTo(true); softly.assertThat(configuration.getDatabaseName()).isEqualTo("databaseName"); softly.assertThat(configuration.getDatabaseSchema()).isEqualTo("databaseSchema"); }); diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index 682fc496963..a1ae9b9abab 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -80,7 +80,7 @@ private void initPostgresSession() throws URISyntaxException { .toString()) .databaseName(PostgresFixture.Database.DB_NAME) .databaseSchema(PostgresFixture.Database.SCHEMA) - .rlsEnabled(rlsEnabled) + .rowLevelSecurityEnabled(rlsEnabled) .build(); connectionFactory = new PostgresqlConnectionFactory(PostgresqlConnectionConfiguration.builder() @@ -149,7 +149,7 @@ public ConnectionFactory getConnectionFactory() { } private void initTablesAndIndexes() { - PostgresTableManager postgresTableManager = new PostgresTableManager(postgresExecutor, postgresModule, postgresConfiguration.rlsEnabled()); + PostgresTableManager postgresTableManager = new PostgresTableManager(postgresExecutor, postgresModule, postgresConfiguration.rowLevelSecurityEnabled()); postgresTableManager.initializeTables().block(); postgresTableManager.initializeTableIndexes().block(); } diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtensionTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtensionTest.java index 406ba4b6bce..ca3a641eadc 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtensionTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtensionTest.java @@ -38,7 +38,7 @@ class PostgresExtensionTest { .column("column1", SQLDataType.UUID.notNull()) .column("column2", SQLDataType.INTEGER) .column("column3", SQLDataType.VARCHAR(255).notNull())) - .noRLS(); + .disableRowLevelSecurity(); static PostgresIndex INDEX_1 = PostgresIndex.name("index1") .createIndexStep((dslContext, indexName) -> dslContext.createIndex(indexName) @@ -47,7 +47,7 @@ class PostgresExtensionTest { static PostgresTable TABLE_2 = PostgresTable.name("table2") .createTableStep((dslContext, tableName) -> dslContext.createTable(tableName) .column("column1", SQLDataType.INTEGER)) - .noRLS(); + .disableRowLevelSecurity(); static PostgresIndex INDEX_2 = PostgresIndex.name("index2") .createIndexStep((dslContext, indexName) -> dslContext.createIndex(indexName) diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java index c7d98cb915f..ac5d73c2d7a 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java @@ -53,7 +53,7 @@ void initializeTableShouldSuccessWhenModuleHasSingleTable() { .column("colum1", SQLDataType.UUID.notNull()) .column("colum2", SQLDataType.INTEGER) .column("colum3", SQLDataType.VARCHAR(255).notNull())) - .noRLS(); + .disableRowLevelSecurity(); PostgresModule module = PostgresModule.table(table); @@ -75,12 +75,12 @@ void initializeTableShouldSuccessWhenModuleHasMultiTables() { PostgresTable table1 = PostgresTable.name(tableName1) .createTableStep((dsl, tbn) -> dsl.createTable(tbn) - .column("columA", SQLDataType.UUID.notNull())).noRLS(); + .column("columA", SQLDataType.UUID.notNull())).disableRowLevelSecurity(); String tableName2 = "tableName2"; PostgresTable table2 = PostgresTable.name(tableName2) .createTableStep((dsl, tbn) -> dsl.createTable(tbn) - .column("columB", SQLDataType.INTEGER)).noRLS(); + .column("columB", SQLDataType.INTEGER)).disableRowLevelSecurity(); PostgresTableManager testee = tableManagerFactory.apply(PostgresModule.table(table1, table2)); @@ -101,7 +101,7 @@ void initializeTableShouldNotThrowWhenTableExists() { PostgresTable table1 = PostgresTable.name(tableName1) .createTableStep((dsl, tbn) -> dsl.createTable(tbn) - .column("columA", SQLDataType.UUID.notNull())).noRLS(); + .column("columA", SQLDataType.UUID.notNull())).disableRowLevelSecurity(); PostgresTableManager testee = tableManagerFactory.apply(PostgresModule.table(table1)); @@ -117,7 +117,7 @@ void initializeTableShouldNotChangeTableStructureOfExistTable() { String tableName1 = "tableName1"; PostgresTable table1 = PostgresTable.name(tableName1) .createTableStep((dsl, tbn) -> dsl.createTable(tbn) - .column("columA", SQLDataType.UUID.notNull())).noRLS(); + .column("columA", SQLDataType.UUID.notNull())).disableRowLevelSecurity(); tableManagerFactory.apply(PostgresModule.table(table1)) .initializeTables() @@ -125,7 +125,7 @@ void initializeTableShouldNotChangeTableStructureOfExistTable() { PostgresTable table1Changed = PostgresTable.name(tableName1) .createTableStep((dsl, tbn) -> dsl.createTable(tbn) - .column("columB", SQLDataType.INTEGER)).noRLS(); + .column("columB", SQLDataType.INTEGER)).disableRowLevelSecurity(); tableManagerFactory.apply(PostgresModule.table(table1Changed)) .initializeTables() @@ -145,7 +145,7 @@ void initializeIndexShouldSuccessWhenModuleHasSingleIndex() { .column("colum1", SQLDataType.UUID.notNull()) .column("colum2", SQLDataType.INTEGER) .column("colum3", SQLDataType.VARCHAR(255).notNull())) - .noRLS(); + .disableRowLevelSecurity(); String indexName = "idx_test_1"; PostgresIndex index = PostgresIndex.name(indexName) @@ -178,7 +178,7 @@ void initializeIndexShouldSuccessWhenModuleHasMultiIndexes() { .column("colum1", SQLDataType.UUID.notNull()) .column("colum2", SQLDataType.INTEGER) .column("colum3", SQLDataType.VARCHAR(255).notNull())) - .noRLS(); + .disableRowLevelSecurity(); String indexName1 = "idx_test_1"; PostgresIndex index1 = PostgresIndex.name(indexName1) @@ -216,7 +216,7 @@ void initializeIndexShouldNotThrowWhenIndexExists() { .column("colum1", SQLDataType.UUID.notNull()) .column("colum2", SQLDataType.INTEGER) .column("colum3", SQLDataType.VARCHAR(255).notNull())) - .noRLS(); + .disableRowLevelSecurity(); String indexName = "idx_test_1"; PostgresIndex index = PostgresIndex.name(indexName) @@ -244,7 +244,7 @@ void truncateShouldEmptyTableData() { String tableName1 = "tbn1"; PostgresTable table1 = PostgresTable.name(tableName1) .createTableStep((dsl, tbn) -> dsl.createTable(tbn) - .column("column1", SQLDataType.INTEGER.notNull())).noRLS(); + .column("column1", SQLDataType.INTEGER.notNull())).disableRowLevelSecurity(); PostgresTableManager testee = tableManagerFactory.apply(PostgresModule.table(table1)); testee.initializeTables() @@ -286,7 +286,7 @@ void createTableShouldCreateRlsColumnWhenEnableRLS() { .createTableStep((dsl, tbn) -> dsl.createTable(tbn) .column("clm1", SQLDataType.UUID.notNull()) .column("clm2", SQLDataType.VARCHAR(255).notNull())) - .enableRowLevelSecurity(); + .supportsRowLevelSecurity(); PostgresModule module = PostgresModule.table(table); @@ -326,7 +326,7 @@ void createTableShouldNotCreateRlsColumnWhenDisableRLS() { .createTableStep((dsl, tbn) -> dsl.createTable(tbn) .column("clm1", SQLDataType.UUID.notNull()) .column("clm2", SQLDataType.VARCHAR(255).notNull())) - .enableRowLevelSecurity(); + .supportsRowLevelSecurity(); PostgresModule module = PostgresModule.table(table); boolean disabledRLS = false; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java index 3f07843eabb..68c8eca1d0c 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java @@ -43,7 +43,7 @@ public interface PostgresSubscriptionModule { .column(MAILBOX) .column(USER) .constraint(DSL.unique(MAILBOX, USER)))) - .enableRowLevelSecurity(); + .supportsRowLevelSecurity(); PostgresIndex INDEX = PostgresIndex.name("subscription_user_index") .createIndexStep((dsl, indexName) -> dsl.createIndex(indexName) .on(TABLE_NAME, USER)); From b0233133cbf0bc9ea432d9e715ebfa42fe39f818 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 13 Nov 2023 11:43:50 +0700 Subject: [PATCH 055/341] JAMES-2586 Fix row-level security implementation --- backends-common/postgres/pom.xml | 7 -- .../postgres/PostgresTableManager.java | 1 + .../postgres/DockerPostgresSingleton.java | 2 +- .../backends/postgres/PostgresExtension.java | 44 +++++++++--- .../backends/postgres/PostgresFixture.java | 70 ++++++++++++++++--- ...pleJamesPostgresConnectionFactoryTest.java | 3 +- ...ubscriptionMapperRowLevelSecurityTest.java | 6 -- .../user/PostgresSubscriptionMapperTest.java | 5 +- 8 files changed, 99 insertions(+), 39 deletions(-) diff --git a/backends-common/postgres/pom.xml b/backends-common/postgres/pom.xml index 499f3b42a71..2e87eb59ead 100644 --- a/backends-common/postgres/pom.xml +++ b/backends-common/postgres/pom.xml @@ -29,7 +29,6 @@ Apache James :: Backends Common :: Postgres - 42.5.1 3.16.22 1.0.2.RELEASE @@ -71,12 +70,6 @@ jooq ${jooq.version} - - org.postgresql - postgresql - ${postgresql.driver.version} - test - org.postgresql r2dbc-postgresql diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java index eaa4aa79948..c7b2ff1bf71 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java @@ -94,6 +94,7 @@ public Mono alterTableEnableRLS(PostgresTable table) { private String rowLevelSecurityAlterStatement(String tableName) { return "SET app.current_domain = ''; ALTER TABLE " + tableName + " ADD DOMAIN varchar(255) not null DEFAULT current_setting('app.current_domain')::text;" + "ALTER TABLE " + tableName + " ENABLE ROW LEVEL SECURITY; " + + "ALTER TABLE " + tableName + " FORCE ROW LEVEL SECURITY; " + "CREATE POLICY DOMAIN_" + tableName + "_POLICY ON " + tableName + " USING (DOMAIN = current_setting('app.current_domain')::text);"; } diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/DockerPostgresSingleton.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/DockerPostgresSingleton.java index 21046eb72f0..d51fa296752 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/DockerPostgresSingleton.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/DockerPostgresSingleton.java @@ -30,7 +30,7 @@ private static void displayDockerLog(OutputFrame outputFrame) { } private static final Logger LOGGER = LoggerFactory.getLogger(DockerPostgresSingleton.class); - public static final PostgreSQLContainer SINGLETON = PostgresFixture.PG_CONTAINER.get() + public static final PostgreSQLContainer SINGLETON = PostgresFixture.PG_CONTAINER.get() .withLogConsumer(DockerPostgresSingleton::displayDockerLog); static { diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index a1ae9b9abab..d6f65b6f7ab 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -19,13 +19,18 @@ package org.apache.james.backends.postgres; +import static org.apache.james.backends.postgres.PostgresFixture.Database.DEFAULT_DATABASE; +import static org.apache.james.backends.postgres.PostgresFixture.Database.ROW_LEVEL_SECURITY_DATABASE; + import java.net.URISyntaxException; import org.apache.http.client.utils.URIBuilder; import org.apache.james.GuiceModuleTestExtension; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.junit.jupiter.api.extension.ExtensionContext; +import org.testcontainers.containers.PostgreSQLContainer; +import com.github.fge.lambdas.Throwing; import com.google.inject.Module; import com.google.inject.util.Modules; @@ -50,8 +55,10 @@ public static PostgresExtension empty() { return withoutRowLevelSecurity(PostgresModule.EMPTY_MODULE); } + public static PostgreSQLContainer PG_CONTAINER = DockerPostgresSingleton.SINGLETON; private final PostgresModule postgresModule; private final boolean rlsEnabled; + private final PostgresFixture.Database selectedDatabase; private PostgresConfiguration postgresConfiguration; private PostgresExecutor postgresExecutor; private PostgresqlConnectionFactory connectionFactory; @@ -59,27 +66,42 @@ public static PostgresExtension empty() { private PostgresExtension(PostgresModule postgresModule, boolean rlsEnabled) { this.postgresModule = postgresModule; this.rlsEnabled = rlsEnabled; + if (rlsEnabled) { + this.selectedDatabase = PostgresFixture.Database.ROW_LEVEL_SECURITY_DATABASE; + } else { + this.selectedDatabase = DEFAULT_DATABASE; + } } @Override public void beforeAll(ExtensionContext extensionContext) throws Exception { - if (!DockerPostgresSingleton.SINGLETON.isRunning()) { - DockerPostgresSingleton.SINGLETON.start(); + if (!PG_CONTAINER.isRunning()) { + PG_CONTAINER.start(); } + querySettingRowLevelSecurityIfNeed(); initPostgresSession(); } + private void querySettingRowLevelSecurityIfNeed() { + Throwing.runnable(() -> { + PG_CONTAINER.execInContainer("psql", "-U", DEFAULT_DATABASE.dbUser(), "-c", "create user " + ROW_LEVEL_SECURITY_DATABASE.dbUser() + " WITH PASSWORD '" + ROW_LEVEL_SECURITY_DATABASE.dbPassword() + "';"); + PG_CONTAINER.execInContainer("psql", "-U", DEFAULT_DATABASE.dbUser(), "-c", "create database " + ROW_LEVEL_SECURITY_DATABASE.dbName() + ";"); + PG_CONTAINER.execInContainer("psql", "-U", DEFAULT_DATABASE.dbUser(), "-c", "grant all privileges on database " + ROW_LEVEL_SECURITY_DATABASE.dbName() + " to " + ROW_LEVEL_SECURITY_DATABASE.dbUser() + ";"); + PG_CONTAINER.execInContainer("psql", "-U", ROW_LEVEL_SECURITY_DATABASE.dbUser(), "-d", ROW_LEVEL_SECURITY_DATABASE.dbName(), "-c", "create schema if not exists " + ROW_LEVEL_SECURITY_DATABASE.schema() + ";"); + }).sneakyThrow().run(); + } + private void initPostgresSession() throws URISyntaxException { postgresConfiguration = PostgresConfiguration.builder() .url(new URIBuilder() .setScheme("postgresql") .setHost(getHost()) .setPort(getMappedPort()) - .setUserInfo(PostgresFixture.Database.DB_USER, PostgresFixture.Database.DB_PASSWORD) + .setUserInfo(selectedDatabase.dbUser(), selectedDatabase.dbPassword()) .build() .toString()) - .databaseName(PostgresFixture.Database.DB_NAME) - .databaseSchema(PostgresFixture.Database.SCHEMA) + .databaseName(selectedDatabase.dbName()) + .databaseSchema(selectedDatabase.schema()) .rowLevelSecurityEnabled(rlsEnabled) .build(); @@ -117,8 +139,8 @@ public void afterEach(ExtensionContext extensionContext) { } public void restartContainer() throws URISyntaxException { - DockerPostgresSingleton.SINGLETON.stop(); - DockerPostgresSingleton.SINGLETON.start(); + PG_CONTAINER.stop(); + PG_CONTAINER.start(); initPostgresSession(); } @@ -129,11 +151,11 @@ public Module getModule() { } public String getHost() { - return DockerPostgresSingleton.SINGLETON.getHost(); + return PG_CONTAINER.getHost(); } public Integer getMappedPort() { - return DockerPostgresSingleton.SINGLETON.getMappedPort(PostgresFixture.PORT); + return PG_CONTAINER.getMappedPort(PostgresFixture.PORT); } public Mono getConnection() { @@ -156,8 +178,8 @@ private void initTablesAndIndexes() { private void resetSchema() { getConnection() - .flatMapMany(connection -> Mono.from(connection.createStatement("DROP SCHEMA " + PostgresFixture.Database.SCHEMA + " CASCADE").execute()) - .then(Mono.from(connection.createStatement("CREATE SCHEMA " + PostgresFixture.Database.SCHEMA).execute())) + .flatMapMany(connection -> Mono.from(connection.createStatement("DROP SCHEMA " + selectedDatabase.schema() + " CASCADE").execute()) + .then(Mono.from(connection.createStatement("CREATE SCHEMA " + selectedDatabase.schema() + " AUTHORIZATION " + selectedDatabase.dbUser()).execute())) .flatMap(result -> Mono.from(result.getRowsUpdated()))) .collectList() .block(); diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresFixture.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresFixture.java index 813e9d73a3e..6c003f7ad9b 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresFixture.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresFixture.java @@ -19,6 +19,7 @@ package org.apache.james.backends.postgres; +import static org.apache.james.backends.postgres.PostgresFixture.Database.DEFAULT_DATABASE; import static org.testcontainers.containers.PostgreSQLContainer.POSTGRESQL_PORT; import java.util.UUID; @@ -29,18 +30,69 @@ public interface PostgresFixture { interface Database { - String DB_USER = "james"; - String DB_PASSWORD = "secret1"; - String DB_NAME = "james"; - String SCHEMA = "public"; + + Database DEFAULT_DATABASE = new DefaultDatabase(); + Database ROW_LEVEL_SECURITY_DATABASE = new RowLevelSecurityDatabase(); + + String dbUser(); + + String dbPassword(); + + String dbName(); + + String schema(); + + + class DefaultDatabase implements Database { + @Override + public String dbUser() { + return "james"; + } + + @Override + public String dbPassword() { + return "secret1"; + } + + @Override + public String dbName() { + return "james"; + } + + @Override + public String schema() { + return "public"; + } + } + + class RowLevelSecurityDatabase implements Database { + @Override + public String dbUser() { + return "rlsuser"; + } + + @Override + public String dbPassword() { + return "secret1"; + } + + @Override + public String dbName() { + return "rlsdb"; + } + + @Override + public String schema() { + return "rlsschema"; + } + } } - String IMAGE = "postgres:16.0"; + String IMAGE = "postgres:16"; Integer PORT = POSTGRESQL_PORT; - Supplier> PG_CONTAINER = () -> new PostgreSQLContainer<>(IMAGE) - .withDatabaseName(Database.DB_NAME) - .withUsername(Database.DB_USER) - .withPassword(Database.DB_PASSWORD) + .withDatabaseName(DEFAULT_DATABASE.dbName()) + .withUsername(DEFAULT_DATABASE.dbUser()) + .withPassword(DEFAULT_DATABASE.dbPassword()) .withCreateContainerCmdModifier(cmd -> cmd.withName("james-postgres-test-" + UUID.randomUUID())); } diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/SimpleJamesPostgresConnectionFactoryTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/SimpleJamesPostgresConnectionFactoryTest.java index cfe457db342..10d7b8f84e1 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/SimpleJamesPostgresConnectionFactoryTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/SimpleJamesPostgresConnectionFactoryTest.java @@ -19,6 +19,7 @@ package org.apache.james.backends.postgres; +import static org.apache.james.backends.postgres.PostgresFixture.Database.DEFAULT_DATABASE; import static org.assertj.core.api.Assertions.assertThat; import java.net.URISyntaxException; @@ -84,7 +85,7 @@ void factoryShouldCreateCorrectNumberOfConnections() { @Nullable private Integer getNumberOfConnections() { return Mono.from(postgresqlConnection.createStatement("SELECT count(*) from pg_stat_activity where usename = $1;") - .bind("$1", PostgresFixture.Database.DB_USER) + .bind("$1", DEFAULT_DATABASE.dbUser()) .execute()).flatMap(result -> Mono.from(result.map((row, rowMetadata) -> row.get(0, Integer.class)))).block(); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java index 9505cd473e9..69dc70eddb0 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java @@ -22,19 +22,14 @@ import static org.assertj.core.api.Assertions.assertThat; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.backends.postgres.utils.SimpleJamesPostgresConnectionFactory; import org.apache.james.core.Username; import org.apache.james.mailbox.MailboxSession; import org.apache.james.mailbox.MailboxSessionUtil; -import org.apache.james.mailbox.exception.SubscriptionException; -import org.apache.james.mailbox.store.user.SubscriptionMapper; import org.apache.james.mailbox.store.user.SubscriptionMapperFactory; -import org.apache.james.mailbox.store.user.SubscriptionMapperTest; import org.apache.james.mailbox.store.user.model.Subscription; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -67,7 +62,6 @@ void subscriptionsCanBeAccessedAtTheDataLevelByMembersOfTheSameDomain() throws E .containsOnly(subscription); } - @Disabled("Row level security for subscriptions is not implemented correctly") @Test void subscriptionsShouldBeIsolatedByDomain() throws Exception { Username username = Username.of("bob@domain1"); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperTest.java index 5d05795398f..ebd4c626e27 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperTest.java @@ -20,9 +20,6 @@ package org.apache.james.mailbox.postgres.user; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.mailbox.postgres.user.PostgresSubscriptionDAO; -import org.apache.james.mailbox.postgres.user.PostgresSubscriptionMapper; -import org.apache.james.mailbox.postgres.user.PostgresSubscriptionModule; import org.apache.james.mailbox.store.user.SubscriptionMapper; import org.apache.james.mailbox.store.user.SubscriptionMapperTest; import org.junit.jupiter.api.extension.RegisterExtension; @@ -30,7 +27,7 @@ public class PostgresSubscriptionMapperTest extends SubscriptionMapperTest { @RegisterExtension - static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresSubscriptionModule.MODULE); + static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresSubscriptionModule.MODULE); @Override protected SubscriptionMapper createSubscriptionMapper() { From c0b22cee04757a11cacc33acdee2b13cf8b439f7 Mon Sep 17 00:00:00 2001 From: hungphan227 <45198168+hungphan227@users.noreply.github.com> Date: Mon, 13 Nov 2023 20:26:36 +0700 Subject: [PATCH 056/341] JAMES-2586 implement dao for mailbox table (#1786) --- .../postgres/utils/PostgresExecutor.java | 11 ++ .../mailbox/postgres/PostgresMailboxId.java | 86 +++++++++++ .../postgres/mail/PostgresMailboxModule.java | 61 ++++++++ .../postgres/mail/dao/PostgresMailboxDAO.java | 143 ++++++++++++++++++ 4 files changed, 301 insertions(+) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxId.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index 1c92abc1974..30f2812a8ad 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -19,6 +19,7 @@ package org.apache.james.backends.postgres.utils; +import java.util.List; import java.util.function.Function; import javax.inject.Inject; @@ -64,6 +65,16 @@ public Flux executeRows(Function> queryFunction .flatMapMany(queryFunction); } + public Mono> executeSingleRowList(Function>> queryFunction) { + return dslContext() + .flatMap(queryFunction); + } + + public Mono executeRow(Function> queryFunction) { + return dslContext() + .flatMap(queryFunction); + } + public Mono connection() { return connection; } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxId.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxId.java new file mode 100644 index 00000000000..52111dd4cb6 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxId.java @@ -0,0 +1,86 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.mailbox.postgres; + +import java.io.Serializable; +import java.util.Objects; +import java.util.UUID; + +import org.apache.james.mailbox.model.MailboxId; + +import com.google.common.base.MoreObjects; + +public class PostgresMailboxId implements MailboxId, Serializable { + + public static class Factory implements MailboxId.Factory { + @Override + public PostgresMailboxId fromString(String serialized) { + return of(serialized); + } + } + + private final UUID id; + + public static PostgresMailboxId generate() { + return of(UUID.randomUUID()); + } + + public static PostgresMailboxId of(UUID id) { + return new PostgresMailboxId(id); + } + + public static PostgresMailboxId of(String serialized) { + return new PostgresMailboxId(UUID.fromString(serialized)); + } + + private PostgresMailboxId(UUID id) { + this.id = id; + } + + @Override + public String serialize() { + return id.toString(); + } + + public UUID asUuid() { + return id; + } + + @Override + public final boolean equals(Object o) { + if (o instanceof PostgresMailboxId) { + PostgresMailboxId other = (PostgresMailboxId) o; + return Objects.equals(id, other.id); + } + return false; + } + + @Override + public final int hashCode() { + return Objects.hash(id); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("id", id) + .toString(); + } + +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java new file mode 100644 index 00000000000..6ed11a0c569 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java @@ -0,0 +1,61 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import java.util.UUID; + +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresMailboxModule { + interface PostgresMailboxTable { + Table TABLE_NAME = DSL.table("mailbox"); + + Field MAILBOX_ID = DSL.field("mailbox_id", SQLDataType.UUID.notNull()); + Field MAILBOX_NAME = DSL.field("mailbox_name", SQLDataType.VARCHAR(255).notNull()); + Field MAILBOX_UID_VALIDITY = DSL.field("mailbox_uid_validity", SQLDataType.BIGINT.notNull()); + Field USER_NAME = DSL.field("user_name", SQLDataType.VARCHAR(255)); + Field MAILBOX_NAMESPACE = DSL.field("mailbox_namespace", SQLDataType.VARCHAR(255).notNull()); + Field MAILBOX_LAST_UID = DSL.field("mailbox_last_uid", SQLDataType.BIGINT); + Field MAILBOX_HIGHEST_MODSEQ = DSL.field("mailbox_highest_modseq", SQLDataType.BIGINT); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTable(tableName) + .column(MAILBOX_ID, SQLDataType.UUID) + .column(MAILBOX_NAME) + .column(MAILBOX_UID_VALIDITY) + .column(USER_NAME) + .column(MAILBOX_NAMESPACE) + .column(MAILBOX_LAST_UID) + .column(MAILBOX_HIGHEST_MODSEQ) + .constraint(DSL.primaryKey(MAILBOX_ID)) + .constraint(DSL.unique(MAILBOX_NAME, USER_NAME, MAILBOX_NAMESPACE)))) + .supportsRowLevelSecurity(); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(PostgresMailboxTable.TABLE) + .build(); +} \ No newline at end of file diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java new file mode 100644 index 00000000000..7e6d592bfb4 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java @@ -0,0 +1,143 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail.dao; + +import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_ID; +import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_NAME; +import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_NAMESPACE; +import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_UID_VALIDITY; +import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.TABLE_NAME; +import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.USER_NAME; +import static org.jooq.impl.DSL.count; + +import javax.inject.Inject; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.core.Username; +import org.apache.james.mailbox.exception.MailboxExistsException; +import org.apache.james.mailbox.exception.MailboxNotFoundException; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.UidValidity; +import org.apache.james.mailbox.model.search.MailboxQuery; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.store.MailboxExpressionBackwardCompatibility; +import org.jooq.Record; +import org.jooq.exception.DataAccessException; + +import com.google.common.base.Preconditions; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresMailboxDAO { + private static final char SQL_WILDCARD_CHAR = '%'; + private static final String DUPLICATE_VIOLATION_MESSAGE = "duplicate key value violates unique constraint"; + + private final PostgresExecutor postgresExecutor; + + @Inject + public PostgresMailboxDAO(PostgresExecutor postgresExecutor) { + this.postgresExecutor = postgresExecutor; + } + + public Mono create(MailboxPath mailboxPath, UidValidity uidValidity) { + final PostgresMailboxId mailboxId = PostgresMailboxId.generate(); + + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME, MAILBOX_ID, MAILBOX_NAME, USER_NAME, MAILBOX_NAMESPACE, MAILBOX_UID_VALIDITY) + .values(mailboxId.asUuid(), mailboxPath.getName(), mailboxPath.getUser().asString(), mailboxPath.getNamespace(), uidValidity.asLong()))) + .thenReturn(new Mailbox(mailboxPath, uidValidity, mailboxId)) + .onErrorMap(e -> e instanceof DataAccessException && e.getMessage().contains(DUPLICATE_VIOLATION_MESSAGE), + e -> new MailboxExistsException(mailboxPath.getName())); + } + + public Mono rename(Mailbox mailbox) { + Preconditions.checkNotNull(mailbox.getMailboxId(), "A mailbox we want to rename should have a defined mailboxId"); + + return findMailboxByPath(mailbox.generateAssociatedPath()) + .flatMap(m -> Mono.error(new MailboxExistsException(mailbox.getName()))) + .then(update(mailbox)); + } + + private Mono update(Mailbox mailbox) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.update(TABLE_NAME) + .set(MAILBOX_NAME, mailbox.getName()) + .set(USER_NAME, mailbox.getUser().asString()) + .set(MAILBOX_NAMESPACE, mailbox.getNamespace()) + .where(MAILBOX_ID.eq(((PostgresMailboxId) mailbox.getMailboxId()).asUuid())) + .returning(MAILBOX_ID))) + .map(record -> mailbox.getMailboxId()) + .switchIfEmpty(Mono.error(new MailboxNotFoundException(mailbox.getMailboxId()))); + } + + public Mono delete(MailboxId mailboxId) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(TABLE_NAME) + .where(MAILBOX_ID.eq(((PostgresMailboxId) mailboxId).asUuid())))); + } + + public Mono findMailboxByPath(MailboxPath mailboxPath) { + return postgresExecutor.executeRow(dsl -> Mono.from(dsl.selectFrom(TABLE_NAME) + .where(MAILBOX_NAME.eq(mailboxPath.getName()) + .and(USER_NAME.eq(mailboxPath.getUser().asString())) + .and(MAILBOX_NAMESPACE.eq(mailboxPath.getNamespace()))))) + .map(this::asMailbox); + } + + public Mono findMailboxById(MailboxId id) { + return postgresExecutor.executeRow(dsl -> Mono.from(dsl.selectFrom(TABLE_NAME) + .where(MAILBOX_ID.eq(((PostgresMailboxId) id).asUuid())))) + .map(this::asMailbox) + .switchIfEmpty(Mono.error(new MailboxNotFoundException(id))); + } + + public Flux findMailboxWithPathLike(MailboxQuery.UserBound query) { + String pathLike = MailboxExpressionBackwardCompatibility.getPathLike(query); + + return postgresExecutor.executeRows(dsl -> Flux.from(dsl.selectFrom(TABLE_NAME) + .where(MAILBOX_NAME.like(pathLike) + .and(USER_NAME.eq(query.getFixedUser().asString())) + .and(MAILBOX_NAMESPACE.eq(query.getFixedNamespace()))))) + .map(this::asMailbox) + .filter(query::matches); + } + + public Mono hasChildren(Mailbox mailbox, char delimiter) { + String name = mailbox.getName() + delimiter + SQL_WILDCARD_CHAR; + + return postgresExecutor.executeRows(dsl -> Flux.from(dsl.select(count()).from(TABLE_NAME) + .where(MAILBOX_NAME.like(name) + .and(USER_NAME.eq(mailbox.getUser().asString())) + .and(MAILBOX_NAMESPACE.eq(mailbox.getNamespace()))))) + .map(record -> record.get(0, Integer.class)) + .filter(count -> count > 0) + .hasElements(); + } + + public Flux getAll() { + return postgresExecutor.executeRows(dsl -> Flux.from(dsl.selectFrom(TABLE_NAME))) + .map(this::asMailbox); + } + + private Mailbox asMailbox(Record record) { + return new Mailbox(new MailboxPath(record.get(MAILBOX_NAMESPACE), Username.of(record.get(USER_NAME)), record.get(MAILBOX_NAME)), + UidValidity.of(record.get(MAILBOX_UID_VALIDITY)), PostgresMailboxId.of(record.get(MAILBOX_ID))); + } +} From 86ad6e928561fedb80915ca8b950ccee8df36768 Mon Sep 17 00:00:00 2001 From: hungphan227 <45198168+hungphan227@users.noreply.github.com> Date: Tue, 14 Nov 2023 14:05:49 +0700 Subject: [PATCH 057/341] JAMES-2586 implement postgres mailbox mapper (#1791) --- .../postgres/mail/PostgresMailboxMapper.java | 104 ++++++++++++++++++ ...gresMailboxMapperRowLevelSecurityTest.java | 85 ++++++++++++++ .../mail/PostgresMailboxMapperTest.java | 43 ++++++++ .../store/mail/model/MailboxMapperTest.java | 15 +++ 4 files changed, 247 insertions(+) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperRowLevelSecurityTest.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperTest.java diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java new file mode 100644 index 00000000000..787ca65cedc --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java @@ -0,0 +1,104 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import javax.inject.Inject; +import javax.naming.OperationNotSupportedException; + +import org.apache.james.core.Username; +import org.apache.james.mailbox.acl.ACLDiff; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxACL; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.UidValidity; +import org.apache.james.mailbox.model.search.MailboxQuery; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; +import org.apache.james.mailbox.store.mail.MailboxMapper; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresMailboxMapper implements MailboxMapper { + private final PostgresMailboxDAO postgresMailboxDAO; + + @Inject + public PostgresMailboxMapper(PostgresMailboxDAO postgresMailboxDAO) { + this.postgresMailboxDAO = postgresMailboxDAO; + } + + @Override + public Mono create(MailboxPath mailboxPath, UidValidity uidValidity) { + return postgresMailboxDAO.create(mailboxPath,uidValidity); + } + + @Override + public Mono rename(Mailbox mailbox) { + return postgresMailboxDAO.rename(mailbox); + } + + @Override + public Mono delete(Mailbox mailbox) { + return postgresMailboxDAO.delete(mailbox.getMailboxId()); + } + + @Override + public Mono findMailboxByPath(MailboxPath mailboxName) { + return postgresMailboxDAO.findMailboxByPath(mailboxName); + } + + @Override + public Mono findMailboxById(MailboxId mailboxId) { + return postgresMailboxDAO.findMailboxById(mailboxId); + } + + @Override + public Flux findMailboxWithPathLike(MailboxQuery.UserBound query) { + return postgresMailboxDAO.findMailboxWithPathLike(query); + } + + @Override + public Mono hasChildren(Mailbox mailbox, char delimiter) { + return postgresMailboxDAO.hasChildren(mailbox, delimiter); + } + + @Override + public Flux list() { + return postgresMailboxDAO.getAll(); + } + + @Override + public Flux findNonPersonalMailboxes(Username userName, MailboxACL.Right right) { + // TODO + return Flux.error(new OperationNotSupportedException()); + } + + @Override + public Mono updateACL(Mailbox mailbox, MailboxACL.ACLCommand mailboxACLCommand) { + // TODO + return Mono.error(new OperationNotSupportedException()); + } + + @Override + public Mono setACL(Mailbox mailbox, MailboxACL mailboxACL) { + // TODO + return Mono.error(new OperationNotSupportedException()); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperRowLevelSecurityTest.java new file mode 100644 index 00000000000..2233e24b651 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperRowLevelSecurityTest.java @@ -0,0 +1,85 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.backends.postgres.utils.SimpleJamesPostgresConnectionFactory; +import org.apache.james.core.Username; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.MailboxSessionUtil; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.UidValidity; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; +import org.apache.james.mailbox.store.mail.MailboxMapperFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresMailboxMapperRowLevelSecurityTest { + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresMailboxModule.MODULE); + + private MailboxMapperFactory mailboxMapperFactory; + + @BeforeEach + public void setUp() { + mailboxMapperFactory = session -> new PostgresMailboxMapper(new PostgresMailboxDAO(new PostgresExecutor( + new SimpleJamesPostgresConnectionFactory(postgresExtension.getConnectionFactory()) + .getConnection(session.getUser().getDomainPart())))); + } + + @Test + void mailboxesCanBeAccessedAtTheDataLevelByMembersOfTheSameDomain() throws Exception { + Username username = Username.of("alice@domain1"); + Username username2 = Username.of("bob@domain1"); + + MailboxSession session = MailboxSessionUtil.create(username); + MailboxSession session2 = MailboxSessionUtil.create(username2); + + mailboxMapperFactory.getMailboxMapper(session) + .create(MailboxPath.forUser(username, "INBOX"), UidValidity.of(1L)) + .block(); + + assertThat(mailboxMapperFactory.getMailboxMapper(session2) + .findMailboxByPath(MailboxPath.forUser(username, "INBOX")).block()) + .isNotNull(); + } + + @Test + void mailboxesShouldBeIsolatedByDomain() throws Exception { + Username username = Username.of("alice@domain1"); + Username username2 = Username.of("bob@domain2"); + + MailboxSession session = MailboxSessionUtil.create(username); + MailboxSession session2 = MailboxSessionUtil.create(username2); + + mailboxMapperFactory.getMailboxMapper(session) + .create(MailboxPath.forUser(username, "INBOX"), UidValidity.of(1L)) + .block(); + + assertThat(mailboxMapperFactory.getMailboxMapper(session2) + .findMailboxByPath(MailboxPath.forUser(username, "INBOX")).block()) + .isNull(); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperTest.java new file mode 100644 index 00000000000..3b134b5bb9a --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperTest.java @@ -0,0 +1,43 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; +import org.apache.james.mailbox.store.mail.MailboxMapper; +import org.apache.james.mailbox.store.mail.model.MailboxMapperTest; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresMailboxMapperTest extends MailboxMapperTest { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxModule.MODULE); + + @Override + protected MailboxMapper createMailboxMapper() { + return new PostgresMailboxMapper(new PostgresMailboxDAO(postgresExtension.getPostgresExecutor())); + } + + @Override + protected MailboxId generateId() { + return PostgresMailboxId.generate(); + } +} diff --git a/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MailboxMapperTest.java b/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MailboxMapperTest.java index a5a13d10367..efdb019d2a1 100644 --- a/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MailboxMapperTest.java +++ b/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MailboxMapperTest.java @@ -152,6 +152,21 @@ void renameShouldRemoveOldMailboxPath() { .isEmpty(); } + @Test + void renameShouldUpdateOnlyOneMailbox() { + MailboxId aliceMailboxId = mailboxMapper.create(benwaInboxPath, UidValidity.of(1L)).block().getMailboxId(); + MailboxId bobMailboxId = mailboxMapper.create(bobInboxPath, UidValidity.of(2L)).block().getMailboxId(); + + MailboxPath newMailboxPath = new MailboxPath(benwaInboxPath.getNamespace(), benwaInboxPath.getUser(), "ENBOX"); + mailboxMapper.rename(new Mailbox(newMailboxPath, UidValidity.of(1L), aliceMailboxId)).block(); + + Mailbox actualAliceMailbox = mailboxMapper.findMailboxById(aliceMailboxId).block(); + Mailbox actualBobMailbox = mailboxMapper.findMailboxById(bobMailboxId).block(); + + assertThat(actualAliceMailbox.getName()).isEqualTo("ENBOX"); + assertThat(actualBobMailbox.getName()).isEqualTo(bobInboxPath.getName()); + } + @Test void listShouldRetrieveAllMailbox() { createAll(); From 63cc13487baaa0b136b1fa563cc3904038156e32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20H=E1=BB=93ng=20Qu=C3=A2n?= <55171818+quantranhong1999@users.noreply.github.com> Date: Tue, 14 Nov 2023 15:02:40 +0700 Subject: [PATCH 058/341] JAMES-2586 Postgres app performance test materials (#1794) --- server/apps/postgres-app/docker-compose.yml | 9 +++++ .../provisioning.properties | 25 +++++++++++++ server/apps/postgres-app/performance-test.md | 11 ++++++ server/apps/postgres-app/provision.sh | 35 +++++++++++++++++++ .../sample-configuration/imapserver.xml | 2 +- 5 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 server/apps/postgres-app/imap-provision-conf/provisioning.properties create mode 100644 server/apps/postgres-app/performance-test.md create mode 100755 server/apps/postgres-app/provision.sh diff --git a/server/apps/postgres-app/docker-compose.yml b/server/apps/postgres-app/docker-compose.yml index c1c3124dc2a..2edf3cd44f3 100644 --- a/server/apps/postgres-app/docker-compose.yml +++ b/server/apps/postgres-app/docker-compose.yml @@ -18,6 +18,15 @@ services: - $PWD/postgresql-42.5.4.jar:/root/libs/postgresql-42.5.4.jar - $PWD/sample-configuration/james-database-postgres.properties:/root/conf/james-database.properties - $PWD/src/test/resources/keystore:/root/conf/keystore + ports: + - "80:80" + - "25:25" + - "110:110" + - "143:143" + - "465:465" + - "587:587" + - "993:993" + - "8000:8000" postgres: image: postgres:16.0 diff --git a/server/apps/postgres-app/imap-provision-conf/provisioning.properties b/server/apps/postgres-app/imap-provision-conf/provisioning.properties new file mode 100644 index 00000000000..e2f27130e8c --- /dev/null +++ b/server/apps/postgres-app/imap-provision-conf/provisioning.properties @@ -0,0 +1,25 @@ +# IMAP (S) URL of the James server. Certificates are blindly trusted +url=imaps://localhost:993 + +# Count of mailboxes to create per user +mailbox.count=4 +# Count of messages to create per folder +message.per.folder.count=5 +# Count of messages to create in INBOX +message.inbox.count=5 + +# Count of threads of the IMAP client +thread.count=8 +# Concurrent count of users to provision simultaneously +concurrent.user.count=10 +# Connections to use per user +connection.per.user.count=2 +# Read timeout of IMAP connections. +read.timeout.ms=180000 +# Connect timeout +connect.timeout.ms=30000 + +# Count of users to offset (ignore) in the provisioning. +users.offset=0 +# Count of users to provision +# users.limit=100 \ No newline at end of file diff --git a/server/apps/postgres-app/performance-test.md b/server/apps/postgres-app/performance-test.md new file mode 100644 index 00000000000..07fea625032 --- /dev/null +++ b/server/apps/postgres-app/performance-test.md @@ -0,0 +1,11 @@ +# Performance test Postgres app + +To provision and benchmark an IMAP server backed by PostgreSQL, please have a look at following steps: +1. Build and extract the Postgres app docker image. + - `mvn clean install -DskipTests -Dmaven.skip.doc=true` + - `docker load -i ./target/jib-image.tar` +2. Run the Postgres app: `docker compose up` +3. Provision users and IMAP mailboxes + messages: `./provision.sh` +4. Performance test IMAP server using [james-gatling](https://github.com/linagora/james-gatling) + + Sample IMAP simulation: `gatling:testOnly org.apache.james.gatling.simulation.imap.PlatformValidationSimulation`. \ No newline at end of file diff --git a/server/apps/postgres-app/provision.sh b/server/apps/postgres-app/provision.sh new file mode 100755 index 00000000000..6bc86840298 --- /dev/null +++ b/server/apps/postgres-app/provision.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +export WEBADMIN_BASE_URL="http://localhost:8000" +export DOMAIN_NAME="domain.org" +export USERS_COUNT=1000 + +echo "Start provisioning users." + +# Remove old users.csv file +rm ./imap-provision-conf/users.csv + +# Create domain +curl -X PUT ${WEBADMIN_BASE_URL}/domains/${DOMAIN_NAME} + +for i in $(seq 1 $USERS_COUNT) +do + # Create user + echo "Creating user $i" + username=user${i}@$DOMAIN_NAME + curl -XPUT ${WEBADMIN_BASE_URL}/users/$username \ + -d '{"password":"secret"}' \ + -H "Content-Type: application/json" + + # Append user to users.csv + echo -e "$username,secret" >> ./imap-provision-conf/users.csv +done + +echo "Finished provisioning users." + +# Provisioning IMAP mailboxes and messages. +echo "Start provisioning IMAP mailboxes and messages..." +docker run --rm -it --name james-provisioning --network host -v ./imap-provision-conf/provisioning.properties:/conf/provisioning.properties \ +-v ./imap-provision-conf/users.csv:/conf/users.csv linagora/james-provisioning:latest +echo "Finished provisioning IMAP mailboxes and messages." + diff --git a/server/apps/postgres-app/sample-configuration/imapserver.xml b/server/apps/postgres-app/sample-configuration/imapserver.xml index 0d38de0d734..12991c48dc1 100644 --- a/server/apps/postgres-app/sample-configuration/imapserver.xml +++ b/server/apps/postgres-app/sample-configuration/imapserver.xml @@ -47,7 +47,7 @@ under the License. 120 SECONDS true - true + false true From 92de77e3051ce90972ec71f87ac438e415660fdb Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Fri, 10 Nov 2023 10:38:47 +0700 Subject: [PATCH 059/341] JAMES-2586 Introduce apache-james-mpt-imapmailbox-postgres - copy mpt-imapmailbox-jpa into a mpt-imapmailbox-pg module --- Jenkinsfile | 3 +- mpt/impl/imap-mailbox/pom.xml | 7 + mpt/impl/imap-mailbox/postgres/pom.xml | 119 ++++++++++++ .../postgres/src/reporting-site/site.xml | 28 +++ .../PostgresAuthenticatePlainTest.java | 35 ++++ .../PostgresAuthenticatedStateTest.java | 35 ++++ .../PostgresConcurrentSessionsTest.java | 47 +++++ .../postgres/PostgresCondstoreTest.java | 35 ++++ .../postgres/PostgresCopyTest.java | 39 ++++ .../postgres/PostgresEventsTest.java | 35 ++++ .../postgres/PostgresExpungeTest.java | 35 ++++ .../PostgresFetchBodySectionTest.java | 35 ++++ .../PostgresFetchBodyStructureTest.java | 35 ++++ .../postgres/PostgresFetchHeadersTest.java | 35 ++++ .../postgres/PostgresFetchTest.java | 43 +++++ .../postgres/PostgresListingTest.java | 35 ++++ .../PostgresMailboxAnnotationTest.java | 35 ++++ .../PostgresMailboxWithLongNameErrorTest.java | 35 ++++ .../postgres/PostgresMoveTest.java | 35 ++++ .../PostgresNonAuthenticatedStateTest.java | 35 ++++ .../postgres/PostgresPartialFetchTest.java | 35 ++++ .../postgres/PostgresQuotaTest.java | 35 ++++ .../postgres/PostgresRenameTest.java | 35 ++++ .../postgres/PostgresSearchTest.java | 35 ++++ .../postgres/PostgresSecurityTest.java | 35 ++++ .../postgres/PostgresSelectTest.java | 35 ++++ .../postgres/PostgresSelectedInboxTest.java | 35 ++++ .../postgres/PostgresSelectedStateTest.java | 65 +++++++ .../PostgresUidSearchOnIndexTest.java | 35 ++++ .../postgres/PostgresUidSearchTest.java | 35 ++++ .../PostgresUserFlagsSupportTest.java | 35 ++++ .../postgres/host/PostgresHostSystem.java | 177 ++++++++++++++++++ .../host/PostgresHostSystemExtension.java | 52 +++++ 33 files changed, 1384 insertions(+), 1 deletion(-) create mode 100644 mpt/impl/imap-mailbox/postgres/pom.xml create mode 100644 mpt/impl/imap-mailbox/postgres/src/reporting-site/site.xml create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresAuthenticatePlainTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresAuthenticatedStateTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresConcurrentSessionsTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresCondstoreTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresCopyTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresEventsTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresExpungeTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchBodySectionTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchBodyStructureTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchHeadersTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresListingTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxAnnotationTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxWithLongNameErrorTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMoveTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresNonAuthenticatedStateTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresPartialFetchTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresQuotaTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresRenameTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSearchTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSecurityTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSelectTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSelectedInboxTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSelectedStateTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresUidSearchOnIndexTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresUidSearchTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresUserFlagsSupportTest.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java create mode 100644 mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystemExtension.java diff --git a/Jenkinsfile b/Jenkinsfile index 45686501d11..850f502afd0 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -44,7 +44,8 @@ pipeline { 'server/data/data-postgres,' + 'server/container/guice/postgres-common,' + 'server/container/guice/mailbox-postgres,' + - 'server/apps/postgres-app' + 'server/apps/postgres-app,' + + 'mpt/impl/imap-mailbox/postgres' } tools { diff --git a/mpt/impl/imap-mailbox/pom.xml b/mpt/impl/imap-mailbox/pom.xml index df6453cbc99..e6b9dc7948d 100644 --- a/mpt/impl/imap-mailbox/pom.xml +++ b/mpt/impl/imap-mailbox/pom.xml @@ -41,6 +41,7 @@ jpa lucenesearch opensearch + postgres rabbitmq @@ -88,6 +89,12 @@ ${project.version} test + + ${james.groupId} + apache-james-mpt-imapmailbox-postgres + ${project.version} + test + diff --git a/mpt/impl/imap-mailbox/postgres/pom.xml b/mpt/impl/imap-mailbox/postgres/pom.xml new file mode 100644 index 00000000000..7c129744ee9 --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/pom.xml @@ -0,0 +1,119 @@ + + + + 4.0.0 + + org.apache.james + apache-james-mpt-imapmailbox + 3.9.0-SNAPSHOT + + + apache-james-mpt-imapmailbox-postgres + Apache James :: MPT :: Imap Mailbox :: Postgres + + + + ${james.groupId} + apache-james-backends-jpa + test-jar + test + + + ${james.groupId} + apache-james-backends-postgres + test-jar + test + + + ${james.groupId} + apache-james-mailbox-api + test-jar + test + + + ${james.groupId} + apache-james-mailbox-postgres + test + + + ${james.groupId} + apache-james-mailbox-postgres + test-jar + test + + + ${james.groupId} + apache-james-mailbox-store + test + + + ${james.groupId} + apache-james-mpt-imapmailbox-core + + + ${james.groupId} + event-bus-api + test-jar + test + + + ${james.groupId} + event-bus-in-vm + test + + + ${james.groupId} + james-server-testing + test + + + ${james.groupId} + metrics-tests + test + + + ${james.groupId} + testing-base + test + + + org.apache.derby + derby + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + 1C + -Djava.library.path= + -javaagent:"${settings.localRepository}"/org/jacoco/org.jacoco.agent/${jacoco-maven-plugin.version}/org.jacoco.agent-${jacoco-maven-plugin.version}-runtime.jar=destfile=${basedir}/target/jacoco.exec + -Xms512m -Xmx1024m -Dopenjpa.Multithreaded=true + + + + + + diff --git a/mpt/impl/imap-mailbox/postgres/src/reporting-site/site.xml b/mpt/impl/imap-mailbox/postgres/src/reporting-site/site.xml new file mode 100644 index 00000000000..f8423071619 --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/reporting-site/site.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresAuthenticatePlainTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresAuthenticatePlainTest.java new file mode 100644 index 00000000000..b5ba6e804b4 --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresAuthenticatePlainTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.AuthenticatePlain; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresAuthenticatePlainTest extends AuthenticatePlain { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresAuthenticatedStateTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresAuthenticatedStateTest.java new file mode 100644 index 00000000000..765a27af314 --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresAuthenticatedStateTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.AuthenticatedState; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresAuthenticatedStateTest extends AuthenticatedState { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresConcurrentSessionsTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresConcurrentSessionsTest.java new file mode 100644 index 00000000000..4f39cbbb957 --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresConcurrentSessionsTest.java @@ -0,0 +1,47 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.ConcurrentSessions; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresConcurrentSessionsTest extends ConcurrentSessions { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } + + @Override + public void testConcurrentFetchResponseITALY() { + } + + @Override + public void testConcurrentFetchResponseKOREA() { + } + + @Override + public void testConcurrentFetchResponseUS() { + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresCondstoreTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresCondstoreTest.java new file mode 100644 index 00000000000..0cb1eedcdc1 --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresCondstoreTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.host.JamesImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.Condstore; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresCondstoreTest extends Condstore { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected JamesImapHostSystem createJamesImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresCopyTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresCopyTest.java new file mode 100644 index 00000000000..f6bfeffbc93 --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresCopyTest.java @@ -0,0 +1,39 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.Copy; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresCopyTest extends Copy { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } + + @Override + public void copyCommandShouldRespectTheRFC() { + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresEventsTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresEventsTest.java new file mode 100644 index 00000000000..17975f0b58e --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresEventsTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.Events; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresEventsTest extends Events { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresExpungeTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresExpungeTest.java new file mode 100644 index 00000000000..f76cff4ca11 --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresExpungeTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.Expunge; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresExpungeTest extends Expunge { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchBodySectionTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchBodySectionTest.java new file mode 100644 index 00000000000..84a3adb305c --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchBodySectionTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.FetchBodySection; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresFetchBodySectionTest extends FetchBodySection { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchBodyStructureTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchBodyStructureTest.java new file mode 100644 index 00000000000..08cad7d3be3 --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchBodyStructureTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.FetchBodyStructure; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresFetchBodyStructureTest extends FetchBodyStructure { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchHeadersTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchHeadersTest.java new file mode 100644 index 00000000000..78833138285 --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchHeadersTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.FetchHeaders; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresFetchHeadersTest extends FetchHeaders { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchTest.java new file mode 100644 index 00000000000..a96c46d65ef --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchTest.java @@ -0,0 +1,43 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.Fetch; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresFetchTest extends Fetch { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } + + @Override + @Test + public void testFetchSaveDate() throws Exception { + simpleScriptedTestProtocol + .run("FetchNILSaveDate"); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresListingTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresListingTest.java new file mode 100644 index 00000000000..e8ec78d728f --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresListingTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.Listing; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresListingTest extends Listing { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxAnnotationTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxAnnotationTest.java new file mode 100644 index 00000000000..8a3f305ed53 --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxAnnotationTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.MailboxAnnotation; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresMailboxAnnotationTest extends MailboxAnnotation { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxWithLongNameErrorTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxWithLongNameErrorTest.java new file mode 100644 index 00000000000..3060d1017f8 --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxWithLongNameErrorTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.MailboxWithLongNameError; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresMailboxWithLongNameErrorTest extends MailboxWithLongNameError { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMoveTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMoveTest.java new file mode 100644 index 00000000000..3368e8c105f --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMoveTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.Move; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresMoveTest extends Move { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresNonAuthenticatedStateTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresNonAuthenticatedStateTest.java new file mode 100644 index 00000000000..106c5270f3e --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresNonAuthenticatedStateTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.NonAuthenticatedState; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresNonAuthenticatedStateTest extends NonAuthenticatedState { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresPartialFetchTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresPartialFetchTest.java new file mode 100644 index 00000000000..9ea8190efe7 --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresPartialFetchTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.PartialFetch; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresPartialFetchTest extends PartialFetch { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresQuotaTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresQuotaTest.java new file mode 100644 index 00000000000..f18f68ecf47 --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresQuotaTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.QuotaTest; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresQuotaTest extends QuotaTest { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresRenameTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresRenameTest.java new file mode 100644 index 00000000000..ebbb4c76ba1 --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresRenameTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.Rename; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresRenameTest extends Rename { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSearchTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSearchTest.java new file mode 100644 index 00000000000..e77193e18ab --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSearchTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.Search; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresSearchTest extends Search { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSecurityTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSecurityTest.java new file mode 100644 index 00000000000..4354e4ff39d --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSecurityTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.Security; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresSecurityTest extends Security { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSelectTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSelectTest.java new file mode 100644 index 00000000000..2e9f7344788 --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSelectTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.Select; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresSelectTest extends Select { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSelectedInboxTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSelectedInboxTest.java new file mode 100644 index 00000000000..e9dbd59e452 --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSelectedInboxTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.SelectedInbox; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresSelectedInboxTest extends SelectedInbox { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSelectedStateTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSelectedStateTest.java new file mode 100644 index 00000000000..ec8cbb5bb40 --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSelectedStateTest.java @@ -0,0 +1,65 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.SelectedState; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresSelectedStateTest extends SelectedState { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } + + @Override + public void testCopyITALY() { + } + + @Override + public void testCopyKOREA() { + } + + @Override + public void testCopyUS() { + } + + @Override + public void testUidITALY() { + } + + @Override + public void testUidKOREA() { + } + + @Override + public void testUidUS() { + } + + @Override + @Disabled("SEARCH save date just return empty result for JPA") + public void testSearchSaveDate() { + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresUidSearchOnIndexTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresUidSearchOnIndexTest.java new file mode 100644 index 00000000000..6e7b1d8a1d3 --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresUidSearchOnIndexTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.UidSearchOnIndex; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresUidSearchOnIndexTest extends UidSearchOnIndex { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresUidSearchTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresUidSearchTest.java new file mode 100644 index 00000000000..8bb3d435102 --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresUidSearchTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.UidSearch; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresUidSearchTest extends UidSearch { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresUserFlagsSupportTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresUserFlagsSupportTest.java new file mode 100644 index 00000000000..4cee9918fc0 --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresUserFlagsSupportTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres; + +import org.apache.james.mpt.api.ImapHostSystem; +import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; +import org.apache.james.mpt.imapmailbox.suite.UserFlagsSupport; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresUserFlagsSupportTest extends UserFlagsSupport { + @RegisterExtension + public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + + @Override + protected ImapHostSystem createImapHostSystem() { + return hostSystemExtension.getHostSystem(); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java new file mode 100644 index 00000000000..eadfab811ce --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java @@ -0,0 +1,177 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres.host; + +import java.time.Instant; + +import javax.persistence.EntityManagerFactory; + +import org.apache.james.backends.jpa.JPAConfiguration; +import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.core.quota.QuotaCountLimit; +import org.apache.james.core.quota.QuotaSizeLimit; +import org.apache.james.events.EventBusTestFixture; +import org.apache.james.events.InVMEventBus; +import org.apache.james.events.MemoryEventDeadLetters; +import org.apache.james.events.delivery.InVmEventDelivery; +import org.apache.james.imap.api.process.ImapProcessor; +import org.apache.james.imap.encode.main.DefaultImapEncoderFactory; +import org.apache.james.imap.main.DefaultImapDecoderFactory; +import org.apache.james.imap.processor.main.DefaultImapProcessorFactory; +import org.apache.james.mailbox.AttachmentContentLoader; +import org.apache.james.mailbox.MailboxManager; +import org.apache.james.mailbox.SubscriptionManager; +import org.apache.james.mailbox.acl.MailboxACLResolver; +import org.apache.james.mailbox.acl.UnionMailboxACLResolver; +import org.apache.james.mailbox.postgres.JPAMailboxFixture; +import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; +import org.apache.james.mailbox.postgres.mail.JPAModSeqProvider; +import org.apache.james.mailbox.postgres.mail.JPAUidProvider; +import org.apache.james.mailbox.postgres.openjpa.OpenJPAMailboxManager; +import org.apache.james.mailbox.postgres.quota.JPAPerUserMaxQuotaDAO; +import org.apache.james.mailbox.postgres.quota.JPAPerUserMaxQuotaManager; +import org.apache.james.mailbox.postgres.quota.JpaCurrentQuotaManager; +import org.apache.james.mailbox.store.SessionProviderImpl; +import org.apache.james.mailbox.store.StoreMailboxAnnotationManager; +import org.apache.james.mailbox.store.StoreRightManager; +import org.apache.james.mailbox.store.StoreSubscriptionManager; +import org.apache.james.mailbox.store.event.MailboxAnnotationListener; +import org.apache.james.mailbox.store.extractor.DefaultTextExtractor; +import org.apache.james.mailbox.store.mail.NaiveThreadIdGuessingAlgorithm; +import org.apache.james.mailbox.store.mail.model.DefaultMessageId; +import org.apache.james.mailbox.store.mail.model.impl.MessageParser; +import org.apache.james.mailbox.store.quota.DefaultUserQuotaRootResolver; +import org.apache.james.mailbox.store.quota.ListeningCurrentQuotaUpdater; +import org.apache.james.mailbox.store.quota.QuotaComponents; +import org.apache.james.mailbox.store.quota.StoreQuotaManager; +import org.apache.james.mailbox.store.search.MessageSearchIndex; +import org.apache.james.mailbox.store.search.SimpleMessageSearchIndex; +import org.apache.james.metrics.logger.DefaultMetricFactory; +import org.apache.james.metrics.tests.RecordingMetricFactory; +import org.apache.james.mpt.api.ImapFeatures; +import org.apache.james.mpt.api.ImapFeatures.Feature; +import org.apache.james.mpt.host.JamesImapHostSystem; +import org.apache.james.utils.UpdatableTickingClock; + +import com.google.common.collect.ImmutableList; + +public class PostgresHostSystem extends JamesImapHostSystem { + + private static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create( + ImmutableList.>builder() + .addAll(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES) + .addAll(JPAMailboxFixture.QUOTA_PERSISTANCE_CLASSES) + .build()); + + private static final ImapFeatures SUPPORTED_FEATURES = ImapFeatures.of(Feature.NAMESPACE_SUPPORT, + Feature.USER_FLAGS_SUPPORT, + Feature.ANNOTATION_SUPPORT, + Feature.QUOTA_SUPPORT, + Feature.MOVE_SUPPORT, + Feature.MOD_SEQ_SEARCH); + + static JamesImapHostSystem build() { + return new PostgresHostSystem(); + } + + private JPAPerUserMaxQuotaManager maxQuotaManager; + private OpenJPAMailboxManager mailboxManager; + + @Override + public void beforeTest() throws Exception { + super.beforeTest(); + EntityManagerFactory entityManagerFactory = JPA_TEST_CLUSTER.getEntityManagerFactory(); + JPAUidProvider uidProvider = new JPAUidProvider(entityManagerFactory); + JPAModSeqProvider modSeqProvider = new JPAModSeqProvider(entityManagerFactory); + JPAConfiguration jpaConfiguration = JPAConfiguration.builder() + .driverName("driverName") + .driverURL("driverUrl") + .build(); + PostgresMailboxSessionMapperFactory mapperFactory = new PostgresMailboxSessionMapperFactory(entityManagerFactory, uidProvider, modSeqProvider, jpaConfiguration, + null); + + MailboxACLResolver aclResolver = new UnionMailboxACLResolver(); + MessageParser messageParser = new MessageParser(); + + + InVMEventBus eventBus = new InVMEventBus(new InVmEventDelivery(new RecordingMetricFactory()), EventBusTestFixture.RETRY_BACKOFF_CONFIGURATION, new MemoryEventDeadLetters()); + StoreRightManager storeRightManager = new StoreRightManager(mapperFactory, aclResolver, eventBus); + StoreMailboxAnnotationManager annotationManager = new StoreMailboxAnnotationManager(mapperFactory, storeRightManager); + SessionProviderImpl sessionProvider = new SessionProviderImpl(authenticator, authorizator); + DefaultUserQuotaRootResolver quotaRootResolver = new DefaultUserQuotaRootResolver(sessionProvider, mapperFactory); + JpaCurrentQuotaManager currentQuotaManager = new JpaCurrentQuotaManager(entityManagerFactory); + maxQuotaManager = new JPAPerUserMaxQuotaManager(entityManagerFactory, new JPAPerUserMaxQuotaDAO(entityManagerFactory)); + StoreQuotaManager storeQuotaManager = new StoreQuotaManager(currentQuotaManager, maxQuotaManager); + ListeningCurrentQuotaUpdater quotaUpdater = new ListeningCurrentQuotaUpdater(currentQuotaManager, quotaRootResolver, eventBus, storeQuotaManager); + QuotaComponents quotaComponents = new QuotaComponents(maxQuotaManager, storeQuotaManager, quotaRootResolver); + AttachmentContentLoader attachmentContentLoader = null; + MessageSearchIndex index = new SimpleMessageSearchIndex(mapperFactory, mapperFactory, new DefaultTextExtractor(), attachmentContentLoader); + + mailboxManager = new OpenJPAMailboxManager(mapperFactory, sessionProvider, messageParser, new DefaultMessageId.Factory(), + eventBus, annotationManager, storeRightManager, quotaComponents, index, new NaiveThreadIdGuessingAlgorithm(), new UpdatableTickingClock(Instant.now())); + + eventBus.register(quotaUpdater); + eventBus.register(new MailboxAnnotationListener(mapperFactory, sessionProvider)); + + SubscriptionManager subscriptionManager = new StoreSubscriptionManager(mapperFactory, mapperFactory, eventBus); + + ImapProcessor defaultImapProcessorFactory = + DefaultImapProcessorFactory.createDefaultProcessor( + mailboxManager, + eventBus, + subscriptionManager, + storeQuotaManager, + quotaRootResolver, + new DefaultMetricFactory()); + + configure(new DefaultImapDecoderFactory().buildImapDecoder(), + new DefaultImapEncoderFactory().buildImapEncoder(), + defaultImapProcessorFactory); + } + + @Override + public void afterTest() { + JPA_TEST_CLUSTER.clear(ImmutableList.builder() + .addAll(JPAMailboxFixture.MAILBOX_TABLE_NAMES) + .addAll(JPAMailboxFixture.QUOTA_TABLES_NAMES) + .build()); + } + + @Override + protected MailboxManager getMailboxManager() { + return mailboxManager; + } + + @Override + public boolean supports(Feature... features) { + return SUPPORTED_FEATURES.supports(features); + } + + @Override + public void setQuotaLimits(QuotaCountLimit maxMessageQuota, QuotaSizeLimit maxStorageQuota) { + maxQuotaManager.setGlobalMaxMessage(maxMessageQuota); + maxQuotaManager.setGlobalMaxStorage(maxStorageQuota); + } + + @Override + protected void await() { + + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystemExtension.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystemExtension.java new file mode 100644 index 00000000000..579f08d6d83 --- /dev/null +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystemExtension.java @@ -0,0 +1,52 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mpt.imapmailbox.postgres.host; + +import org.apache.james.mpt.host.JamesImapHostSystem; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; + +public class PostgresHostSystemExtension implements BeforeEachCallback, AfterEachCallback { + private final JamesImapHostSystem hostSystem; + + public PostgresHostSystemExtension() { + try { + hostSystem = PostgresHostSystem.build(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + public void afterEach(ExtensionContext extensionContext) throws Exception { + + hostSystem.afterTest(); + } + + @Override + public void beforeEach(ExtensionContext extensionContext) throws Exception { + hostSystem.beforeTest(); + } + + public JamesImapHostSystem getHostSystem() { + return hostSystem; + } +} From 907b1afac82fcd39317b0943c5f57e52809cdee9 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Fri, 10 Nov 2023 15:48:09 +0700 Subject: [PATCH 060/341] JAMES-2586 mpt-imapmailbox-postgres: bindings and setup in PGHostSystem - Adapt PostgresSubscriptionMapper --- mpt/impl/imap-mailbox/postgres/pom.xml | 11 ++++++ .../postgres/src/reporting-site/site.xml | 28 ------------- .../PostgresAuthenticatePlainTest.java | 2 +- .../PostgresAuthenticatedStateTest.java | 2 +- .../PostgresConcurrentSessionsTest.java | 2 +- .../postgres/PostgresCondstoreTest.java | 2 +- .../postgres/PostgresCopyTest.java | 2 +- .../postgres/PostgresEventsTest.java | 2 +- .../postgres/PostgresExpungeTest.java | 2 +- .../PostgresFetchBodySectionTest.java | 2 +- .../PostgresFetchBodyStructureTest.java | 2 +- .../postgres/PostgresFetchHeadersTest.java | 2 +- .../postgres/PostgresFetchTest.java | 2 +- .../postgres/PostgresListingTest.java | 2 +- .../PostgresMailboxAnnotationTest.java | 2 +- .../PostgresMailboxWithLongNameErrorTest.java | 2 +- .../postgres/PostgresMoveTest.java | 2 +- .../PostgresNonAuthenticatedStateTest.java | 2 +- .../postgres/PostgresPartialFetchTest.java | 2 +- .../postgres/PostgresQuotaTest.java | 2 +- .../postgres/PostgresRenameTest.java | 2 +- .../postgres/PostgresSearchTest.java | 2 +- .../postgres/PostgresSecurityTest.java | 2 +- .../postgres/PostgresSelectTest.java | 2 +- .../postgres/PostgresSelectedInboxTest.java | 2 +- .../postgres/PostgresSelectedStateTest.java | 2 +- .../PostgresUidSearchOnIndexTest.java | 2 +- .../postgres/PostgresUidSearchTest.java | 2 +- .../PostgresUserFlagsSupportTest.java | 2 +- .../postgres/host/PostgresHostSystem.java | 22 +++++++++-- .../host/PostgresHostSystemExtension.java | 39 +++++++++++++++++-- 31 files changed, 91 insertions(+), 63 deletions(-) delete mode 100644 mpt/impl/imap-mailbox/postgres/src/reporting-site/site.xml diff --git a/mpt/impl/imap-mailbox/postgres/pom.xml b/mpt/impl/imap-mailbox/postgres/pom.xml index 7c129744ee9..19b5bc9148f 100644 --- a/mpt/impl/imap-mailbox/postgres/pom.xml +++ b/mpt/impl/imap-mailbox/postgres/pom.xml @@ -78,6 +78,12 @@ event-bus-in-vm test + + ${james.groupId} + james-server-guice-common + test-jar + test + ${james.groupId} james-server-testing @@ -98,6 +104,11 @@ derby test + + org.testcontainers + postgresql + test + diff --git a/mpt/impl/imap-mailbox/postgres/src/reporting-site/site.xml b/mpt/impl/imap-mailbox/postgres/src/reporting-site/site.xml deleted file mode 100644 index f8423071619..00000000000 --- a/mpt/impl/imap-mailbox/postgres/src/reporting-site/site.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresAuthenticatePlainTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresAuthenticatePlainTest.java index b5ba6e804b4..a8d39c4fed7 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresAuthenticatePlainTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresAuthenticatePlainTest.java @@ -26,7 +26,7 @@ public class PostgresAuthenticatePlainTest extends AuthenticatePlain { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresAuthenticatedStateTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresAuthenticatedStateTest.java index 765a27af314..4432a6fd5bd 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresAuthenticatedStateTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresAuthenticatedStateTest.java @@ -26,7 +26,7 @@ public class PostgresAuthenticatedStateTest extends AuthenticatedState { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresConcurrentSessionsTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresConcurrentSessionsTest.java index 4f39cbbb957..444e1d13579 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresConcurrentSessionsTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresConcurrentSessionsTest.java @@ -26,7 +26,7 @@ public class PostgresConcurrentSessionsTest extends ConcurrentSessions { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresCondstoreTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresCondstoreTest.java index 0cb1eedcdc1..d8953168202 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresCondstoreTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresCondstoreTest.java @@ -26,7 +26,7 @@ public class PostgresCondstoreTest extends Condstore { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected JamesImapHostSystem createJamesImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresCopyTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresCopyTest.java index f6bfeffbc93..e50255ad74d 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresCopyTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresCopyTest.java @@ -26,7 +26,7 @@ public class PostgresCopyTest extends Copy { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresEventsTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresEventsTest.java index 17975f0b58e..116fa312c55 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresEventsTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresEventsTest.java @@ -26,7 +26,7 @@ public class PostgresEventsTest extends Events { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresExpungeTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresExpungeTest.java index f76cff4ca11..d6cc8489002 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresExpungeTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresExpungeTest.java @@ -26,7 +26,7 @@ public class PostgresExpungeTest extends Expunge { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchBodySectionTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchBodySectionTest.java index 84a3adb305c..24f06e0c30b 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchBodySectionTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchBodySectionTest.java @@ -26,7 +26,7 @@ public class PostgresFetchBodySectionTest extends FetchBodySection { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchBodyStructureTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchBodyStructureTest.java index 08cad7d3be3..de45b07180c 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchBodyStructureTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchBodyStructureTest.java @@ -26,7 +26,7 @@ public class PostgresFetchBodyStructureTest extends FetchBodyStructure { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchHeadersTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchHeadersTest.java index 78833138285..ed908a5b89a 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchHeadersTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchHeadersTest.java @@ -26,7 +26,7 @@ public class PostgresFetchHeadersTest extends FetchHeaders { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchTest.java index a96c46d65ef..f24b19527dd 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchTest.java @@ -27,7 +27,7 @@ public class PostgresFetchTest extends Fetch { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresListingTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresListingTest.java index e8ec78d728f..2069ee06784 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresListingTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresListingTest.java @@ -26,7 +26,7 @@ public class PostgresListingTest extends Listing { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxAnnotationTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxAnnotationTest.java index 8a3f305ed53..e4c7535eb98 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxAnnotationTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxAnnotationTest.java @@ -26,7 +26,7 @@ public class PostgresMailboxAnnotationTest extends MailboxAnnotation { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxWithLongNameErrorTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxWithLongNameErrorTest.java index 3060d1017f8..8dc66398aa6 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxWithLongNameErrorTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxWithLongNameErrorTest.java @@ -26,7 +26,7 @@ public class PostgresMailboxWithLongNameErrorTest extends MailboxWithLongNameError { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMoveTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMoveTest.java index 3368e8c105f..8637e5d2609 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMoveTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMoveTest.java @@ -26,7 +26,7 @@ public class PostgresMoveTest extends Move { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresNonAuthenticatedStateTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresNonAuthenticatedStateTest.java index 106c5270f3e..5fa63f5b95b 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresNonAuthenticatedStateTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresNonAuthenticatedStateTest.java @@ -26,7 +26,7 @@ public class PostgresNonAuthenticatedStateTest extends NonAuthenticatedState { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresPartialFetchTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresPartialFetchTest.java index 9ea8190efe7..be90ff06e1c 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresPartialFetchTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresPartialFetchTest.java @@ -26,7 +26,7 @@ public class PostgresPartialFetchTest extends PartialFetch { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresQuotaTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresQuotaTest.java index f18f68ecf47..a19495b582c 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresQuotaTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresQuotaTest.java @@ -26,7 +26,7 @@ public class PostgresQuotaTest extends QuotaTest { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresRenameTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresRenameTest.java index ebbb4c76ba1..4ea7a04f306 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresRenameTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresRenameTest.java @@ -26,7 +26,7 @@ public class PostgresRenameTest extends Rename { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSearchTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSearchTest.java index e77193e18ab..9baf18e5f1e 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSearchTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSearchTest.java @@ -26,7 +26,7 @@ public class PostgresSearchTest extends Search { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSecurityTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSecurityTest.java index 4354e4ff39d..127147bd141 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSecurityTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSecurityTest.java @@ -26,7 +26,7 @@ public class PostgresSecurityTest extends Security { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSelectTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSelectTest.java index 2e9f7344788..246023b1d13 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSelectTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSelectTest.java @@ -26,7 +26,7 @@ public class PostgresSelectTest extends Select { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSelectedInboxTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSelectedInboxTest.java index e9dbd59e452..9e6a273d1d4 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSelectedInboxTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSelectedInboxTest.java @@ -26,7 +26,7 @@ public class PostgresSelectedInboxTest extends SelectedInbox { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSelectedStateTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSelectedStateTest.java index ec8cbb5bb40..85bc13f155e 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSelectedStateTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresSelectedStateTest.java @@ -27,7 +27,7 @@ public class PostgresSelectedStateTest extends SelectedState { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresUidSearchOnIndexTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresUidSearchOnIndexTest.java index 6e7b1d8a1d3..916938eefe5 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresUidSearchOnIndexTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresUidSearchOnIndexTest.java @@ -26,7 +26,7 @@ public class PostgresUidSearchOnIndexTest extends UidSearchOnIndex { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresUidSearchTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresUidSearchTest.java index 8bb3d435102..2f374ca2e4a 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresUidSearchTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresUidSearchTest.java @@ -26,7 +26,7 @@ public class PostgresUidSearchTest extends UidSearch { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresUserFlagsSupportTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresUserFlagsSupportTest.java index 4cee9918fc0..006e41500f7 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresUserFlagsSupportTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresUserFlagsSupportTest.java @@ -26,7 +26,7 @@ public class PostgresUserFlagsSupportTest extends UserFlagsSupport { @RegisterExtension - public PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); + public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); @Override protected ImapHostSystem createImapHostSystem() { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java index eadfab811ce..06da403d789 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java @@ -25,6 +25,9 @@ import org.apache.james.backends.jpa.JPAConfiguration; import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.SimpleJamesPostgresConnectionFactory; import org.apache.james.core.quota.QuotaCountLimit; import org.apache.james.core.quota.QuotaSizeLimit; import org.apache.james.events.EventBusTestFixture; @@ -70,6 +73,7 @@ import org.apache.james.mpt.host.JamesImapHostSystem; import org.apache.james.utils.UpdatableTickingClock; +import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; public class PostgresHostSystem extends JamesImapHostSystem { @@ -87,12 +91,23 @@ public class PostgresHostSystem extends JamesImapHostSystem { Feature.MOVE_SUPPORT, Feature.MOD_SEQ_SEARCH); - static JamesImapHostSystem build() { - return new PostgresHostSystem(); + + static PostgresHostSystem build(PostgresExtension postgresExtension) { + return new PostgresHostSystem(postgresExtension); } private JPAPerUserMaxQuotaManager maxQuotaManager; private OpenJPAMailboxManager mailboxManager; + private final PostgresExtension postgresExtension; + private static JamesPostgresConnectionFactory postgresConnectionFactory; + public PostgresHostSystem(PostgresExtension postgresExtension) { + this.postgresExtension = postgresExtension; + } + + public void beforeAll() { + Preconditions.checkNotNull(postgresExtension.getConnectionFactory()); + postgresConnectionFactory = new SimpleJamesPostgresConnectionFactory(postgresExtension.getConnectionFactory()); + } @Override public void beforeTest() throws Exception { @@ -104,8 +119,7 @@ public void beforeTest() throws Exception { .driverName("driverName") .driverURL("driverUrl") .build(); - PostgresMailboxSessionMapperFactory mapperFactory = new PostgresMailboxSessionMapperFactory(entityManagerFactory, uidProvider, modSeqProvider, jpaConfiguration, - null); + PostgresMailboxSessionMapperFactory mapperFactory = new PostgresMailboxSessionMapperFactory(entityManagerFactory, uidProvider, modSeqProvider, jpaConfiguration, postgresConnectionFactory); MailboxACLResolver aclResolver = new UnionMailboxACLResolver(); MessageParser messageParser = new MessageParser(); diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystemExtension.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystemExtension.java index 579f08d6d83..298c9a222a3 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystemExtension.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystemExtension.java @@ -19,17 +19,26 @@ package org.apache.james.mpt.imapmailbox.postgres.host; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.mailbox.postgres.user.PostgresSubscriptionModule; import org.apache.james.mpt.host.JamesImapHostSystem; +import org.junit.jupiter.api.extension.AfterAllCallback; import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junit.jupiter.api.extension.ParameterResolver; -public class PostgresHostSystemExtension implements BeforeEachCallback, AfterEachCallback { - private final JamesImapHostSystem hostSystem; +public class PostgresHostSystemExtension implements BeforeEachCallback, AfterEachCallback, BeforeAllCallback, AfterAllCallback, ParameterResolver { + private final PostgresHostSystem hostSystem; + private final PostgresExtension postgresExtension; public PostgresHostSystemExtension() { + this.postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresSubscriptionModule.MODULE); try { - hostSystem = PostgresHostSystem.build(); + hostSystem = PostgresHostSystem.build(postgresExtension); } catch (Exception e) { throw new RuntimeException(e); } @@ -37,16 +46,38 @@ public PostgresHostSystemExtension() { @Override public void afterEach(ExtensionContext extensionContext) throws Exception { - + postgresExtension.afterEach(extensionContext); hostSystem.afterTest(); } @Override public void beforeEach(ExtensionContext extensionContext) throws Exception { + postgresExtension.beforeEach(extensionContext); hostSystem.beforeTest(); } public JamesImapHostSystem getHostSystem() { return hostSystem; } + + @Override + public void afterAll(ExtensionContext extensionContext) throws Exception { + postgresExtension.afterAll(extensionContext); + } + + @Override + public void beforeAll(ExtensionContext extensionContext) throws Exception { + postgresExtension.beforeAll(extensionContext); + hostSystem.beforeAll(); + } + + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + return false; + } + + @Override + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + return postgresExtension; + } } From 17915f2e62561a5ca6e2f6fc81f12d6305771dbd Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 13 Nov 2023 17:16:02 +0700 Subject: [PATCH 061/341] =?UTF-8?q?JAMES-2586=20SimpleJamesPostgresConnect?= =?UTF-8?q?ionFactory=20=E2=80=93=20set=20empty=20attribute=20value=20when?= =?UTF-8?q?=20without=20domain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SimpleJamesPostgresConnectionFactory.java | 17 +++++++++++------ .../JamesPostgresConnectionFactoryTest.java | 4 ++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SimpleJamesPostgresConnectionFactory.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SimpleJamesPostgresConnectionFactory.java index 385f32012cc..7bd1cf01221 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SimpleJamesPostgresConnectionFactory.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SimpleJamesPostgresConnectionFactory.java @@ -36,6 +36,7 @@ public class SimpleJamesPostgresConnectionFactory implements JamesPostgresConnectionFactory { private static final Logger LOGGER = LoggerFactory.getLogger(SimpleJamesPostgresConnectionFactory.class); private static final Domain DEFAULT = Domain.of("default"); + private static final String DEFAULT_DOMAIN_ATTRIBUTE_VALUE = ""; private final ConnectionFactory connectionFactory; private final Map mapDomainToConnection = new ConcurrentHashMap<>(); @@ -63,7 +64,7 @@ private Mono create(Domain domain) { } private Mono getAndSetConnection(Domain domain, Connection newConnection) { - return Mono.justOrEmpty(mapDomainToConnection.putIfAbsent(domain, newConnection)) + return Mono.fromCallable(() -> mapDomainToConnection.putIfAbsent(domain, newConnection)) .map(postgresqlConnection -> { //close redundant connection Mono.from(newConnection.close()) @@ -74,13 +75,17 @@ private Mono getAndSetConnection(Domain domain, Connection newConnec } private static Mono setDomainAttributeForConnection(Domain domain, Connection newConnection) { + return Mono.from(newConnection.createStatement("SET " + DOMAIN_ATTRIBUTE + " TO '" + getDomainAttributeValue(domain) + "'") // It should be set value via Bind, but it doesn't work + .execute()) + .doOnError(e -> LOGGER.error("Error while setting domain attribute for domain {}", domain, e)) + .then(Mono.just(newConnection)); + } + + private static String getDomainAttributeValue(Domain domain) { if (DEFAULT.equals(domain)) { - return Mono.just(newConnection); + return DEFAULT_DOMAIN_ATTRIBUTE_VALUE; } else { - return Mono.from(newConnection.createStatement("SET " + DOMAIN_ATTRIBUTE + " TO '" + domain.asString() + "'") // It should be set value via Bind, but it doesn't work - .execute()) - .doOnError(e -> LOGGER.error("Error while setting domain attribute for domain {}", domain, e)) - .then(Mono.just(newConnection)); + return domain.asString(); } } } diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/JamesPostgresConnectionFactoryTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/JamesPostgresConnectionFactoryTest.java index ab68dd611a3..98fb54de436 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/JamesPostgresConnectionFactoryTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/JamesPostgresConnectionFactoryTest.java @@ -71,7 +71,7 @@ void getConnectionShouldSetCurrentDomainAttribute() { } @Test - void getConnectionWithoutDomainShouldNotSetCurrentDomainAttribute() { + void getConnectionWithoutDomainShouldReturnEmptyAttribute() { Connection connection = jamesPostgresConnectionFactory().getConnection(Optional.empty()).block(); String message = Flux.from(connection.createStatement("show " + JamesPostgresConnectionFactory.DOMAIN_ATTRIBUTE) @@ -82,7 +82,7 @@ void getConnectionWithoutDomainShouldNotSetCurrentDomainAttribute() { .onErrorResume(throwable -> Mono.just(throwable.getMessage())) .block(); - assertThat(message).isEqualTo("unrecognized configuration parameter \"" + JamesPostgresConnectionFactory.DOMAIN_ATTRIBUTE + "\""); + assertThat(message).isEqualTo(""); } String getDomainAttributeValue(Connection connection) { From ec456f5a6ab736054e166168623bbfa1a4e1c0df Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Tue, 14 Nov 2023 10:40:09 +0700 Subject: [PATCH 062/341] JAMES-2586 mpt-imapmailbox-postgres - update maven build, increase memory and disable reuseForks - To fix ci failed --- mpt/impl/imap-mailbox/postgres/pom.xml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mpt/impl/imap-mailbox/postgres/pom.xml b/mpt/impl/imap-mailbox/postgres/pom.xml index 19b5bc9148f..4201111f07b 100644 --- a/mpt/impl/imap-mailbox/postgres/pom.xml +++ b/mpt/impl/imap-mailbox/postgres/pom.xml @@ -117,11 +117,9 @@ org.apache.maven.plugins maven-surefire-plugin - true - 1C -Djava.library.path= -javaagent:"${settings.localRepository}"/org/jacoco/org.jacoco.agent/${jacoco-maven-plugin.version}/org.jacoco.agent-${jacoco-maven-plugin.version}-runtime.jar=destfile=${basedir}/target/jacoco.exec - -Xms512m -Xmx1024m -Dopenjpa.Multithreaded=true + -Xms1024m -Xmx2048m -Dopenjpa.Multithreaded=true From 63c62ce6157915d092dfe2bcc4af91e5ed2ff04e Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 13 Nov 2023 17:23:07 +0700 Subject: [PATCH 063/341] JAMES-2586 Rename SimpleJamesPostgresConnectionFactory -> DomainImplPostgresConnectionFactory --- ...tory.java => DomainImplPostgresConnectionFactory.java} | 6 +++--- .../backends/postgres/ConnectionThreadSafetyTest.java | 6 +++--- ....java => DomainImplPostgresConnectionFactoryTest.java} | 8 ++++---- .../james/mailbox/postgres/JpaMailboxManagerProvider.java | 6 ++---- .../mailbox/postgres/PostgresSubscriptionManagerTest.java | 4 ++-- .../mail/PostgresMailboxMapperRowLevelSecurityTest.java | 4 ++-- .../mail/task/JPARecomputeCurrentQuotasServiceTest.java | 4 ++-- .../PostgresSubscriptionMapperRowLevelSecurityTest.java | 4 ++-- .../mpt/imapmailbox/postgres/host/PostgresHostSystem.java | 4 ++-- .../apache/james/modules/data/PostgresCommonModule.java | 6 +++--- 10 files changed, 25 insertions(+), 27 deletions(-) rename backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/{SimpleJamesPostgresConnectionFactory.java => DomainImplPostgresConnectionFactory.java} (94%) rename backends-common/postgres/src/test/java/org/apache/james/backends/postgres/{SimpleJamesPostgresConnectionFactoryTest.java => DomainImplPostgresConnectionFactoryTest.java} (93%) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SimpleJamesPostgresConnectionFactory.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/DomainImplPostgresConnectionFactory.java similarity index 94% rename from backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SimpleJamesPostgresConnectionFactory.java rename to backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/DomainImplPostgresConnectionFactory.java index 7bd1cf01221..552eae74a8d 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SimpleJamesPostgresConnectionFactory.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/DomainImplPostgresConnectionFactory.java @@ -33,8 +33,8 @@ import io.r2dbc.spi.ConnectionFactory; import reactor.core.publisher.Mono; -public class SimpleJamesPostgresConnectionFactory implements JamesPostgresConnectionFactory { - private static final Logger LOGGER = LoggerFactory.getLogger(SimpleJamesPostgresConnectionFactory.class); +public class DomainImplPostgresConnectionFactory implements JamesPostgresConnectionFactory { + private static final Logger LOGGER = LoggerFactory.getLogger(DomainImplPostgresConnectionFactory.class); private static final Domain DEFAULT = Domain.of("default"); private static final String DEFAULT_DOMAIN_ATTRIBUTE_VALUE = ""; @@ -42,7 +42,7 @@ public class SimpleJamesPostgresConnectionFactory implements JamesPostgresConnec private final Map mapDomainToConnection = new ConcurrentHashMap<>(); @Inject - public SimpleJamesPostgresConnectionFactory(ConnectionFactory connectionFactory) { + public DomainImplPostgresConnectionFactory(ConnectionFactory connectionFactory) { this.connectionFactory = connectionFactory; } diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/ConnectionThreadSafetyTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/ConnectionThreadSafetyTest.java index 80b927a5a24..4cdecdc86da 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/ConnectionThreadSafetyTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/ConnectionThreadSafetyTest.java @@ -29,7 +29,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Stream; -import org.apache.james.backends.postgres.utils.SimpleJamesPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; import org.apache.james.core.Domain; import org.apache.james.util.concurrency.ConcurrentTestRunner; import org.jetbrains.annotations.NotNull; @@ -60,11 +60,11 @@ public class ConnectionThreadSafetyTest { static PostgresExtension postgresExtension = PostgresExtension.empty(); private static PostgresqlConnection postgresqlConnection; - private static SimpleJamesPostgresConnectionFactory jamesPostgresConnectionFactory; + private static DomainImplPostgresConnectionFactory jamesPostgresConnectionFactory; @BeforeAll static void beforeAll() { - jamesPostgresConnectionFactory = new SimpleJamesPostgresConnectionFactory(postgresExtension.getConnectionFactory()); + jamesPostgresConnectionFactory = new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory()); postgresqlConnection = (PostgresqlConnection) postgresExtension.getConnection().block(); } diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/SimpleJamesPostgresConnectionFactoryTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/DomainImplPostgresConnectionFactoryTest.java similarity index 93% rename from backends-common/postgres/src/test/java/org/apache/james/backends/postgres/SimpleJamesPostgresConnectionFactoryTest.java rename to backends-common/postgres/src/test/java/org/apache/james/backends/postgres/DomainImplPostgresConnectionFactoryTest.java index 10d7b8f84e1..dc4b3209539 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/SimpleJamesPostgresConnectionFactoryTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/DomainImplPostgresConnectionFactoryTest.java @@ -29,7 +29,7 @@ import java.util.concurrent.ConcurrentHashMap; import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; -import org.apache.james.backends.postgres.utils.SimpleJamesPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; import org.apache.james.core.Domain; import org.apache.james.util.concurrency.ConcurrentTestRunner; import org.jetbrains.annotations.Nullable; @@ -45,12 +45,12 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -public class SimpleJamesPostgresConnectionFactoryTest extends JamesPostgresConnectionFactoryTest { +public class DomainImplPostgresConnectionFactoryTest extends JamesPostgresConnectionFactoryTest { @RegisterExtension static PostgresExtension postgresExtension = PostgresExtension.empty(); private PostgresqlConnection postgresqlConnection; - private SimpleJamesPostgresConnectionFactory jamesPostgresConnectionFactory; + private DomainImplPostgresConnectionFactory jamesPostgresConnectionFactory; JamesPostgresConnectionFactory jamesPostgresConnectionFactory() { return jamesPostgresConnectionFactory; @@ -58,7 +58,7 @@ JamesPostgresConnectionFactory jamesPostgresConnectionFactory() { @BeforeEach void beforeEach() { - jamesPostgresConnectionFactory = new SimpleJamesPostgresConnectionFactory(postgresExtension.getConnectionFactory()); + jamesPostgresConnectionFactory = new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory()); postgresqlConnection = (PostgresqlConnection) postgresExtension.getConnection().block(); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerProvider.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerProvider.java index 9a93f28f23b..980804d2ccd 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerProvider.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerProvider.java @@ -26,7 +26,7 @@ import org.apache.james.backends.jpa.JPAConfiguration; import org.apache.james.backends.jpa.JpaTestCluster; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.backends.postgres.utils.SimpleJamesPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; import org.apache.james.events.EventBusTestFixture; import org.apache.james.events.InVMEventBus; import org.apache.james.events.MemoryEventDeadLetters; @@ -38,8 +38,6 @@ import org.apache.james.mailbox.postgres.mail.JPAModSeqProvider; import org.apache.james.mailbox.postgres.mail.JPAUidProvider; import org.apache.james.mailbox.postgres.openjpa.OpenJPAMailboxManager; -import org.apache.james.mailbox.postgres.JPAAttachmentContentLoader; -import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; import org.apache.james.mailbox.store.SessionProviderImpl; import org.apache.james.mailbox.store.StoreMailboxAnnotationManager; import org.apache.james.mailbox.store.StoreRightManager; @@ -68,7 +66,7 @@ public static OpenJPAMailboxManager provideMailboxManager(JpaTestCluster jpaTest .build(); PostgresMailboxSessionMapperFactory mf = new PostgresMailboxSessionMapperFactory(entityManagerFactory, new JPAUidProvider(entityManagerFactory), new JPAModSeqProvider(entityManagerFactory), jpaConfiguration, - new SimpleJamesPostgresConnectionFactory(postgresExtension.getConnectionFactory())); + new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory())); MailboxACLResolver aclResolver = new UnionMailboxACLResolver(); MessageParser messageParser = new MessageParser(); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresSubscriptionManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresSubscriptionManagerTest.java index 39343b7b1ac..ebf07bf37f1 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresSubscriptionManagerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresSubscriptionManagerTest.java @@ -23,7 +23,7 @@ import org.apache.james.backends.jpa.JPAConfiguration; import org.apache.james.backends.jpa.JpaTestCluster; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.backends.postgres.utils.SimpleJamesPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; import org.apache.james.events.EventBusTestFixture; import org.apache.james.events.InVMEventBus; import org.apache.james.events.MemoryEventDeadLetters; @@ -66,7 +66,7 @@ void setUp() { new JPAUidProvider(entityManagerFactory), new JPAModSeqProvider(entityManagerFactory), jpaConfiguration, - new SimpleJamesPostgresConnectionFactory(postgresExtension.getConnectionFactory())); + new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory())); InVMEventBus eventBus = new InVMEventBus(new InVmEventDelivery(new RecordingMetricFactory()), EventBusTestFixture.RETRY_BACKOFF_CONFIGURATION, new MemoryEventDeadLetters()); subscriptionManager = new StoreSubscriptionManager(mapperFactory, mapperFactory, eventBus); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperRowLevelSecurityTest.java index 2233e24b651..3eb23fe07e1 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperRowLevelSecurityTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperRowLevelSecurityTest.java @@ -22,8 +22,8 @@ import static org.assertj.core.api.Assertions.assertThat; import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.PostgresExecutor; -import org.apache.james.backends.postgres.utils.SimpleJamesPostgresConnectionFactory; import org.apache.james.core.Username; import org.apache.james.mailbox.MailboxSession; import org.apache.james.mailbox.MailboxSessionUtil; @@ -45,7 +45,7 @@ public class PostgresMailboxMapperRowLevelSecurityTest { @BeforeEach public void setUp() { mailboxMapperFactory = session -> new PostgresMailboxMapper(new PostgresMailboxDAO(new PostgresExecutor( - new SimpleJamesPostgresConnectionFactory(postgresExtension.getConnectionFactory()) + new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory()) .getConnection(session.getUser().getDomainPart())))); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/JPARecomputeCurrentQuotasServiceTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/JPARecomputeCurrentQuotasServiceTest.java index 10a76bae46a..ca3b89df12b 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/JPARecomputeCurrentQuotasServiceTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/JPARecomputeCurrentQuotasServiceTest.java @@ -25,7 +25,7 @@ import org.apache.james.backends.jpa.JPAConfiguration; import org.apache.james.backends.jpa.JpaTestCluster; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.backends.postgres.utils.SimpleJamesPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; import org.apache.james.domainlist.api.DomainList; import org.apache.james.domainlist.jpa.model.JPADomain; import org.apache.james.mailbox.MailboxManager; @@ -89,7 +89,7 @@ void setUp() throws Exception { new JPAUidProvider(entityManagerFactory), new JPAModSeqProvider(entityManagerFactory), jpaConfiguration, - new SimpleJamesPostgresConnectionFactory(postgresExtension.getConnectionFactory())); + new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory())); usersRepository = new JPAUsersRepository(NO_DOMAIN_LIST); usersRepository.setEntityManagerFactory(JPA_TEST_CLUSTER.getEntityManagerFactory()); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java index 69dc70eddb0..b9c1c2caa09 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java @@ -23,7 +23,7 @@ import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.backends.postgres.utils.PostgresExecutor; -import org.apache.james.backends.postgres.utils.SimpleJamesPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; import org.apache.james.core.Username; import org.apache.james.mailbox.MailboxSession; import org.apache.james.mailbox.MailboxSessionUtil; @@ -42,7 +42,7 @@ public class PostgresSubscriptionMapperRowLevelSecurityTest { @BeforeEach public void setUp() { subscriptionMapperFactory = session -> new PostgresSubscriptionMapper(new PostgresSubscriptionDAO(new PostgresExecutor( - new SimpleJamesPostgresConnectionFactory(postgresExtension.getConnectionFactory()) + new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory()) .getConnection(session.getUser().getDomainPart())))); } diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java index 06da403d789..5c98591f0ee 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java @@ -26,8 +26,8 @@ import org.apache.james.backends.jpa.JPAConfiguration; import org.apache.james.backends.jpa.JpaTestCluster; import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; -import org.apache.james.backends.postgres.utils.SimpleJamesPostgresConnectionFactory; import org.apache.james.core.quota.QuotaCountLimit; import org.apache.james.core.quota.QuotaSizeLimit; import org.apache.james.events.EventBusTestFixture; @@ -106,7 +106,7 @@ public PostgresHostSystem(PostgresExtension postgresExtension) { public void beforeAll() { Preconditions.checkNotNull(postgresExtension.getConnectionFactory()); - postgresConnectionFactory = new SimpleJamesPostgresConnectionFactory(postgresExtension.getConnectionFactory()); + postgresConnectionFactory = new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory()); } @Override diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index b95a1fdf01e..f8508438c44 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -28,7 +28,7 @@ import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.backends.postgres.PostgresTableManager; import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; -import org.apache.james.backends.postgres.utils.SimpleJamesPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; import org.apache.james.utils.InitializationOperation; import org.apache.james.utils.InitilizationOperationBuilder; import org.apache.james.utils.PropertiesProvider; @@ -46,11 +46,11 @@ public class PostgresCommonModule extends AbstractModule { @Override public void configure() { - bind(JamesPostgresConnectionFactory.class).to(SimpleJamesPostgresConnectionFactory.class); + bind(JamesPostgresConnectionFactory.class).to(DomainImplPostgresConnectionFactory.class); Multibinder.newSetBinder(binder(), PostgresModule.class); - bind(SimpleJamesPostgresConnectionFactory.class).in(Scopes.SINGLETON); + bind(DomainImplPostgresConnectionFactory.class).in(Scopes.SINGLETON); } @Provides From 45c8c285d00409ec8f17c2c99649f1dc52719ab7 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 13 Nov 2023 17:42:41 +0700 Subject: [PATCH 064/341] JAMES-2586 Introduce Single postgres connection factory when disable row level security --- .../SinglePostgresConnectionFactory.java | 40 +++++++++++++++++++ .../modules/data/PostgresCommonModule.java | 15 +++++-- 2 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SinglePostgresConnectionFactory.java diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SinglePostgresConnectionFactory.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SinglePostgresConnectionFactory.java new file mode 100644 index 00000000000..58f1dc72f83 --- /dev/null +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SinglePostgresConnectionFactory.java @@ -0,0 +1,40 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres.utils; + +import java.util.Optional; + +import org.apache.james.core.Domain; + +import io.r2dbc.spi.Connection; +import reactor.core.publisher.Mono; + +public class SinglePostgresConnectionFactory implements JamesPostgresConnectionFactory { + private final Connection connection; + + public SinglePostgresConnectionFactory(Connection connection) { + this.connection = connection; + } + + @Override + public Mono getConnection(Optional domain) { + return Mono.just(connection); + } +} diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index f8508438c44..ad6575aaba1 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -27,8 +27,9 @@ import org.apache.james.backends.postgres.PostgresConfiguration; import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.backends.postgres.PostgresTableManager; -import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.SinglePostgresConnectionFactory; import org.apache.james.utils.InitializationOperation; import org.apache.james.utils.InitilizationOperationBuilder; import org.apache.james.utils.PropertiesProvider; @@ -42,12 +43,11 @@ import io.r2dbc.postgresql.PostgresqlConnectionConfiguration; import io.r2dbc.postgresql.PostgresqlConnectionFactory; import io.r2dbc.spi.ConnectionFactory; +import reactor.core.publisher.Mono; public class PostgresCommonModule extends AbstractModule { @Override public void configure() { - bind(JamesPostgresConnectionFactory.class).to(DomainImplPostgresConnectionFactory.class); - Multibinder.newSetBinder(binder(), PostgresModule.class); bind(DomainImplPostgresConnectionFactory.class).in(Scopes.SINGLETON); @@ -59,6 +59,15 @@ PostgresConfiguration provideConfiguration(PropertiesProvider propertiesProvider return PostgresConfiguration.from(propertiesProvider.getConfiguration("postgres")); } + @Provides + @Singleton + JamesPostgresConnectionFactory provideJamesPostgresConnectionFactory(PostgresConfiguration postgresConfiguration, ConnectionFactory connectionFactory) { + if (postgresConfiguration.rowLevelSecurityEnabled()) { + return new DomainImplPostgresConnectionFactory(connectionFactory); + } + return new SinglePostgresConnectionFactory(Mono.from(connectionFactory.create()).block()); + } + @Provides @Singleton ConnectionFactory postgresqlConnectionFactory(PostgresConfiguration postgresConfiguration) { From 5352a09ab0f90ba6084e1dc91c3ddfd26b69539b Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 20 Nov 2023 11:39:07 +0700 Subject: [PATCH 065/341] JAMES-2586 LOGGER when choice implementation of Postgresql connection factory --- .../james/modules/data/PostgresCommonModule.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index ad6575aaba1..e9f53a70466 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -33,10 +33,11 @@ import org.apache.james.utils.InitializationOperation; import org.apache.james.utils.InitilizationOperationBuilder; import org.apache.james.utils.PropertiesProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.google.inject.AbstractModule; import com.google.inject.Provides; -import com.google.inject.Scopes; import com.google.inject.multibindings.Multibinder; import com.google.inject.multibindings.ProvidesIntoSet; @@ -46,11 +47,11 @@ import reactor.core.publisher.Mono; public class PostgresCommonModule extends AbstractModule { + private static final Logger LOGGER = LoggerFactory.getLogger("POSTGRES"); + @Override public void configure() { Multibinder.newSetBinder(binder(), PostgresModule.class); - - bind(DomainImplPostgresConnectionFactory.class).in(Scopes.SINGLETON); } @Provides @@ -63,8 +64,11 @@ PostgresConfiguration provideConfiguration(PropertiesProvider propertiesProvider @Singleton JamesPostgresConnectionFactory provideJamesPostgresConnectionFactory(PostgresConfiguration postgresConfiguration, ConnectionFactory connectionFactory) { if (postgresConfiguration.rowLevelSecurityEnabled()) { + LOGGER.info("PostgreSQL row level security enabled"); + LOGGER.info("Implementation for PostgreSQL connection factory: {}", DomainImplPostgresConnectionFactory.class.getName()); return new DomainImplPostgresConnectionFactory(connectionFactory); } + LOGGER.info("Implementation for PostgreSQL connection factory: {}", SinglePostgresConnectionFactory.class.getName()); return new SinglePostgresConnectionFactory(Mono.from(connectionFactory.create()).block()); } From 5cd3f88e45c8fdf63239f2bbe3a48dc5f60f696e Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 20 Nov 2023 11:40:44 +0700 Subject: [PATCH 066/341] JAMES-2586 Clean-up the provision.sh file of postgres-app --- server/apps/postgres-app/provision.sh | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/server/apps/postgres-app/provision.sh b/server/apps/postgres-app/provision.sh index 6bc86840298..9a62d68dfdc 100755 --- a/server/apps/postgres-app/provision.sh +++ b/server/apps/postgres-app/provision.sh @@ -6,8 +6,13 @@ export USERS_COUNT=1000 echo "Start provisioning users." +user_file="./imap-provision-conf/users.csv" + # Remove old users.csv file -rm ./imap-provision-conf/users.csv +if [ -e "$user_file" ]; then + echo "Removing old users.csv file" + rm $user_file +fi # Create domain curl -X PUT ${WEBADMIN_BASE_URL}/domains/${DOMAIN_NAME} @@ -22,7 +27,7 @@ do -H "Content-Type: application/json" # Append user to users.csv - echo -e "$username,secret" >> ./imap-provision-conf/users.csv + echo -e "$username,secret" >> $user_file done echo "Finished provisioning users." @@ -30,6 +35,6 @@ echo "Finished provisioning users." # Provisioning IMAP mailboxes and messages. echo "Start provisioning IMAP mailboxes and messages..." docker run --rm -it --name james-provisioning --network host -v ./imap-provision-conf/provisioning.properties:/conf/provisioning.properties \ --v ./imap-provision-conf/users.csv:/conf/users.csv linagora/james-provisioning:latest +-v $user_file:/conf/users.csv linagora/james-provisioning:latest echo "Finished provisioning IMAP mailboxes and messages." From ecabed40c18ff317567fb82c0f9b3b1dacb3c4d5 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Tue, 21 Nov 2023 15:10:44 +0700 Subject: [PATCH 067/341] JAMES-2586 Parameterize MailboxSession for getUidProvider/getModSeqProvider methods in MailboxSessionMapperFactory --- .../cassandra/CassandraMailboxSessionMapperFactory.java | 4 ++-- .../mailbox/jpa/JPAMailboxSessionMapperFactory.java | 4 ++-- .../inmemory/InMemoryMailboxSessionMapperFactory.java | 4 ++-- .../mailbox/inmemory/mail/InMemoryMapperProvider.java | 4 ++-- .../postgres/PostgresMailboxSessionMapperFactory.java | 4 ++-- .../james/mailbox/store/MailboxSessionMapperFactory.java | 4 ++-- .../james/mailbox/store/StoreMessageIdManager.java | 9 +++++---- 7 files changed, 17 insertions(+), 16 deletions(-) diff --git a/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/CassandraMailboxSessionMapperFactory.java b/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/CassandraMailboxSessionMapperFactory.java index 6c8e39c5b3c..55a27497830 100644 --- a/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/CassandraMailboxSessionMapperFactory.java +++ b/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/CassandraMailboxSessionMapperFactory.java @@ -180,12 +180,12 @@ public SubscriptionMapper createSubscriptionMapper(MailboxSession mailboxSession } @Override - public ModSeqProvider getModSeqProvider() { + public ModSeqProvider getModSeqProvider(MailboxSession session) { return modSeqProvider; } @Override - public UidProvider getUidProvider() { + public UidProvider getUidProvider(MailboxSession session) { return uidProvider; } diff --git a/mailbox/jpa/src/main/java/org/apache/james/mailbox/jpa/JPAMailboxSessionMapperFactory.java b/mailbox/jpa/src/main/java/org/apache/james/mailbox/jpa/JPAMailboxSessionMapperFactory.java index 7f4f05d6c24..233d0e45a6d 100644 --- a/mailbox/jpa/src/main/java/org/apache/james/mailbox/jpa/JPAMailboxSessionMapperFactory.java +++ b/mailbox/jpa/src/main/java/org/apache/james/mailbox/jpa/JPAMailboxSessionMapperFactory.java @@ -102,12 +102,12 @@ public AnnotationMapper createAnnotationMapper(MailboxSession session) { } @Override - public UidProvider getUidProvider() { + public UidProvider getUidProvider(MailboxSession session) { return uidProvider; } @Override - public ModSeqProvider getModSeqProvider() { + public ModSeqProvider getModSeqProvider(MailboxSession session) { return modSeqProvider; } diff --git a/mailbox/memory/src/main/java/org/apache/james/mailbox/inmemory/InMemoryMailboxSessionMapperFactory.java b/mailbox/memory/src/main/java/org/apache/james/mailbox/inmemory/InMemoryMailboxSessionMapperFactory.java index 84250a64512..bef77415878 100644 --- a/mailbox/memory/src/main/java/org/apache/james/mailbox/inmemory/InMemoryMailboxSessionMapperFactory.java +++ b/mailbox/memory/src/main/java/org/apache/james/mailbox/inmemory/InMemoryMailboxSessionMapperFactory.java @@ -103,12 +103,12 @@ public AnnotationMapper createAnnotationMapper(MailboxSession session) { } @Override - public UidProvider getUidProvider() { + public UidProvider getUidProvider(MailboxSession session) { return uidProvider; } @Override - public ModSeqProvider getModSeqProvider() { + public ModSeqProvider getModSeqProvider(MailboxSession session) { return modSeqProvider; } diff --git a/mailbox/memory/src/test/java/org/apache/james/mailbox/inmemory/mail/InMemoryMapperProvider.java b/mailbox/memory/src/test/java/org/apache/james/mailbox/inmemory/mail/InMemoryMapperProvider.java index 286057ee167..e5f96ace5e6 100644 --- a/mailbox/memory/src/test/java/org/apache/james/mailbox/inmemory/mail/InMemoryMapperProvider.java +++ b/mailbox/memory/src/test/java/org/apache/james/mailbox/inmemory/mail/InMemoryMapperProvider.java @@ -119,13 +119,13 @@ public List getSupportedCapabilities() { @Override public ModSeq generateModSeq(Mailbox mailbox) throws MailboxException { - return inMemoryMailboxSessionMapperFactory.getModSeqProvider() + return inMemoryMailboxSessionMapperFactory.getModSeqProvider(null) .nextModSeq(mailbox); } @Override public ModSeq highestModSeq(Mailbox mailbox) throws MailboxException { - return inMemoryMailboxSessionMapperFactory.getModSeqProvider() + return inMemoryMailboxSessionMapperFactory.getModSeqProvider(null) .highestModSeq(mailbox); } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java index 9f6a29028bb..7b20e1996fa 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java @@ -109,12 +109,12 @@ public AnnotationMapper createAnnotationMapper(MailboxSession session) { } @Override - public UidProvider getUidProvider() { + public UidProvider getUidProvider(MailboxSession session) { return uidProvider; } @Override - public ModSeqProvider getModSeqProvider() { + public ModSeqProvider getModSeqProvider(MailboxSession session) { return modSeqProvider; } diff --git a/mailbox/store/src/main/java/org/apache/james/mailbox/store/MailboxSessionMapperFactory.java b/mailbox/store/src/main/java/org/apache/james/mailbox/store/MailboxSessionMapperFactory.java index af455608bfa..3267a7ce319 100644 --- a/mailbox/store/src/main/java/org/apache/james/mailbox/store/MailboxSessionMapperFactory.java +++ b/mailbox/store/src/main/java/org/apache/james/mailbox/store/MailboxSessionMapperFactory.java @@ -124,9 +124,9 @@ public SubscriptionMapper getSubscriptionMapper(MailboxSession session) { */ public abstract SubscriptionMapper createSubscriptionMapper(MailboxSession session); - public abstract UidProvider getUidProvider(); + public abstract UidProvider getUidProvider(MailboxSession session); - public abstract ModSeqProvider getModSeqProvider(); + public abstract ModSeqProvider getModSeqProvider(MailboxSession session); /** * Call endRequest on {@link Mapper} instances diff --git a/mailbox/store/src/main/java/org/apache/james/mailbox/store/StoreMessageIdManager.java b/mailbox/store/src/main/java/org/apache/james/mailbox/store/StoreMessageIdManager.java index d4950bd309f..4d1b42f712a 100644 --- a/mailbox/store/src/main/java/org/apache/james/mailbox/store/StoreMessageIdManager.java +++ b/mailbox/store/src/main/java/org/apache/james/mailbox/store/StoreMessageIdManager.java @@ -505,7 +505,7 @@ private Mono addMessageToMailboxes(MailboxMessage mailboxMessage, MessageM .build()) .build()); - return save(messageIdMapper, copy, mailbox) + return save(messageIdMapper, copy, mailbox, mailboxSession) .flatMap(metadata -> dispatchAddedEvent(mailboxSession, mailbox, metadata, messageMoves)); }).sneakyThrow()) .then(); @@ -534,10 +534,11 @@ private boolean isSingleMove(MessageMovesWithMailbox messageMoves) { return messageMoves.addedMailboxes().size() == 1 && messageMoves.removedMailboxes().size() == 1; } - private Mono save(MessageIdMapper messageIdMapper, MailboxMessage mailboxMessage, Mailbox mailbox) { + private Mono save(MessageIdMapper messageIdMapper, MailboxMessage mailboxMessage, + Mailbox mailbox, MailboxSession mailboxSession) { return Mono.zip( - mailboxSessionMapperFactory.getModSeqProvider().nextModSeqReactive(mailbox.getMailboxId()), - mailboxSessionMapperFactory.getUidProvider().nextUidReactive(mailbox.getMailboxId())) + mailboxSessionMapperFactory.getModSeqProvider(mailboxSession).nextModSeqReactive(mailbox.getMailboxId()), + mailboxSessionMapperFactory.getUidProvider(mailboxSession).nextUidReactive(mailbox.getMailboxId())) .flatMap(modSeqAndUid -> { mailboxMessage.setModSeq(modSeqAndUid.getT1()); mailboxMessage.setUid(modSeqAndUid.getT2()); From c39c6c0c3e33601aa57fb6480d0b65eec8910f55 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 22 Nov 2023 10:12:28 +0700 Subject: [PATCH 068/341] JAMES-2586 Implement PostgresUidProvider --- .../postgres/PostgresMailboxIdFaker.java | 43 ++++++ .../postgres/mail/PostgresMailboxModule.java | 10 +- .../postgres/mail/PostgresUidProvider.java | 106 +++++++++++++ .../postgres/mail/dao/PostgresMailboxDAO.java | 24 ++- .../mail/PostgresUidProviderTest.java | 140 ++++++++++++++++++ 5 files changed, 316 insertions(+), 7 deletions(-) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxIdFaker.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresUidProvider.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresUidProviderTest.java diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxIdFaker.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxIdFaker.java new file mode 100644 index 00000000000..23751b5001a --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxIdFaker.java @@ -0,0 +1,43 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres; + +import java.util.UUID; + +import org.apache.james.mailbox.model.MailboxId; + +// TODO remove: this is trick convert JPAId to PostgresMailboxId when implementing PostgresUidProvider. +// it should be removed when all JPA dependencies are removed +@Deprecated +public class PostgresMailboxIdFaker { + public static PostgresMailboxId getMailboxId(MailboxId mailboxId) { + if (mailboxId instanceof JPAId) { + long longValue = ((JPAId) mailboxId).getRawId(); + return PostgresMailboxId.of(longToUUID(longValue)); + } + return (PostgresMailboxId) mailboxId; + } + + public static UUID longToUUID(Long longValue) { + long mostSigBits = longValue << 32; + long leastSigBits = 0; + return new UUID(mostSigBits, leastSigBits); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java index 6ed11a0c569..9c9b424c482 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java @@ -19,6 +19,8 @@ package org.apache.james.mailbox.postgres.mail; +import static org.jooq.impl.SQLDataType.BIGINT; + import java.util.UUID; import org.apache.james.backends.postgres.PostgresModule; @@ -35,14 +37,14 @@ interface PostgresMailboxTable { Field MAILBOX_ID = DSL.field("mailbox_id", SQLDataType.UUID.notNull()); Field MAILBOX_NAME = DSL.field("mailbox_name", SQLDataType.VARCHAR(255).notNull()); - Field MAILBOX_UID_VALIDITY = DSL.field("mailbox_uid_validity", SQLDataType.BIGINT.notNull()); + Field MAILBOX_UID_VALIDITY = DSL.field("mailbox_uid_validity", BIGINT.notNull()); Field USER_NAME = DSL.field("user_name", SQLDataType.VARCHAR(255)); Field MAILBOX_NAMESPACE = DSL.field("mailbox_namespace", SQLDataType.VARCHAR(255).notNull()); - Field MAILBOX_LAST_UID = DSL.field("mailbox_last_uid", SQLDataType.BIGINT); - Field MAILBOX_HIGHEST_MODSEQ = DSL.field("mailbox_highest_modseq", SQLDataType.BIGINT); + Field MAILBOX_LAST_UID = DSL.field("mailbox_last_uid", BIGINT); + Field MAILBOX_HIGHEST_MODSEQ = DSL.field("mailbox_highest_modseq", BIGINT); PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) - .createTableStep(((dsl, tableName) -> dsl.createTable(tableName) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) .column(MAILBOX_ID, SQLDataType.UUID) .column(MAILBOX_NAME) .column(MAILBOX_UID_VALIDITY) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresUidProvider.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresUidProvider.java new file mode 100644 index 00000000000..8333fcbf036 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresUidProvider.java @@ -0,0 +1,106 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import java.util.List; +import java.util.Optional; +import java.util.stream.LongStream; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; +import org.apache.james.mailbox.store.mail.UidProvider; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; + +import reactor.core.publisher.Mono; + +public class PostgresUidProvider implements UidProvider { + + public static class Factory { + + private final PostgresExecutor.Factory executorFactory; + + public Factory(PostgresExecutor.Factory executorFactory) { + this.executorFactory = executorFactory; + } + + public PostgresUidProvider create(MailboxSession session) { + PostgresExecutor postgresExecutor = executorFactory.create(session.getUser().getDomainPart()); + return new PostgresUidProvider(new PostgresMailboxDAO(postgresExecutor)); + } + } + + private final PostgresMailboxDAO mailboxDAO; + + public PostgresUidProvider(PostgresMailboxDAO mailboxDAO) { + this.mailboxDAO = mailboxDAO; + } + + @Override + public MessageUid nextUid(Mailbox mailbox) throws MailboxException { + return nextUid(mailbox.getMailboxId()); + } + + @Override + public Optional lastUid(Mailbox mailbox) { + return lastUidReactive(mailbox).block(); + } + + @Override + public MessageUid nextUid(MailboxId mailboxId) throws MailboxException { + return nextUidReactive(mailboxId) + .blockOptional() + .orElseThrow(() -> new MailboxException("Error during Uid update")); + } + + @Override + public Mono> lastUidReactive(Mailbox mailbox) { + return mailboxDAO.findLastUidByMailboxId(mailbox.getMailboxId()) + .map(Optional::of) + .switchIfEmpty(Mono.just(Optional.empty())); + } + + @Override + public Mono nextUidReactive(MailboxId mailboxId) { + return mailboxDAO.incrementAndGetLastUid(mailboxId, 1) + .defaultIfEmpty(MessageUid.MIN_VALUE); + } + + @Override + public Mono> nextUids(MailboxId mailboxId, int count) { + Preconditions.checkArgument(count > 0, "Count need to be positive"); + Mono updateNewLastUid = mailboxDAO.incrementAndGetLastUid(mailboxId, count) + .defaultIfEmpty(MessageUid.MIN_VALUE); + return updateNewLastUid.map(lastUid -> range(lastUid, count)); + } + + private List range(MessageUid higherInclusive, int count) { + return LongStream.range(higherInclusive.asLong() - count + 1, higherInclusive.asLong() + 1) + .mapToObj(MessageUid::of) + .collect(ImmutableList.toImmutableList()); + } + +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java index 7e6d592bfb4..de6a42e8267 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java @@ -19,18 +19,20 @@ package org.apache.james.mailbox.postgres.mail.dao; +import static org.apache.james.mailbox.postgres.PostgresMailboxIdFaker.getMailboxId; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_ID; +import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_LAST_UID; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_NAME; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_NAMESPACE; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_UID_VALIDITY; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.TABLE_NAME; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.USER_NAME; +import static org.jooq.impl.DSL.coalesce; import static org.jooq.impl.DSL.count; -import javax.inject.Inject; - import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.Username; +import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.exception.MailboxExistsException; import org.apache.james.mailbox.exception.MailboxNotFoundException; import org.apache.james.mailbox.model.Mailbox; @@ -54,7 +56,6 @@ public class PostgresMailboxDAO { private final PostgresExecutor postgresExecutor; - @Inject public PostgresMailboxDAO(PostgresExecutor postgresExecutor) { this.postgresExecutor = postgresExecutor; } @@ -140,4 +141,21 @@ private Mailbox asMailbox(Record record) { return new Mailbox(new MailboxPath(record.get(MAILBOX_NAMESPACE), Username.of(record.get(USER_NAME)), record.get(MAILBOX_NAME)), UidValidity.of(record.get(MAILBOX_UID_VALIDITY)), PostgresMailboxId.of(record.get(MAILBOX_ID))); } + + public Mono findLastUidByMailboxId(MailboxId mailboxId) { + return postgresExecutor.executeRow(dsl -> Mono.from(dsl.select(MAILBOX_LAST_UID) + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(getMailboxId(mailboxId).asUuid())))) + .flatMap(record -> Mono.justOrEmpty(record.get(MAILBOX_LAST_UID))) + .map(MessageUid::of); + } + + public Mono incrementAndGetLastUid(MailboxId mailboxId, int count) { + return postgresExecutor.executeRow(dsl -> Mono.from(dsl.update(TABLE_NAME) + .set(MAILBOX_LAST_UID, coalesce(MAILBOX_LAST_UID, 0L).add(count)) + .where(MAILBOX_ID.eq(getMailboxId(mailboxId).asUuid())) + .returning(MAILBOX_LAST_UID))) + .map(record -> record.get(MAILBOX_LAST_UID)) + .map(MessageUid::of); + } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresUidProviderTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresUidProviderTest.java new file mode 100644 index 00000000000..f2e20f09aca --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresUidProviderTest.java @@ -0,0 +1,140 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentSkipListSet; +import java.util.concurrent.ExecutionException; +import java.util.stream.LongStream; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.core.Username; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.UidValidity; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; +import org.apache.james.mailbox.store.mail.UidProvider; +import org.apache.james.util.concurrency.ConcurrentTestRunner; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.github.fge.lambdas.Throwing; + +public class PostgresUidProviderTest { + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxModule.MODULE); + + private UidProvider uidProvider; + + private Mailbox mailbox; + + @BeforeEach + void setup() { + PostgresMailboxDAO mailboxDAO = new PostgresMailboxDAO(postgresExtension.getPostgresExecutor()); + uidProvider = new PostgresUidProvider(mailboxDAO); + MailboxPath mailboxPath = new MailboxPath("gsoc", Username.of("ieugen" + UUID.randomUUID()), "INBOX"); + UidValidity uidValidity = UidValidity.of(1234); + mailbox = mailboxDAO.create(mailboxPath, uidValidity).block(); + } + + @Test + void lastUidShouldRetrieveValueStoredByNextUid() throws Exception { + int nbEntries = 100; + Optional result = uidProvider.lastUid(mailbox); + assertThat(result).isEmpty(); + LongStream.range(0, nbEntries) + .forEach(Throwing.longConsumer(value -> { + MessageUid uid = uidProvider.nextUid(mailbox); + assertThat(uid).isEqualTo(uidProvider.lastUid(mailbox).get()); + }) + ); + } + + @Test + void nextUidShouldIncrementValueByOne() { + int nbEntries = 100; + LongStream.range(1, nbEntries) + .forEach(Throwing.longConsumer(value -> { + MessageUid result = uidProvider.nextUid(mailbox); + assertThat(value).isEqualTo(result.asLong()); + })); + } + + @Test + void nextUidShouldGenerateUniqueValuesWhenParallelCalls() throws ExecutionException, InterruptedException, MailboxException { + uidProvider.nextUid(mailbox); + int threadCount = 10; + int nbEntries = 100; + + ConcurrentSkipListSet messageUids = new ConcurrentSkipListSet<>(); + ConcurrentTestRunner.builder() + .operation((threadNumber, step) -> messageUids.add(uidProvider.nextUid(mailbox))) + .threadCount(threadCount) + .operationCount(nbEntries / threadCount) + .runSuccessfullyWithin(Duration.ofMinutes(1)); + + assertThat(messageUids).hasSize(nbEntries); + } + + @Test + void nextUidsShouldGenerateUniqueValuesWhenParallelCalls() throws ExecutionException, InterruptedException, MailboxException { + uidProvider.nextUid(mailbox); + + int threadCount = 10; + int nbOperations = 100; + + ConcurrentSkipListSet messageUids = new ConcurrentSkipListSet<>(); + ConcurrentTestRunner.builder() + .operation((threadNumber, step) -> messageUids.addAll(uidProvider.nextUids(mailbox.getMailboxId(), 10).block())) + .threadCount(threadCount) + .operationCount(nbOperations / threadCount) + .runSuccessfullyWithin(Duration.ofMinutes(1)); + + assertThat(messageUids).hasSize(nbOperations * 10); + } + + @Test + void nextUidWithCountShouldReturnCorrectUids() { + int count = 10; + List messageUids = uidProvider.nextUids(mailbox.getMailboxId(), count).block(); + assertThat(messageUids).hasSize(count) + .containsExactlyInAnyOrder( + MessageUid.of(1), + MessageUid.of(2), + MessageUid.of(3), + MessageUid.of(4), + MessageUid.of(5), + MessageUid.of(6), + MessageUid.of(7), + MessageUid.of(8), + MessageUid.of(9), + MessageUid.of(10)); + } + +} From 14a66e6568f66acab804f5f27b4083b6a5ebc3ba Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 22 Nov 2023 10:13:45 +0700 Subject: [PATCH 069/341] JAMES-2586 Implement PostgresModSeqProvider --- .../postgres/mail/PostgresModSeqProvider.java | 92 ++++++++++++++++ .../postgres/mail/dao/PostgresMailboxDAO.java | 20 ++++ .../mail/PostgresModSeqProviderTest.java | 104 ++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresModSeqProvider.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresModSeqProviderTest.java diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresModSeqProvider.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresModSeqProvider.java new file mode 100644 index 00000000000..23734e8138e --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresModSeqProvider.java @@ -0,0 +1,92 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; +import org.apache.james.mailbox.store.mail.ModSeqProvider; + +import reactor.core.publisher.Mono; + +public class PostgresModSeqProvider implements ModSeqProvider { + + public static class Factory { + + private final PostgresExecutor.Factory executorFactory; + + public Factory(PostgresExecutor.Factory executorFactory) { + this.executorFactory = executorFactory; + } + + public PostgresModSeqProvider create(MailboxSession session) { + PostgresExecutor postgresExecutor = executorFactory.create(session.getUser().getDomainPart()); + return new PostgresModSeqProvider(new PostgresMailboxDAO(postgresExecutor)); + } + } + + private final PostgresMailboxDAO mailboxDAO; + + public PostgresModSeqProvider(PostgresMailboxDAO mailboxDAO) { + this.mailboxDAO = mailboxDAO; + } + + @Override + public ModSeq nextModSeq(Mailbox mailbox) throws MailboxException { + return nextModSeq(mailbox.getMailboxId()); + } + + @Override + public ModSeq nextModSeq(MailboxId mailboxId) throws MailboxException { + return nextModSeqReactive(mailboxId) + .blockOptional() + .orElseThrow(() -> new MailboxException("Can not retrieve modseq for " + mailboxId)); + } + + @Override + public ModSeq highestModSeq(Mailbox mailbox) { + return highestModSeqReactive(mailbox).block(); + } + + @Override + public Mono highestModSeqReactive(Mailbox mailbox) { + return getHighestModSeq(mailbox.getMailboxId()); + } + + private Mono getHighestModSeq(MailboxId mailboxId) { + return mailboxDAO.findHighestModSeqByMailboxId(mailboxId) + .defaultIfEmpty(ModSeq.first()); + } + + @Override + public ModSeq highestModSeq(MailboxId mailboxId) { + return getHighestModSeq(mailboxId).block(); + } + + @Override + public Mono nextModSeqReactive(MailboxId mailboxId) { + return mailboxDAO.incrementAndGetModSeq(mailboxId) + .defaultIfEmpty(ModSeq.first()); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java index de6a42e8267..c63909a43ab 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java @@ -20,6 +20,7 @@ package org.apache.james.mailbox.postgres.mail.dao; import static org.apache.james.mailbox.postgres.PostgresMailboxIdFaker.getMailboxId; +import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_HIGHEST_MODSEQ; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_ID; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_LAST_UID; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_NAME; @@ -33,6 +34,7 @@ import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.Username; import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; import org.apache.james.mailbox.exception.MailboxExistsException; import org.apache.james.mailbox.exception.MailboxNotFoundException; import org.apache.james.mailbox.model.Mailbox; @@ -158,4 +160,22 @@ public Mono incrementAndGetLastUid(MailboxId mailboxId, int count) { .map(record -> record.get(MAILBOX_LAST_UID)) .map(MessageUid::of); } + + + public Mono findHighestModSeqByMailboxId(MailboxId mailboxId) { + return postgresExecutor.executeRow(dsl -> Mono.from(dsl.select(MAILBOX_HIGHEST_MODSEQ) + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(getMailboxId(mailboxId).asUuid())))) + .flatMap(record -> Mono.justOrEmpty(record.get(MAILBOX_HIGHEST_MODSEQ))) + .map(ModSeq::of); + } + + public Mono incrementAndGetModSeq(MailboxId mailboxId) { + return postgresExecutor.executeRow(dsl -> Mono.from(dsl.update(TABLE_NAME) + .set(MAILBOX_HIGHEST_MODSEQ, coalesce(MAILBOX_HIGHEST_MODSEQ, 0L).add(1)) + .where(MAILBOX_ID.eq(getMailboxId(mailboxId).asUuid())) + .returning(MAILBOX_HIGHEST_MODSEQ))) + .map(record -> record.get(MAILBOX_HIGHEST_MODSEQ)) + .map(ModSeq::of); + } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresModSeqProviderTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresModSeqProviderTest.java new file mode 100644 index 00000000000..eff361562c2 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresModSeqProviderTest.java @@ -0,0 +1,104 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.ConcurrentSkipListSet; +import java.util.concurrent.ExecutionException; +import java.util.stream.LongStream; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.core.Username; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.UidValidity; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; +import org.apache.james.mailbox.store.mail.ModSeqProvider; +import org.apache.james.util.concurrency.ConcurrentTestRunner; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.github.fge.lambdas.Throwing; + +public class PostgresModSeqProviderTest { + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxModule.MODULE); + + private ModSeqProvider modSeqProvider; + + private Mailbox mailbox; + + @BeforeEach + void setup() { + PostgresMailboxDAO mailboxDAO = new PostgresMailboxDAO(postgresExtension.getPostgresExecutor()); + modSeqProvider = new PostgresModSeqProvider(mailboxDAO); + MailboxPath mailboxPath = new MailboxPath("gsoc", Username.of("ieugen" + UUID.randomUUID()), "INBOX"); + UidValidity uidValidity = UidValidity.of(1234); + mailbox = mailboxDAO.create(mailboxPath, uidValidity).block(); + } + + @Test + void highestModSeqShouldRetrieveValueStoredNextModSeq() throws Exception { + int nbEntries = 100; + ModSeq result = modSeqProvider.highestModSeq(mailbox); + assertThat(result).isEqualTo(ModSeq.first()); + LongStream.range(0, nbEntries) + .forEach(Throwing.longConsumer(value -> { + ModSeq modSeq = modSeqProvider.nextModSeq(mailbox); + assertThat(modSeq).isEqualTo(modSeqProvider.highestModSeq(mailbox)); + }) + ); + } + + @Test + void nextModSeqShouldIncrementValueByOne() throws Exception { + int nbEntries = 100; + ModSeq lastModSeq = modSeqProvider.highestModSeq(mailbox); + LongStream.range(lastModSeq.asLong() + 1, lastModSeq.asLong() + nbEntries) + .forEach(Throwing.longConsumer(value -> { + ModSeq result = modSeqProvider.nextModSeq(mailbox); + assertThat(result.asLong()).isEqualTo(value); + })); + } + + @Test + void nextModSeqShouldGenerateUniqueValuesWhenParallelCalls() throws ExecutionException, InterruptedException, MailboxException { + modSeqProvider.nextModSeq(mailbox); + + ConcurrentSkipListSet modSeqs = new ConcurrentSkipListSet<>(); + int nbEntries = 10; + + ConcurrentTestRunner.builder() + .operation( + (threadNumber, step) -> modSeqs.add(modSeqProvider.nextModSeq(mailbox))) + .threadCount(10) + .operationCount(nbEntries) + .runSuccessfullyWithin(Duration.ofMinutes(1)); + + assertThat(modSeqs).hasSize(100); + } +} From 84aa95e92e554e0f5c7f28db96e412a4d83adb46 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 22 Nov 2023 10:16:27 +0700 Subject: [PATCH 070/341] JAMES-2586 Implement PostgresExecutor Factory and Mailbox Aggregate Module --- .../postgres/utils/PostgresExecutor.java | 21 ++++++++++++- .../PostgresMailboxAggregateModule.java | 31 +++++++++++++++++++ .../postgres/JPAMailboxManagerTest.java | 3 +- .../postgres/JpaMailboxManagerStressTest.java | 3 +- .../host/PostgresHostSystemExtension.java | 4 +-- .../mailbox/PostgresMailboxModule.java | 4 +-- .../modules/data/PostgresCommonModule.java | 6 ++-- 7 files changed, 61 insertions(+), 11 deletions(-) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAggregateModule.java diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index 30f2812a8ad..3b3fd015694 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -20,10 +20,12 @@ package org.apache.james.backends.postgres.utils; import java.util.List; +import java.util.Optional; import java.util.function.Function; import javax.inject.Inject; +import org.apache.james.core.Domain; import org.jooq.DSLContext; import org.jooq.Record; import org.jooq.SQLDialect; @@ -39,13 +41,30 @@ public class PostgresExecutor { + public static class Factory { + + private final JamesPostgresConnectionFactory jamesPostgresConnectionFactory; + + @Inject + public Factory(JamesPostgresConnectionFactory jamesPostgresConnectionFactory) { + this.jamesPostgresConnectionFactory = jamesPostgresConnectionFactory; + } + + public PostgresExecutor create(Optional domain) { + return new PostgresExecutor(jamesPostgresConnectionFactory.getConnection(domain)); + } + + public PostgresExecutor create() { + return create(Optional.empty()); + } + } + private static final SQLDialect PGSQL_DIALECT = SQLDialect.POSTGRES; private static final Settings SETTINGS = new Settings() .withRenderFormatted(true) .withStatementType(StatementType.PREPARED_STATEMENT); private final Mono connection; - @Inject public PostgresExecutor(Mono connection) { this.connection = connection; } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAggregateModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAggregateModule.java new file mode 100644 index 00000000000..db208dd9750 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAggregateModule.java @@ -0,0 +1,31 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres; + +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.mailbox.postgres.mail.PostgresMailboxModule; +import org.apache.james.mailbox.postgres.user.PostgresSubscriptionModule; + +public interface PostgresMailboxAggregateModule { + + PostgresModule MODULE = PostgresModule.aggregateModules( + PostgresMailboxModule.MODULE, + PostgresSubscriptionModule.MODULE); +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxManagerTest.java index 53871f68f86..bc98c13a50c 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxManagerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxManagerTest.java @@ -26,7 +26,6 @@ import org.apache.james.mailbox.MailboxManagerTest; import org.apache.james.mailbox.SubscriptionManager; import org.apache.james.mailbox.postgres.openjpa.OpenJPAMailboxManager; -import org.apache.james.mailbox.postgres.user.PostgresSubscriptionModule; import org.apache.james.mailbox.store.StoreSubscriptionManager; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Disabled; @@ -43,7 +42,7 @@ class HookTests { } @RegisterExtension - static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresSubscriptionModule.MODULE); + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); Optional openJPAMailboxManager = Optional.empty(); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerStressTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerStressTest.java index 2abd96da331..ea1ce952e42 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerStressTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerStressTest.java @@ -26,7 +26,6 @@ import org.apache.james.events.EventBus; import org.apache.james.mailbox.MailboxManagerStressContract; import org.apache.james.mailbox.postgres.openjpa.OpenJPAMailboxManager; -import org.apache.james.mailbox.postgres.user.PostgresSubscriptionModule; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.RegisterExtension; @@ -34,7 +33,7 @@ class JpaMailboxManagerStressTest implements MailboxManagerStressContract { @RegisterExtension - static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresSubscriptionModule.MODULE); + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); Optional openJPAMailboxManager = Optional.empty(); diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystemExtension.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystemExtension.java index 298c9a222a3..8ec2e1df875 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystemExtension.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystemExtension.java @@ -20,7 +20,7 @@ package org.apache.james.mpt.imapmailbox.postgres.host; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.mailbox.postgres.user.PostgresSubscriptionModule; +import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; import org.apache.james.mpt.host.JamesImapHostSystem; import org.junit.jupiter.api.extension.AfterAllCallback; import org.junit.jupiter.api.extension.AfterEachCallback; @@ -36,7 +36,7 @@ public class PostgresHostSystemExtension implements BeforeEachCallback, AfterEac private final PostgresExtension postgresExtension; public PostgresHostSystemExtension() { - this.postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresSubscriptionModule.MODULE); + this.postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); try { hostSystem = PostgresHostSystem.build(postgresExtension); } catch (Exception e) { diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java index a8c132a6dca..4ef3119e078 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java @@ -19,7 +19,7 @@ package org.apache.james.modules.mailbox; import org.apache.james.backends.postgres.PostgresModule; -import org.apache.james.mailbox.postgres.user.PostgresSubscriptionModule; +import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; import org.apache.james.modules.data.PostgresCommonModule; import com.google.inject.AbstractModule; @@ -32,7 +32,7 @@ protected void configure() { install(new PostgresCommonModule()); Multibinder postgresDataDefinitions = Multibinder.newSetBinder(binder(), PostgresModule.class); - postgresDataDefinitions.addBinding().toInstance(PostgresSubscriptionModule.MODULE); + postgresDataDefinitions.addBinding().toInstance(PostgresMailboxAggregateModule.MODULE); } } \ No newline at end of file diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index e9f53a70466..5492c65893d 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -21,14 +21,13 @@ import java.io.FileNotFoundException; import java.util.Set; -import javax.inject.Singleton; - import org.apache.commons.configuration2.ex.ConfigurationException; import org.apache.james.backends.postgres.PostgresConfiguration; import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.backends.postgres.PostgresTableManager; import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.backends.postgres.utils.SinglePostgresConnectionFactory; import org.apache.james.utils.InitializationOperation; import org.apache.james.utils.InitilizationOperationBuilder; @@ -38,6 +37,8 @@ import com.google.inject.AbstractModule; import com.google.inject.Provides; +import com.google.inject.Scopes; +import com.google.inject.Singleton; import com.google.inject.multibindings.Multibinder; import com.google.inject.multibindings.ProvidesIntoSet; @@ -52,6 +53,7 @@ public class PostgresCommonModule extends AbstractModule { @Override public void configure() { Multibinder.newSetBinder(binder(), PostgresModule.class); + bind(PostgresExecutor.Factory.class).in(Scopes.SINGLETON); } @Provides From 5864179e0215c4d9c1e00f055be1227d153420eb Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 21 Nov 2023 15:28:02 +0700 Subject: [PATCH 071/341] JAMES-2586 Implement MailboxACL support for PostgresMailboxMapper Co-authored-by: Tung TRAN --- backends-common/postgres/pom.xml | 5 ++ .../backends/postgres/PostgresExtension.java | 46 ++++++++++--- .../postgres/mail/PostgresMailboxMapper.java | 23 +++++-- .../postgres/mail/PostgresMailboxModule.java | 4 ++ .../postgres/mail/dao/PostgresMailboxDAO.java | 64 ++++++++++++++++++- .../mail/PostgresMailboxMapperACLTest.java | 36 +++++++++++ 6 files changed, 159 insertions(+), 19 deletions(-) create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperACLTest.java diff --git a/backends-common/postgres/pom.xml b/backends-common/postgres/pom.xml index 2e87eb59ead..e204034eccb 100644 --- a/backends-common/postgres/pom.xml +++ b/backends-common/postgres/pom.xml @@ -70,6 +70,11 @@ jooq ${jooq.version} + + org.jooq + jooq-postgres-extensions + ${jooq.version} + org.postgresql r2dbc-postgresql diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index d6f65b6f7ab..4f9ba51094a 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -22,7 +22,10 @@ import static org.apache.james.backends.postgres.PostgresFixture.Database.DEFAULT_DATABASE; import static org.apache.james.backends.postgres.PostgresFixture.Database.ROW_LEVEL_SECURITY_DATABASE; +import java.io.IOException; import java.net.URISyntaxException; +import java.util.List; +import java.util.stream.Collectors; import org.apache.http.client.utils.URIBuilder; import org.apache.james.GuiceModuleTestExtension; @@ -79,16 +82,23 @@ public void beforeAll(ExtensionContext extensionContext) throws Exception { PG_CONTAINER.start(); } querySettingRowLevelSecurityIfNeed(); + querySettingExtension(); initPostgresSession(); } private void querySettingRowLevelSecurityIfNeed() { - Throwing.runnable(() -> { - PG_CONTAINER.execInContainer("psql", "-U", DEFAULT_DATABASE.dbUser(), "-c", "create user " + ROW_LEVEL_SECURITY_DATABASE.dbUser() + " WITH PASSWORD '" + ROW_LEVEL_SECURITY_DATABASE.dbPassword() + "';"); - PG_CONTAINER.execInContainer("psql", "-U", DEFAULT_DATABASE.dbUser(), "-c", "create database " + ROW_LEVEL_SECURITY_DATABASE.dbName() + ";"); - PG_CONTAINER.execInContainer("psql", "-U", DEFAULT_DATABASE.dbUser(), "-c", "grant all privileges on database " + ROW_LEVEL_SECURITY_DATABASE.dbName() + " to " + ROW_LEVEL_SECURITY_DATABASE.dbUser() + ";"); - PG_CONTAINER.execInContainer("psql", "-U", ROW_LEVEL_SECURITY_DATABASE.dbUser(), "-d", ROW_LEVEL_SECURITY_DATABASE.dbName(), "-c", "create schema if not exists " + ROW_LEVEL_SECURITY_DATABASE.schema() + ";"); - }).sneakyThrow().run(); + if (rlsEnabled) { + Throwing.runnable(() -> { + PG_CONTAINER.execInContainer("psql", "-U", DEFAULT_DATABASE.dbUser(), "-c", "create user " + ROW_LEVEL_SECURITY_DATABASE.dbUser() + " WITH PASSWORD '" + ROW_LEVEL_SECURITY_DATABASE.dbPassword() + "';"); + PG_CONTAINER.execInContainer("psql", "-U", DEFAULT_DATABASE.dbUser(), "-c", "create database " + ROW_LEVEL_SECURITY_DATABASE.dbName() + ";"); + PG_CONTAINER.execInContainer("psql", "-U", DEFAULT_DATABASE.dbUser(), "-c", "grant all privileges on database " + ROW_LEVEL_SECURITY_DATABASE.dbName() + " to " + ROW_LEVEL_SECURITY_DATABASE.dbUser() + ";"); + PG_CONTAINER.execInContainer("psql", "-U", ROW_LEVEL_SECURITY_DATABASE.dbUser(), "-d", ROW_LEVEL_SECURITY_DATABASE.dbName(), "-c", "create schema if not exists " + ROW_LEVEL_SECURITY_DATABASE.schema() + ";"); + }).sneakyThrow().run(); + } + } + + private void querySettingExtension() throws IOException, InterruptedException { + PG_CONTAINER.execInContainer("psql", "-U", selectedDatabase.dbUser(), selectedDatabase.dbName(), "-c", String.format("CREATE EXTENSION IF NOT EXISTS hstore SCHEMA %s;", selectedDatabase.schema())); } private void initPostgresSession() throws URISyntaxException { @@ -177,10 +187,26 @@ private void initTablesAndIndexes() { } private void resetSchema() { - getConnection() - .flatMapMany(connection -> Mono.from(connection.createStatement("DROP SCHEMA " + selectedDatabase.schema() + " CASCADE").execute()) - .then(Mono.from(connection.createStatement("CREATE SCHEMA " + selectedDatabase.schema() + " AUTHORIZATION " + selectedDatabase.dbUser()).execute())) - .flatMap(result -> Mono.from(result.getRowsUpdated()))) + dropTables(listAllTables()); + } + + private void dropTables(List tables) { + String tablesToDelete = tables.stream() + .map(tableName -> "\"" + tableName + "\"") + .collect(Collectors.joining(", ")); + + postgresExecutor.connection() + .flatMapMany(connection -> connection.createStatement(String.format("DROP table if exists %s cascade;", tablesToDelete)) + .execute()) + .then() + .block(); + } + + private List listAllTables() { + return postgresExecutor.connection() + .flatMapMany(connection -> connection.createStatement(String.format("SELECT tablename FROM pg_tables WHERE schemaname = '%s'", selectedDatabase.schema())) + .execute()) + .flatMap(result -> result.map((row, rowMetadata) -> row.get(0, String.class))) .collectList() .block(); } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java index 787ca65cedc..f44a4fb0c54 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java @@ -20,7 +20,6 @@ package org.apache.james.mailbox.postgres.mail; import javax.inject.Inject; -import javax.naming.OperationNotSupportedException; import org.apache.james.core.Username; import org.apache.james.mailbox.acl.ACLDiff; @@ -33,6 +32,8 @@ import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; import org.apache.james.mailbox.store.mail.MailboxMapper; +import com.github.fge.lambdas.Throwing; + import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -86,19 +87,27 @@ public Flux list() { @Override public Flux findNonPersonalMailboxes(Username userName, MailboxACL.Right right) { - // TODO - return Flux.error(new OperationNotSupportedException()); + return postgresMailboxDAO.findNonPersonalMailboxes(userName, right); } @Override public Mono updateACL(Mailbox mailbox, MailboxACL.ACLCommand mailboxACLCommand) { - // TODO - return Mono.error(new OperationNotSupportedException()); + MailboxACL oldACL = mailbox.getACL(); + MailboxACL newACL = Throwing.supplier(() -> oldACL.apply(mailboxACLCommand)).get(); + return postgresMailboxDAO.upsertACL(mailbox.getMailboxId(), newACL) + .map(updatedACL -> { + mailbox.setACL(updatedACL); + return ACLDiff.computeDiff(oldACL, updatedACL); + }); } @Override public Mono setACL(Mailbox mailbox, MailboxACL mailboxACL) { - // TODO - return Mono.error(new OperationNotSupportedException()); + MailboxACL oldACL = mailbox.getACL(); + return postgresMailboxDAO.upsertACL(mailbox.getMailboxId(), mailboxACL) + .map(updatedACL -> { + mailbox.setACL(updatedACL); + return ACLDiff.computeDiff(oldACL, updatedACL); + }); } } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java index 9c9b424c482..af8b1dca049 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java @@ -30,6 +30,8 @@ import org.jooq.Table; import org.jooq.impl.DSL; import org.jooq.impl.SQLDataType; +import org.jooq.postgres.extensions.bindings.HstoreBinding; +import org.jooq.postgres.extensions.types.Hstore; public interface PostgresMailboxModule { interface PostgresMailboxTable { @@ -42,6 +44,7 @@ interface PostgresMailboxTable { Field MAILBOX_NAMESPACE = DSL.field("mailbox_namespace", SQLDataType.VARCHAR(255).notNull()); Field MAILBOX_LAST_UID = DSL.field("mailbox_last_uid", BIGINT); Field MAILBOX_HIGHEST_MODSEQ = DSL.field("mailbox_highest_modseq", BIGINT); + Field MAILBOX_ACL = DSL.field("mailbox_acl", org.jooq.impl.DefaultDataType.getDefaultDataType("hstore").asConvertedDataType(new HstoreBinding())); PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) @@ -52,6 +55,7 @@ interface PostgresMailboxTable { .column(MAILBOX_NAMESPACE) .column(MAILBOX_LAST_UID) .column(MAILBOX_HIGHEST_MODSEQ) + .column(MAILBOX_ACL) .constraint(DSL.primaryKey(MAILBOX_ID)) .constraint(DSL.unique(MAILBOX_NAME, USER_NAME, MAILBOX_NAMESPACE)))) .supportsRowLevelSecurity(); diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java index c63909a43ab..ab0aec95320 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java @@ -20,6 +20,7 @@ package org.apache.james.mailbox.postgres.mail.dao; import static org.apache.james.mailbox.postgres.PostgresMailboxIdFaker.getMailboxId; +import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_ACL; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_HIGHEST_MODSEQ; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_ID; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_LAST_UID; @@ -31,13 +32,21 @@ import static org.jooq.impl.DSL.coalesce; import static org.jooq.impl.DSL.count; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.Username; import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.ModSeq; import org.apache.james.mailbox.exception.MailboxExistsException; import org.apache.james.mailbox.exception.MailboxNotFoundException; +import org.apache.james.mailbox.exception.UnsupportedRightException; import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxACL; import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.MailboxPath; import org.apache.james.mailbox.model.UidValidity; @@ -46,15 +55,46 @@ import org.apache.james.mailbox.store.MailboxExpressionBackwardCompatibility; import org.jooq.Record; import org.jooq.exception.DataAccessException; +import org.jooq.impl.DSL; +import org.jooq.postgres.extensions.types.Hstore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableMap; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; public class PostgresMailboxDAO { + private static final Logger LOGGER = LoggerFactory.getLogger(PostgresMailboxDAO.class); private static final char SQL_WILDCARD_CHAR = '%'; private static final String DUPLICATE_VIOLATION_MESSAGE = "duplicate key value violates unique constraint"; + private static final Function MAILBOX_ACL_TO_HSTORE_FUNCTION = acl -> Hstore.hstore(acl.getEntries() + .entrySet() + .stream() + .collect(Collectors.toMap( + entry -> entry.getKey().serialize(), + entry -> entry.getValue().serialize()))); + + private static final Function HSTORE_TO_MAILBOX_ACL_FUNCTION = hstore -> new MailboxACL(hstore.data() + .entrySet() + .stream() + .map(entry -> deserializeMailboxACLEntry(entry.getKey(), entry.getValue())) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(ImmutableMap.toImmutableMap(Map.Entry::getKey, Map.Entry::getValue))); + + private static Optional> deserializeMailboxACLEntry(String key, String value) { + try { + MailboxACL.EntryKey entryKey = MailboxACL.EntryKey.deserialize(key); + MailboxACL.Rfc4314Rights rfc4314Rights = MailboxACL.Rfc4314Rights.deserialize(value); + return Optional.of(Map.entry(entryKey, rfc4314Rights)); + } catch (UnsupportedRightException e) { + LOGGER.error("Error while deserializing mailbox ACL", e); + return Optional.empty(); + } + } private final PostgresExecutor postgresExecutor; @@ -66,7 +106,7 @@ public Mono create(MailboxPath mailboxPath, UidValidity uidValidity) { final PostgresMailboxId mailboxId = PostgresMailboxId.generate(); return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME, MAILBOX_ID, MAILBOX_NAME, USER_NAME, MAILBOX_NAMESPACE, MAILBOX_UID_VALIDITY) - .values(mailboxId.asUuid(), mailboxPath.getName(), mailboxPath.getUser().asString(), mailboxPath.getNamespace(), uidValidity.asLong()))) + .values(mailboxId.asUuid(), mailboxPath.getName(), mailboxPath.getUser().asString(), mailboxPath.getNamespace(), uidValidity.asLong()))) .thenReturn(new Mailbox(mailboxPath, uidValidity, mailboxId)) .onErrorMap(e -> e instanceof DataAccessException && e.getMessage().contains(DUPLICATE_VIOLATION_MESSAGE), e -> new MailboxExistsException(mailboxPath.getName())); @@ -91,6 +131,24 @@ private Mono update(Mailbox mailbox) { .switchIfEmpty(Mono.error(new MailboxNotFoundException(mailbox.getMailboxId()))); } + public Mono upsertACL(MailboxId mailboxId, MailboxACL acl) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.update(TABLE_NAME) + .set(MAILBOX_ACL, MAILBOX_ACL_TO_HSTORE_FUNCTION.apply(acl)) + .where(MAILBOX_ID.eq(((PostgresMailboxId) mailboxId).asUuid())) + .returning(MAILBOX_ACL))) + .map(record -> HSTORE_TO_MAILBOX_ACL_FUNCTION.apply(record.get(MAILBOX_ACL))); + } + + public Flux findNonPersonalMailboxes(Username userName, MailboxACL.Right right) { + String mailboxACLEntryByUser = String.format("mailbox_acl -> '%s'", userName.asString()); + + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.selectFrom(TABLE_NAME) + .where(MAILBOX_ACL.isNotNull(), + DSL.field(mailboxACLEntryByUser).isNotNull(), + DSL.field(mailboxACLEntryByUser).contains(Character.toString(right.asCharacter()))))) + .map(this::asMailbox); + } + public Mono delete(MailboxId mailboxId) { return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(TABLE_NAME) .where(MAILBOX_ID.eq(((PostgresMailboxId) mailboxId).asUuid())))); @@ -140,8 +198,10 @@ public Flux getAll() { } private Mailbox asMailbox(Record record) { - return new Mailbox(new MailboxPath(record.get(MAILBOX_NAMESPACE), Username.of(record.get(USER_NAME)), record.get(MAILBOX_NAME)), + Mailbox mailbox = new Mailbox(new MailboxPath(record.get(MAILBOX_NAMESPACE), Username.of(record.get(USER_NAME)), record.get(MAILBOX_NAME)), UidValidity.of(record.get(MAILBOX_UID_VALIDITY)), PostgresMailboxId.of(record.get(MAILBOX_ID))); + mailbox.setACL(HSTORE_TO_MAILBOX_ACL_FUNCTION.apply(Hstore.hstore(record.get(MAILBOX_ACL, LinkedHashMap.class)))); + return mailbox; } public Mono findLastUidByMailboxId(MailboxId mailboxId) { diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperACLTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperACLTest.java new file mode 100644 index 00000000000..4b73a298ea5 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperACLTest.java @@ -0,0 +1,36 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; +import org.apache.james.mailbox.store.mail.MailboxMapper; +import org.apache.james.mailbox.store.mail.model.MailboxMapperACLTest; +import org.junit.jupiter.api.extension.RegisterExtension; + +class PostgresMailboxMapperACLTest extends MailboxMapperACLTest { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxModule.MODULE); + + @Override + protected MailboxMapper createMailboxMapper() { + return new PostgresMailboxMapper(new PostgresMailboxDAO(postgresExtension.getPostgresExecutor())); + } +} From c262bbf126a1004bf22d8a50682df0f944be970a Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 21 Nov 2023 16:35:57 +0700 Subject: [PATCH 072/341] JAMES-2586 Create hstore extension if needed upon James startup --- .../james/backends/postgres/PostgresTableManager.java | 8 ++++++++ .../apache/james/modules/data/PostgresCommonModule.java | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java index c7b2ff1bf71..a46e6b36a25 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java @@ -58,6 +58,14 @@ public PostgresTableManager(PostgresExecutor postgresExecutor, PostgresModule mo this.rowLevelSecurityEnabled = rowLevelSecurityEnabled; } + public Mono initializePostgresExtension() { + return postgresExecutor.connection() + .flatMapMany(connection -> connection.createStatement("CREATE EXTENSION IF NOT EXISTS hstore") + .execute()) + .flatMap(Result::getRowsUpdated) + .then(); + } + public Mono initializeTables() { return postgresExecutor.dslContext() .flatMap(dsl -> Flux.fromIterable(module.tables()) diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index 5492c65893d..d33097bc4f0 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -105,7 +105,8 @@ PostgresTableManager postgresTableManager(JamesPostgresConnectionFactory jamesPo InitializationOperation provisionPostgresTablesAndIndexes(PostgresTableManager postgresTableManager) { return InitilizationOperationBuilder .forClass(PostgresTableManager.class) - .init(() -> postgresTableManager.initializeTables() + .init(() -> postgresTableManager.initializePostgresExtension() + .then(postgresTableManager.initializeTables()) .then(postgresTableManager.initializeTableIndexes()) .block()); } From a4e8bc1ed3534632ced5bad75291fbb949711d91 Mon Sep 17 00:00:00 2001 From: hungphan227 <45198168+hungphan227@users.noreply.github.com> Date: Thu, 23 Nov 2023 11:02:46 +0700 Subject: [PATCH 073/341] JAMES-2586 postgres users dao and repository (#1803) --- .../postgres/utils/PostgresUtils.java | 31 ++ .../postgres/mail/dao/PostgresMailboxDAO.java | 10 +- .../apache/james/PostgresJamesServerMain.java | 12 +- .../main/resources/META-INF/persistence.xml | 1 - ...ataModule.java => PostgresDataModule.java} | 3 +- .../data/PostgresUsersRepositoryModule.java | 57 ++++ server/data/data-postgres/pom.xml | 11 + .../apache/james/user/jpa/JPAUsersDAO.java | 267 ------------------ .../apache/james/user/jpa/model/JPAUser.java | 193 ------------- .../user/postgres/PostgresUserModule.java | 50 ++++ .../james/user/postgres/PostgresUsersDAO.java | 143 ++++++++++ .../postgres/PostgresUsersRepository.java} | 28 +- ...PostgresUsersRepositoryConfiguration.java} | 57 ++-- .../rrt/jpa/JPARecipientRewriteTableTest.java | 5 +- .../org/apache/james/rrt/jpa/JPAStepdefs.java | 7 +- .../james/user/jpa/model/JPAUserTest.java | 73 ----- .../PostgresUsersRepositoryTest.java} | 41 ++- 17 files changed, 366 insertions(+), 623 deletions(-) create mode 100644 backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresUtils.java rename server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/{JPADataModule.java => PostgresDataModule.java} (96%) create mode 100644 server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresUsersRepositoryModule.java delete mode 100644 server/data/data-postgres/src/main/java/org/apache/james/user/jpa/JPAUsersDAO.java delete mode 100644 server/data/data-postgres/src/main/java/org/apache/james/user/jpa/model/JPAUser.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUserModule.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java rename server/{container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAUsersRepositoryModule.java => data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersRepository.java} (53%) rename server/data/data-postgres/src/main/java/org/apache/james/user/{jpa/JPAUsersRepository.java => postgres/PostgresUsersRepositoryConfiguration.java} (50%) delete mode 100644 server/data/data-postgres/src/test/java/org/apache/james/user/jpa/model/JPAUserTest.java rename server/data/data-postgres/src/test/java/org/apache/james/user/{jpa/JpaUsersRepositoryTest.java => postgres/PostgresUsersRepositoryTest.java} (74%) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresUtils.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresUtils.java new file mode 100644 index 00000000000..9f8b075c14a --- /dev/null +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresUtils.java @@ -0,0 +1,31 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres.utils; + +import java.util.function.Predicate; + +import org.jooq.exception.DataAccessException; + +public class PostgresUtils { + private static final String UNIQUE_CONSTRAINT_VIOLATION_MESSAGE = "duplicate key value violates unique constraint"; + + public static final Predicate UNIQUE_CONSTRAINT_VIOLATION_PREDICATE = + throwable -> throwable instanceof DataAccessException && throwable.getMessage().contains(UNIQUE_CONSTRAINT_VIOLATION_MESSAGE); +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java index ab0aec95320..f820a2ce075 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java @@ -19,6 +19,7 @@ package org.apache.james.mailbox.postgres.mail.dao; +import static org.apache.james.backends.postgres.utils.PostgresUtils.UNIQUE_CONSTRAINT_VIOLATION_PREDICATE; import static org.apache.james.mailbox.postgres.PostgresMailboxIdFaker.getMailboxId; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_ACL; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_HIGHEST_MODSEQ; @@ -54,7 +55,6 @@ import org.apache.james.mailbox.postgres.PostgresMailboxId; import org.apache.james.mailbox.store.MailboxExpressionBackwardCompatibility; import org.jooq.Record; -import org.jooq.exception.DataAccessException; import org.jooq.impl.DSL; import org.jooq.postgres.extensions.types.Hstore; import org.slf4j.Logger; @@ -69,7 +69,6 @@ public class PostgresMailboxDAO { private static final Logger LOGGER = LoggerFactory.getLogger(PostgresMailboxDAO.class); private static final char SQL_WILDCARD_CHAR = '%'; - private static final String DUPLICATE_VIOLATION_MESSAGE = "duplicate key value violates unique constraint"; private static final Function MAILBOX_ACL_TO_HSTORE_FUNCTION = acl -> Hstore.hstore(acl.getEntries() .entrySet() .stream() @@ -105,10 +104,11 @@ public PostgresMailboxDAO(PostgresExecutor postgresExecutor) { public Mono create(MailboxPath mailboxPath, UidValidity uidValidity) { final PostgresMailboxId mailboxId = PostgresMailboxId.generate(); - return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME, MAILBOX_ID, MAILBOX_NAME, USER_NAME, MAILBOX_NAMESPACE, MAILBOX_UID_VALIDITY) - .values(mailboxId.asUuid(), mailboxPath.getName(), mailboxPath.getUser().asString(), mailboxPath.getNamespace(), uidValidity.asLong()))) + return postgresExecutor.executeVoid(dslContext -> + Mono.from(dslContext.insertInto(TABLE_NAME, MAILBOX_ID, MAILBOX_NAME, USER_NAME, MAILBOX_NAMESPACE, MAILBOX_UID_VALIDITY) + .values(mailboxId.asUuid(), mailboxPath.getName(), mailboxPath.getUser().asString(), mailboxPath.getNamespace(), uidValidity.asLong()))) .thenReturn(new Mailbox(mailboxPath, uidValidity, mailboxId)) - .onErrorMap(e -> e instanceof DataAccessException && e.getMessage().contains(DUPLICATE_VIOLATION_MESSAGE), + .onErrorMap(UNIQUE_CONSTRAINT_VIOLATION_PREDICATE, e -> new MailboxExistsException(mailboxPath.getName())); } diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index fe536100f79..7c5f47c086d 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -23,8 +23,8 @@ import org.apache.james.modules.MailboxModule; import org.apache.james.modules.MailetProcessingModule; import org.apache.james.modules.RunArgumentsModule; -import org.apache.james.modules.data.JPADataModule; -import org.apache.james.modules.data.JPAUsersRepositoryModule; +import org.apache.james.modules.data.PostgresDataModule; +import org.apache.james.modules.data.PostgresUsersRepositoryModule; import org.apache.james.modules.data.SieveJPARepositoryModules; import org.apache.james.modules.mailbox.DefaultEventModule; import org.apache.james.modules.mailbox.JPAMailboxModule; @@ -82,9 +82,9 @@ public class PostgresJamesServerMain implements JamesServerMain { new ActiveMQQueueModule(), new NaiveDelegationStoreModule(), new DefaultProcessorsConfigurationProviderModule(), - new JPADataModule(), new JPAMailboxModule(), new PostgresMailboxModule(), + new PostgresDataModule(), new MailboxModule(), new LuceneSearchMailboxModule(), new NoJwtModule(), @@ -114,8 +114,8 @@ public static void main(String[] args) throws Exception { static GuiceJamesServer createServer(PostgresJamesConfiguration configuration) { return GuiceJamesServer.forConfiguration(configuration) - .combineWith(POSTGRES_MODULE_AGGREGATE) - .combineWith(new UsersRepositoryModuleChooser(new JPAUsersRepositoryModule()) - .chooseModules(configuration.getUsersRepositoryImplementation())); + .combineWith(new UsersRepositoryModuleChooser(new PostgresUsersRepositoryModule()) + .chooseModules(configuration.getUsersRepositoryImplementation())) + .combineWith(POSTGRES_MODULE_AGGREGATE); } } diff --git a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml index d9e49513f37..5d55f9b7673 100644 --- a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml +++ b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml @@ -33,7 +33,6 @@ org.apache.james.domainlist.jpa.model.JPADomain org.apache.james.mailrepository.jpa.model.JPAUrl org.apache.james.mailrepository.jpa.model.JPAMail - org.apache.james.user.jpa.model.JPAUser org.apache.james.rrt.jpa.model.JPARecipientRewrite org.apache.james.sieve.jpa.model.JPASieveQuota org.apache.james.sieve.jpa.model.JPASieveScript diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPADataModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataModule.java similarity index 96% rename from server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPADataModule.java rename to server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataModule.java index ff1b84b4495..125746063b1 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPADataModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataModule.java @@ -16,13 +16,14 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ + package org.apache.james.modules.data; import org.apache.james.CoreDataModule; import com.google.inject.AbstractModule; -public class JPADataModule extends AbstractModule { +public class PostgresDataModule extends AbstractModule { @Override protected void configure() { install(new CoreDataModule()); diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresUsersRepositoryModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresUsersRepositoryModule.java new file mode 100644 index 00000000000..99289c5ce41 --- /dev/null +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresUsersRepositoryModule.java @@ -0,0 +1,57 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.data; + +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.server.core.configuration.ConfigurationProvider; +import org.apache.james.user.api.UsersRepository; +import org.apache.james.user.lib.UsersDAO; +import org.apache.james.user.postgres.PostgresUserModule; +import org.apache.james.user.postgres.PostgresUsersDAO; +import org.apache.james.user.postgres.PostgresUsersRepository; +import org.apache.james.user.postgres.PostgresUsersRepositoryConfiguration; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import com.google.inject.Scopes; +import com.google.inject.Singleton; +import com.google.inject.multibindings.Multibinder; + +public class PostgresUsersRepositoryModule extends AbstractModule { + @Override + public void configure() { + bind(PostgresUsersRepository.class).in(Scopes.SINGLETON); + bind(UsersRepository.class).to(PostgresUsersRepository.class); + + bind(PostgresUsersDAO.class).in(Scopes.SINGLETON); + bind(UsersDAO.class).to(PostgresUsersDAO.class); + + Multibinder postgresDataDefinitions = Multibinder.newSetBinder(binder(), PostgresModule.class); + postgresDataDefinitions.addBinding().toInstance(PostgresUserModule.MODULE); + } + + @Provides + @Singleton + public PostgresUsersRepositoryConfiguration provideConfiguration(ConfigurationProvider configurationProvider) throws ConfigurationException { + return PostgresUsersRepositoryConfiguration.from( + configurationProvider.getConfiguration("usersrepository")); + } +} diff --git a/server/data/data-postgres/pom.xml b/server/data/data-postgres/pom.xml index dc021f10756..82e0bec73be 100644 --- a/server/data/data-postgres/pom.xml +++ b/server/data/data-postgres/pom.xml @@ -65,6 +65,12 @@ james-server-dnsservice-test test + + ${james.groupId} + james-server-guice-common + test-jar + test + ${james.groupId} james-server-lifecycle-api @@ -134,6 +140,11 @@ org.slf4j slf4j-api + + org.testcontainers + postgresql + test + diff --git a/server/data/data-postgres/src/main/java/org/apache/james/user/jpa/JPAUsersDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/user/jpa/JPAUsersDAO.java deleted file mode 100644 index fc12e0eaa0e..00000000000 --- a/server/data/data-postgres/src/main/java/org/apache/james/user/jpa/JPAUsersDAO.java +++ /dev/null @@ -1,267 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.user.jpa; - -import java.util.Iterator; -import java.util.List; -import java.util.Locale; -import java.util.Optional; - -import javax.persistence.EntityManager; -import javax.persistence.EntityManagerFactory; -import javax.persistence.EntityTransaction; -import javax.persistence.NoResultException; -import javax.persistence.PersistenceException; - -import org.apache.commons.configuration2.HierarchicalConfiguration; -import org.apache.commons.configuration2.tree.ImmutableNode; -import org.apache.james.backends.jpa.EntityManagerUtils; -import org.apache.james.core.Username; -import org.apache.james.lifecycle.api.Configurable; -import org.apache.james.user.api.UsersRepositoryException; -import org.apache.james.user.api.model.User; -import org.apache.james.user.jpa.model.JPAUser; -import org.apache.james.user.lib.UsersDAO; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; - -/** - * JPA based UserRepository - */ -public class JPAUsersDAO implements UsersDAO, Configurable { - private static final Logger LOGGER = LoggerFactory.getLogger(JPAUsersDAO.class); - - private EntityManagerFactory entityManagerFactory; - private String algo; - - @Override - public void configure(HierarchicalConfiguration config) { - algo = config.getString("algorithm", "PBKDF2"); - } - - /** - * Sets entity manager. - * - * @param entityManagerFactory - * the entityManager to set - */ - public final void setEntityManagerFactory(EntityManagerFactory entityManagerFactory) { - this.entityManagerFactory = entityManagerFactory; - } - - public void init() { - EntityManagerUtils.safelyClose(createEntityManager()); - } - - /** - * Get the user object with the specified user name. Return null if no such - * user. - * - * @param name - * the name of the user to retrieve - * @return the user being retrieved, null if the user doesn't exist - * - * @since James 1.2.2 - */ - @Override - public Optional getUserByName(Username name) throws UsersRepositoryException { - EntityManager entityManager = entityManagerFactory.createEntityManager(); - - try { - JPAUser singleResult = (JPAUser) entityManager - .createNamedQuery("findUserByName") - .setParameter("name", name.asString()) - .getSingleResult(); - return Optional.of(singleResult); - } catch (NoResultException e) { - return Optional.empty(); - } catch (PersistenceException e) { - LOGGER.debug("Failed to find user", e); - throw new UsersRepositoryException("Unable to search user", e); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - /** - * Update the repository with the specified user object. A user object with - * this username must already exist. - */ - @Override - public void updateUser(User user) throws UsersRepositoryException { - Preconditions.checkNotNull(user); - - EntityManager entityManager = entityManagerFactory.createEntityManager(); - - final EntityTransaction transaction = entityManager.getTransaction(); - try { - if (contains(user.getUserName())) { - transaction.begin(); - entityManager.merge(user); - transaction.commit(); - } else { - LOGGER.debug("User not found"); - throw new UsersRepositoryException("User " + user.getUserName() + " not found"); - } - } catch (PersistenceException e) { - LOGGER.debug("Failed to update user", e); - if (transaction.isActive()) { - transaction.rollback(); - } - throw new UsersRepositoryException("Failed to update user " + user.getUserName().asString(), e); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - /** - * Removes a user from the repository - * - * @param name - * the user to remove from the repository - */ - @Override - public void removeUser(Username name) throws UsersRepositoryException { - EntityManager entityManager = entityManagerFactory.createEntityManager(); - - final EntityTransaction transaction = entityManager.getTransaction(); - try { - transaction.begin(); - if (entityManager.createNamedQuery("deleteUserByName").setParameter("name", name.asString()).executeUpdate() < 1) { - transaction.commit(); - throw new UsersRepositoryException("User " + name.asString() + " does not exist"); - } else { - transaction.commit(); - } - } catch (PersistenceException e) { - LOGGER.debug("Failed to remove user", e); - if (transaction.isActive()) { - transaction.rollback(); - } - throw new UsersRepositoryException("Failed to remove user " + name.asString(), e); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - /** - * Returns whether or not this user is in the repository - * - * @param name - * the name to check in the repository - * @return whether the user is in the repository - */ - @Override - public boolean contains(Username name) throws UsersRepositoryException { - EntityManager entityManager = entityManagerFactory.createEntityManager(); - - try { - return (Long) entityManager.createNamedQuery("containsUser") - .setParameter("name", name.asString().toLowerCase(Locale.US)) - .getSingleResult() > 0; - } catch (PersistenceException e) { - LOGGER.debug("Failed to find user", e); - throw new UsersRepositoryException("Failed to find user" + name.asString(), e); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - /** - * Returns a count of the users in the repository. - * - * @return the number of users in the repository - */ - @Override - public int countUsers() throws UsersRepositoryException { - EntityManager entityManager = entityManagerFactory.createEntityManager(); - - try { - return ((Long) entityManager.createNamedQuery("countUsers").getSingleResult()).intValue(); - } catch (PersistenceException e) { - LOGGER.debug("Failed to find user", e); - throw new UsersRepositoryException("Failed to count users", e); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - /** - * List users in repository. - * - * @return Iterator over a collection of Strings, each being one user in the - * repository. - */ - @Override - @SuppressWarnings("unchecked") - public Iterator list() throws UsersRepositoryException { - EntityManager entityManager = entityManagerFactory.createEntityManager(); - - try { - return ((List) entityManager.createNamedQuery("listUserNames").getResultList()) - .stream() - .map(Username::of) - .collect(ImmutableList.toImmutableList()).iterator(); - - } catch (PersistenceException e) { - LOGGER.debug("Failed to find user", e); - throw new UsersRepositoryException("Failed to list users", e); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - /** - * Return a new {@link EntityManager} instance - * - * @return manager - */ - private EntityManager createEntityManager() { - return entityManagerFactory.createEntityManager(); - } - - @Override - public void addUser(Username username, String password) throws UsersRepositoryException { - Username lowerCasedUsername = Username.of(username.asString().toLowerCase(Locale.US)); - if (contains(lowerCasedUsername)) { - throw new UsersRepositoryException(lowerCasedUsername.asString() + " already exists."); - } - EntityManager entityManager = entityManagerFactory.createEntityManager(); - final EntityTransaction transaction = entityManager.getTransaction(); - try { - transaction.begin(); - JPAUser user = new JPAUser(lowerCasedUsername.asString(), password, algo); - entityManager.persist(user); - transaction.commit(); - } catch (PersistenceException e) { - LOGGER.debug("Failed to save user", e); - if (transaction.isActive()) { - transaction.rollback(); - } - throw new UsersRepositoryException("Failed to add user" + username.asString(), e); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - -} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/user/jpa/model/JPAUser.java b/server/data/data-postgres/src/main/java/org/apache/james/user/jpa/model/JPAUser.java deleted file mode 100644 index 8a5cad22efb..00000000000 --- a/server/data/data-postgres/src/main/java/org/apache/james/user/jpa/model/JPAUser.java +++ /dev/null @@ -1,193 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.user.jpa.model; - -import java.nio.charset.StandardCharsets; -import java.util.Optional; -import java.util.function.Function; - -import javax.persistence.Basic; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.Id; -import javax.persistence.NamedQueries; -import javax.persistence.NamedQuery; -import javax.persistence.Table; -import javax.persistence.Version; - -import org.apache.james.core.Username; -import org.apache.james.user.api.model.User; -import org.apache.james.user.lib.model.Algorithm; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.hash.HashFunction; -import com.google.common.hash.Hashing; - -@Entity(name = "JamesUser") -@Table(name = "JAMES_USER") -@NamedQueries({ - @NamedQuery(name = "findUserByName", query = "SELECT user FROM JamesUser user WHERE user.name=:name"), - @NamedQuery(name = "deleteUserByName", query = "DELETE FROM JamesUser user WHERE user.name=:name"), - @NamedQuery(name = "containsUser", query = "SELECT COUNT(user) FROM JamesUser user WHERE user.name=:name"), - @NamedQuery(name = "countUsers", query = "SELECT COUNT(user) FROM JamesUser user"), - @NamedQuery(name = "listUserNames", query = "SELECT user.name FROM JamesUser user") }) -public class JPAUser implements User { - - /** - * Hash password. - * - * @param password - * not null - * @return not null - */ - @VisibleForTesting - static String hashPassword(String password, String nullableSalt, String nullableAlgorithm) { - Algorithm algorithm = Algorithm.of(Optional.ofNullable(nullableAlgorithm).orElse("SHA-512")); - if (algorithm.isPBKDF2()) { - return algorithm.digest(password, nullableSalt); - } - String credentials = password; - if (algorithm.isSalted() && nullableSalt != null) { - credentials = nullableSalt + password; - } - return chooseHashFunction(algorithm.getName()).apply(credentials); - } - - interface PasswordHashFunction extends Function {} - - private static PasswordHashFunction chooseHashFunction(String algorithm) { - switch (algorithm) { - case "NONE": - return password -> password; - default: - return password -> chooseHashing(algorithm).hashString(password, StandardCharsets.UTF_8).toString(); - } - } - - @SuppressWarnings("deprecation") - private static HashFunction chooseHashing(String algorithm) { - switch (algorithm) { - case "MD5": - return Hashing.md5(); - case "SHA-256": - return Hashing.sha256(); - case "SHA-512": - return Hashing.sha512(); - case "SHA-1": - case "SHA1": - return Hashing.sha1(); - default: - return Hashing.sha512(); - } - } - - /** Prevents concurrent modification */ - @Version - private int version; - - /** Key by user name */ - @Id - @Column(name = "USER_NAME", nullable = false, length = 100) - private String name; - - /** Hashed password */ - @Basic - @Column(name = "PASSWORD", nullable = false, length = 128) - private String password; - - @Basic - @Column(name = "PASSWORD_HASH_ALGORITHM", nullable = false, length = 100) - private String alg; - - protected JPAUser() { - } - - public JPAUser(String userName, String password, String alg) { - super(); - this.name = userName; - this.alg = alg; - this.password = hashPassword(password, userName, alg); - } - - @Override - public Username getUserName() { - return Username.of(name); - } - - @Override - public boolean setPassword(String newPass) { - final boolean result; - if (newPass == null) { - result = false; - } else { - password = hashPassword(newPass, name, alg); - result = true; - } - return result; - } - - @Override - public boolean verifyPassword(String pass) { - final boolean result; - if (pass == null) { - result = password == null; - } else { - result = password != null && password.equals(hashPassword(pass, name, alg)); - } - - return result; - } - - @Override - public int hashCode() { - final int PRIME = 31; - int result = 1; - result = PRIME * result + ((name == null) ? 0 : name.hashCode()); - return result; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null) { - return false; - } - if (getClass() != obj.getClass()) { - return false; - } - final JPAUser other = (JPAUser) obj; - if (name == null) { - if (other.name != null) { - return false; - } - } else if (!name.equals(other.name)) { - return false; - } - return true; - } - - @Override - public String toString() { - return "[User " + name + "]"; - } - -} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUserModule.java b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUserModule.java new file mode 100644 index 00000000000..6aae9183f82 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUserModule.java @@ -0,0 +1,50 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.user.postgres; + +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresUserModule { + interface PostgresUserTable { + Table TABLE_NAME = DSL.table("users"); + + Field USERNAME = DSL.field("username", SQLDataType.VARCHAR(255).notNull()); + Field HASHED_PASSWORD = DSL.field("hashed_password", SQLDataType.VARCHAR.notNull()); + Field ALGORITHM = DSL.field("algorithm", SQLDataType.VARCHAR(100).notNull()); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(USERNAME) + .column(HASHED_PASSWORD) + .column(ALGORITHM) + .constraint(DSL.primaryKey(USERNAME)))) + .disableRowLevelSecurity(); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(PostgresUserTable.TABLE) + .build(); +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java new file mode 100644 index 00000000000..67c998b09a6 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java @@ -0,0 +1,143 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.user.postgres; + +import static org.apache.james.backends.postgres.utils.PostgresUtils.UNIQUE_CONSTRAINT_VIOLATION_PREDICATE; +import static org.apache.james.user.postgres.PostgresUserModule.PostgresUserTable.ALGORITHM; +import static org.apache.james.user.postgres.PostgresUserModule.PostgresUserTable.HASHED_PASSWORD; +import static org.apache.james.user.postgres.PostgresUserModule.PostgresUserTable.TABLE_NAME; +import static org.apache.james.user.postgres.PostgresUserModule.PostgresUserTable.USERNAME; +import static org.jooq.impl.DSL.count; + +import java.util.Iterator; +import java.util.Optional; + +import javax.inject.Inject; + +import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.core.Username; +import org.apache.james.user.api.AlreadyExistInUsersRepositoryException; +import org.apache.james.user.api.UsersRepositoryException; +import org.apache.james.user.api.model.User; +import org.apache.james.user.lib.UsersDAO; +import org.apache.james.user.lib.model.Algorithm; +import org.apache.james.user.lib.model.DefaultUser; + +import com.google.common.base.Preconditions; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresUsersDAO implements UsersDAO { + private final PostgresExecutor postgresExecutor; + private final Algorithm algorithm; + private final Algorithm.HashingMode fallbackHashingMode; + + @Inject + public PostgresUsersDAO(JamesPostgresConnectionFactory jamesPostgresConnectionFactory, + PostgresUsersRepositoryConfiguration postgresUsersRepositoryConfiguration) { + this.postgresExecutor = new PostgresExecutor(jamesPostgresConnectionFactory.getConnection(Optional.empty())); + this.algorithm = postgresUsersRepositoryConfiguration.getPreferredAlgorithm(); + this.fallbackHashingMode = postgresUsersRepositoryConfiguration.getFallbackHashingMode(); + } + + @Override + public Optional getUserByName(Username name) { + return getUserByNameReactive(name).blockOptional(); + } + + private Mono getUserByNameReactive(Username name) { + return postgresExecutor.executeRow(dsl -> Mono.from(dsl.selectFrom(TABLE_NAME) + .where(USERNAME.eq(name.asString())))) + .map(record -> new DefaultUser(name, record.get(HASHED_PASSWORD), + Algorithm.of(record.get(ALGORITHM), fallbackHashingMode), algorithm)); + } + + @Override + public void updateUser(User user) throws UsersRepositoryException { + Preconditions.checkArgument(user instanceof DefaultUser); + DefaultUser defaultUser = (DefaultUser) user; + + boolean executed = postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.update(TABLE_NAME) + .set(HASHED_PASSWORD, defaultUser.getHashedPassword()) + .set(ALGORITHM, defaultUser.getHashAlgorithm().asString()) + .where(USERNAME.eq(user.getUserName().asString())) + .returning(USERNAME))) + .map(record -> record.get(USERNAME)) + .blockOptional() + .isPresent(); + + if (!executed) { + throw new UsersRepositoryException("Unable to update user"); + } + } + + @Override + public void removeUser(Username name) throws UsersRepositoryException { + boolean executed = postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.deleteFrom(TABLE_NAME) + .where(USERNAME.eq(name.asString())) + .returning(USERNAME))) + .map(record -> record.get(USERNAME)) + .blockOptional() + .isPresent(); + + if (!executed) { + throw new UsersRepositoryException("Unable to update user"); + } + } + + @Override + public boolean contains(Username name) { + return getUserByName(name).isPresent(); + } + + @Override + public int countUsers() { + return postgresExecutor.executeRow(dsl -> Mono.from(dsl.select(count()).from(TABLE_NAME))) + .map(record -> record.get(0, Integer.class)) + .block(); + } + + @Override + public Iterator list() throws UsersRepositoryException { + return listReactive() + .toIterable() + .iterator(); + } + + @Override + public Flux listReactive() { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.selectFrom(TABLE_NAME))) + .map(record -> Username.of(record.get(USERNAME))); + } + + @Override + public void addUser(Username username, String password) { + DefaultUser user = new DefaultUser(username, algorithm, algorithm); + user.setPassword(password); + + postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME, USERNAME, HASHED_PASSWORD, ALGORITHM) + .values(user.getUserName().asString(), user.getHashedPassword(), user.getHashAlgorithm().asString()))) + .onErrorMap(UNIQUE_CONSTRAINT_VIOLATION_PREDICATE, + e -> new AlreadyExistInUsersRepositoryException("User with username " + username + " already exist!")) + .block(); + } +} diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAUsersRepositoryModule.java b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersRepository.java similarity index 53% rename from server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAUsersRepositoryModule.java rename to server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersRepository.java index 5a719244a4c..610dc905293 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAUsersRepositoryModule.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersRepository.java @@ -16,29 +16,17 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.modules.data; -import org.apache.james.server.core.configuration.ConfigurationProvider; -import org.apache.james.user.api.UsersRepository; -import org.apache.james.user.jpa.JPAUsersRepository; -import org.apache.james.utils.InitializationOperation; -import org.apache.james.utils.InitilizationOperationBuilder; +package org.apache.james.user.postgres; -import com.google.inject.AbstractModule; -import com.google.inject.Scopes; -import com.google.inject.multibindings.ProvidesIntoSet; +import javax.inject.Inject; -public class JPAUsersRepositoryModule extends AbstractModule { - @Override - public void configure() { - bind(JPAUsersRepository.class).in(Scopes.SINGLETON); - bind(UsersRepository.class).to(JPAUsersRepository.class); - } +import org.apache.james.domainlist.api.DomainList; +import org.apache.james.user.lib.UsersRepositoryImpl; - @ProvidesIntoSet - InitializationOperation configureJpaUsers(ConfigurationProvider configurationProvider, JPAUsersRepository usersRepository) { - return InitilizationOperationBuilder - .forClass(JPAUsersRepository.class) - .init(() -> usersRepository.configure(configurationProvider.getConfiguration("usersrepository"))); +public class PostgresUsersRepository extends UsersRepositoryImpl { + @Inject + public PostgresUsersRepository(DomainList domainList, PostgresUsersDAO usersDAO) { + super(domainList, usersDAO); } } diff --git a/server/data/data-postgres/src/main/java/org/apache/james/user/jpa/JPAUsersRepository.java b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersRepositoryConfiguration.java similarity index 50% rename from server/data/data-postgres/src/main/java/org/apache/james/user/jpa/JPAUsersRepository.java rename to server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersRepositoryConfiguration.java index b3f9397abe9..8e891c185ff 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/user/jpa/JPAUsersRepository.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersRepositoryConfiguration.java @@ -17,48 +17,41 @@ * under the License. * ****************************************************************/ -package org.apache.james.user.jpa; - -import javax.annotation.PostConstruct; -import javax.inject.Inject; -import javax.persistence.EntityManagerFactory; -import javax.persistence.PersistenceUnit; +package org.apache.james.user.postgres; import org.apache.commons.configuration2.HierarchicalConfiguration; import org.apache.commons.configuration2.ex.ConfigurationException; import org.apache.commons.configuration2.tree.ImmutableNode; -import org.apache.james.domainlist.api.DomainList; -import org.apache.james.user.lib.UsersRepositoryImpl; +import org.apache.james.user.lib.model.Algorithm; +import org.apache.james.user.lib.model.Algorithm.HashingMode; + +public class PostgresUsersRepositoryConfiguration { + public static final String DEFAULT_ALGORITHM = "PBKDF2-SHA512"; + public static final String DEFAULT_HASHING_MODE = HashingMode.PLAIN.name(); + + public static final PostgresUsersRepositoryConfiguration DEFAULT = new PostgresUsersRepositoryConfiguration( + Algorithm.of(DEFAULT_ALGORITHM), HashingMode.parse(DEFAULT_HASHING_MODE) + ); + + private final Algorithm preferredAlgorithm; + private final HashingMode fallbackHashingMode; -/** - * JPA based UserRepository - */ -public class JPAUsersRepository extends UsersRepositoryImpl { - @Inject - public JPAUsersRepository(DomainList domainList) { - super(domainList, new JPAUsersDAO()); + public PostgresUsersRepositoryConfiguration(Algorithm preferredAlgorithm, HashingMode fallbackHashingMode) { + this.preferredAlgorithm = preferredAlgorithm; + this.fallbackHashingMode = fallbackHashingMode; } - /** - * Sets entity manager. - * - * @param entityManagerFactory - * the entityManager to set - */ - @Inject - @PersistenceUnit(unitName = "James") - public final void setEntityManagerFactory(EntityManagerFactory entityManagerFactory) { - usersDAO.setEntityManagerFactory(entityManagerFactory); + public Algorithm getPreferredAlgorithm() { + return preferredAlgorithm; } - @PostConstruct - public void init() { - usersDAO.init(); + public HashingMode getFallbackHashingMode() { + return fallbackHashingMode; } - @Override - public void configure(HierarchicalConfiguration config) throws ConfigurationException { - usersDAO.configure(config); - super.configure(config); + public static PostgresUsersRepositoryConfiguration from(HierarchicalConfiguration config) throws ConfigurationException { + return new PostgresUsersRepositoryConfiguration( + Algorithm.of(config.getString("algorithm", DEFAULT_ALGORITHM)), + HashingMode.parse(config.getString("hashingMode", DEFAULT_HASHING_MODE))); } } diff --git a/server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/JPARecipientRewriteTableTest.java b/server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/JPARecipientRewriteTableTest.java index 2f60f581928..308f448d694 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/JPARecipientRewriteTableTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/JPARecipientRewriteTableTest.java @@ -25,7 +25,8 @@ import org.apache.james.rrt.jpa.model.JPARecipientRewrite; import org.apache.james.rrt.lib.AbstractRecipientRewriteTable; import org.apache.james.rrt.lib.RecipientRewriteTableContract; -import org.apache.james.user.jpa.JPAUsersRepository; +import org.apache.james.user.postgres.PostgresUsersDAO; +import org.apache.james.user.postgres.PostgresUsersRepository; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -49,7 +50,7 @@ void teardown() throws Exception { public void createRecipientRewriteTable() { JPARecipientRewriteTable localVirtualUserTable = new JPARecipientRewriteTable(); localVirtualUserTable.setEntityManagerFactory(JPA_TEST_CLUSTER.getEntityManagerFactory()); - localVirtualUserTable.setUsersRepository(new JPAUsersRepository(mock(DomainList.class))); + localVirtualUserTable.setUsersRepository(new PostgresUsersRepository(mock(DomainList.class), mock(PostgresUsersDAO.class))); recipientRewriteTable = localVirtualUserTable; } diff --git a/server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/JPAStepdefs.java b/server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/JPAStepdefs.java index 3908dfe98e0..6ff90584029 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/JPAStepdefs.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/JPAStepdefs.java @@ -18,12 +18,15 @@ ****************************************************************/ package org.apache.james.rrt.jpa; +import static org.mockito.Mockito.mock; + import org.apache.james.backends.jpa.JpaTestCluster; import org.apache.james.rrt.jpa.model.JPARecipientRewrite; import org.apache.james.rrt.lib.AbstractRecipientRewriteTable; import org.apache.james.rrt.lib.RecipientRewriteTableFixture; import org.apache.james.rrt.lib.RewriteTablesStepdefs; -import org.apache.james.user.jpa.JPAUsersRepository; +import org.apache.james.user.postgres.PostgresUsersDAO; +import org.apache.james.user.postgres.PostgresUsersRepository; import com.github.fge.lambdas.Throwing; @@ -53,7 +56,7 @@ public void tearDown() { private AbstractRecipientRewriteTable getRecipientRewriteTable() throws Exception { JPARecipientRewriteTable localVirtualUserTable = new JPARecipientRewriteTable(); localVirtualUserTable.setEntityManagerFactory(JPA_TEST_CLUSTER.getEntityManagerFactory()); - localVirtualUserTable.setUsersRepository(new JPAUsersRepository(RecipientRewriteTableFixture.domainListForCucumberTests())); + localVirtualUserTable.setUsersRepository(new PostgresUsersRepository(RecipientRewriteTableFixture.domainListForCucumberTests(), mock(PostgresUsersDAO.class))); localVirtualUserTable.setDomainList(RecipientRewriteTableFixture.domainListForCucumberTests()); return localVirtualUserTable; } diff --git a/server/data/data-postgres/src/test/java/org/apache/james/user/jpa/model/JPAUserTest.java b/server/data/data-postgres/src/test/java/org/apache/james/user/jpa/model/JPAUserTest.java deleted file mode 100644 index fa11b2504de..00000000000 --- a/server/data/data-postgres/src/test/java/org/apache/james/user/jpa/model/JPAUserTest.java +++ /dev/null @@ -1,73 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.user.jpa.model; - -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.Test; - -class JPAUserTest { - - private static final String RANDOM_PASSWORD = "baeMiqu7"; - - @Test - void hashPasswordShouldBeNoopWhenNone() { - //I doubt the expected result was the author intent - Assertions.assertThat(JPAUser.hashPassword(RANDOM_PASSWORD, null, "NONE")).isEqualTo("baeMiqu7"); - } - - @Test - void hashPasswordShouldHashWhenMD5() { - Assertions.assertThat(JPAUser.hashPassword(RANDOM_PASSWORD, null, "MD5")).isEqualTo("702000e50c9fd3755b8fc20ecb07d1ac"); - } - - @Test - void hashPasswordShouldHashWhenSHA1() { - Assertions.assertThat(JPAUser.hashPassword(RANDOM_PASSWORD, null, "SHA1")).isEqualTo("05dbbaa7b4bcae245f14d19ae58ef1b80adf3363"); - } - - @Test - void hashPasswordShouldHashWhenSHA256() { - Assertions.assertThat(JPAUser.hashPassword(RANDOM_PASSWORD, null, "SHA-256")).isEqualTo("6d06c72a578fe0b78ede2393b07739831a287774dcad0b18bc4bde8b0c948b82"); - } - - @Test - void hashPasswordShouldHashWhenSHA512() { - Assertions.assertThat(JPAUser.hashPassword(RANDOM_PASSWORD, null, "SHA-512")).isEqualTo("f9cc82d1c04bb2ce0494a51f7a21d07ac60b6f79a8a55397f454603acac29d8589fdfd694d5c01ba01a346c76b090abca9ad855b5b0c92c6062ad6d93cdc0d03"); - } - - @Test - void hashPasswordShouldSha512WhenRandomString() { - Assertions.assertThat(JPAUser.hashPassword(RANDOM_PASSWORD, null, "random")).isEqualTo("f9cc82d1c04bb2ce0494a51f7a21d07ac60b6f79a8a55397f454603acac29d8589fdfd694d5c01ba01a346c76b090abca9ad855b5b0c92c6062ad6d93cdc0d03"); - } - - @Test - void hashPasswordShouldSha512WhenNull() { - Assertions.assertThat(JPAUser.hashPassword(RANDOM_PASSWORD, null, null)).isEqualTo("f9cc82d1c04bb2ce0494a51f7a21d07ac60b6f79a8a55397f454603acac29d8589fdfd694d5c01ba01a346c76b090abca9ad855b5b0c92c6062ad6d93cdc0d03"); - } - - @Test - void hashPasswordShouldHashWithNullSalt() { - Assertions.assertThat(JPAUser.hashPassword(RANDOM_PASSWORD, null, "SHA-512/salted")).isEqualTo("f9cc82d1c04bb2ce0494a51f7a21d07ac60b6f79a8a55397f454603acac29d8589fdfd694d5c01ba01a346c76b090abca9ad855b5b0c92c6062ad6d93cdc0d03"); - } - - @Test - void hashPasswordShouldHashWithSalt() { - Assertions.assertThat(JPAUser.hashPassword(RANDOM_PASSWORD, "salt", "SHA-512/salted")).isEqualTo("b7941dcdc380ec414623834919f7d5cbe241a2b6a23be79a61cd9f36178382901b8d83642b743297ac72e5de24e4111885dd05df06e14e47c943c05fdd1ff15a"); - } -} \ No newline at end of file diff --git a/server/data/data-postgres/src/test/java/org/apache/james/user/jpa/JpaUsersRepositoryTest.java b/server/data/data-postgres/src/test/java/org/apache/james/user/postgres/PostgresUsersRepositoryTest.java similarity index 74% rename from server/data/data-postgres/src/test/java/org/apache/james/user/jpa/JpaUsersRepositoryTest.java rename to server/data/data-postgres/src/test/java/org/apache/james/user/postgres/PostgresUsersRepositoryTest.java index 55355b0a9d4..e83f03bf107 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/user/jpa/JpaUsersRepositoryTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/user/postgres/PostgresUsersRepositoryTest.java @@ -16,32 +16,34 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.user.jpa; -import java.util.Optional; +package org.apache.james.user.postgres; import org.apache.commons.configuration2.BaseHierarchicalConfiguration; -import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.utils.SinglePostgresConnectionFactory; import org.apache.james.core.Username; import org.apache.james.domainlist.api.DomainList; import org.apache.james.user.api.UsersRepository; -import org.apache.james.user.jpa.model.JPAUser; import org.apache.james.user.lib.UsersRepositoryContract; -import org.junit.jupiter.api.AfterEach; +import org.apache.james.user.lib.UsersRepositoryImpl; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.extension.RegisterExtension; -class JpaUsersRepositoryTest { +import java.util.Optional; + +class PostgresUsersRepositoryTest { - private static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAUser.class); + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresUserModule.MODULE); @Nested class WhenEnableVirtualHosting implements UsersRepositoryContract.WithVirtualHostingContract { @RegisterExtension UserRepositoryExtension extension = UserRepositoryExtension.withVirtualHost(); - private JPAUsersRepository usersRepository; + private UsersRepositoryImpl usersRepository; private TestSystem testSystem; @BeforeEach @@ -51,7 +53,7 @@ void setUp(TestSystem testSystem) throws Exception { } @Override - public UsersRepository testee() { + public UsersRepositoryImpl testee() { return usersRepository; } @@ -66,7 +68,7 @@ class WhenDisableVirtualHosting implements UsersRepositoryContract.WithOutVirtua @RegisterExtension UserRepositoryExtension extension = UserRepositoryExtension.withoutVirtualHosting(); - private JPAUsersRepository usersRepository; + private UsersRepositoryImpl usersRepository; private TestSystem testSystem; @BeforeEach @@ -76,7 +78,7 @@ void setUp(TestSystem testSystem) throws Exception { } @Override - public UsersRepository testee() { + public UsersRepositoryImpl testee() { return usersRepository; } @@ -86,18 +88,15 @@ public UsersRepository testee(Optional administrator) throws Exception } } - @AfterEach - void tearDown() { - JPA_TEST_CLUSTER.clear("JAMES_USER"); - } - - private static JPAUsersRepository getUsersRepository(DomainList domainList, boolean enableVirtualHosting, Optional administrator) throws Exception { - JPAUsersRepository repos = new JPAUsersRepository(domainList); - repos.setEntityManagerFactory(JPA_TEST_CLUSTER.getEntityManagerFactory()); + private static UsersRepositoryImpl getUsersRepository(DomainList domainList, boolean enableVirtualHosting, Optional administrator) throws Exception { + PostgresUsersDAO usersDAO = new PostgresUsersDAO(new SinglePostgresConnectionFactory(postgresExtension.getConnection().block()), + PostgresUsersRepositoryConfiguration.DEFAULT); BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); configuration.addProperty("enableVirtualHosting", String.valueOf(enableVirtualHosting)); administrator.ifPresent(username -> configuration.addProperty("administratorId", username.asString())); - repos.configure(configuration); - return repos; + + UsersRepositoryImpl usersRepository = new PostgresUsersRepository(domainList, usersDAO); + usersRepository.configure(configuration); + return usersRepository; } } From b76222a3c5ced95fff406e8e70d5e011c6332d41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20H=E1=BB=93ng=20Qu=C3=A2n?= <55171818+quantranhong1999@users.noreply.github.com> Date: Mon, 27 Nov 2023 11:30:30 +0700 Subject: [PATCH 074/341] JAMES-2586 Implement PostgresQuotaCurrentValueDAO (#1813) --- .../CassandraQuotaCurrentValueDao.java | 64 +------- .../CassandraQuotaCurrentValueDaoTest.java | 7 +- .../quota/PostgresQuotaCurrentValueDAO.java | 120 ++++++++++++++ .../postgres/quota/PostgresQuotaModule.java | 59 +++++++ .../PostgresQuotaCurrentValueDAOTest.java | 147 ++++++++++++++++++ .../james/core/quota/QuotaCurrentValue.java | 53 +++++++ .../quota/CassandraCurrentQuotaManagerV2.java | 9 +- .../cassandra/CassandraSieveQuotaDAOV2.java | 8 +- .../CassandraUploadUsageRepository.java | 7 +- 9 files changed, 398 insertions(+), 76 deletions(-) create mode 100644 backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java create mode 100644 backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaModule.java create mode 100644 backends-common/postgres/src/test/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAOTest.java diff --git a/backends-common/cassandra/src/main/java/org/apache/james/backends/cassandra/components/CassandraQuotaCurrentValueDao.java b/backends-common/cassandra/src/main/java/org/apache/james/backends/cassandra/components/CassandraQuotaCurrentValueDao.java index 95618be4f25..aec997b27b4 100644 --- a/backends-common/cassandra/src/main/java/org/apache/james/backends/cassandra/components/CassandraQuotaCurrentValueDao.java +++ b/backends-common/cassandra/src/main/java/org/apache/james/backends/cassandra/components/CassandraQuotaCurrentValueDao.java @@ -30,8 +30,6 @@ import static org.apache.james.backends.cassandra.components.CassandraQuotaCurrentValueTable.QUOTA_TYPE; import static org.apache.james.backends.cassandra.components.CassandraQuotaCurrentValueTable.TABLE_NAME; -import java.util.Objects; - import jakarta.inject.Inject; import org.apache.james.backends.cassandra.utils.CassandraAsyncExecutor; @@ -47,66 +45,12 @@ import com.datastax.oss.driver.api.querybuilder.delete.Delete; import com.datastax.oss.driver.api.querybuilder.select.Select; import com.datastax.oss.driver.api.querybuilder.update.Update; -import com.google.common.base.MoreObjects; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; public class CassandraQuotaCurrentValueDao { - public static class QuotaKey { - - public static QuotaKey of(QuotaComponent component, String identifier, QuotaType quotaType) { - return new QuotaKey(component, identifier, quotaType); - } - - private final QuotaComponent quotaComponent; - private final String identifier; - private final QuotaType quotaType; - - public QuotaComponent getQuotaComponent() { - return quotaComponent; - } - - public String getIdentifier() { - return identifier; - } - - public QuotaType getQuotaType() { - return quotaType; - } - - private QuotaKey(QuotaComponent quotaComponent, String identifier, QuotaType quotaType) { - this.quotaComponent = quotaComponent; - this.identifier = identifier; - this.quotaType = quotaType; - } - - @Override - public final int hashCode() { - return Objects.hash(quotaComponent, identifier, quotaType); - } - - @Override - public final boolean equals(Object o) { - if (o instanceof QuotaKey) { - QuotaKey other = (QuotaKey) o; - return Objects.equals(quotaComponent, other.quotaComponent) - && Objects.equals(identifier, other.identifier) - && Objects.equals(quotaType, other.quotaType); - } - return false; - } - - public String toString() { - return MoreObjects.toStringHelper(this) - .add("quotaComponent", quotaComponent) - .add("identifier", identifier) - .add("quotaType", quotaType) - .toString(); - } - } - private static final Logger LOGGER = LoggerFactory.getLogger(CassandraQuotaCurrentValueDao.class); private final CassandraAsyncExecutor queryExecutor; @@ -126,7 +70,7 @@ public CassandraQuotaCurrentValueDao(CqlSession session) { this.deleteQuotaCurrentValueStatement = session.prepare(deleteQuotaCurrentValueStatement().build()); } - public Mono increase(QuotaKey quotaKey, long amount) { + public Mono increase(QuotaCurrentValue.Key quotaKey, long amount) { return queryExecutor.executeVoid(increaseStatement.bind() .setString(QUOTA_COMPONENT, quotaKey.getQuotaComponent().getValue()) .setString(IDENTIFIER, quotaKey.getIdentifier()) @@ -139,7 +83,7 @@ public Mono increase(QuotaKey quotaKey, long amount) { }); } - public Mono decrease(QuotaKey quotaKey, long amount) { + public Mono decrease(QuotaCurrentValue.Key quotaKey, long amount) { return queryExecutor.executeVoid(decreaseStatement.bind() .setString(QUOTA_COMPONENT, quotaKey.getQuotaComponent().getValue()) .setString(IDENTIFIER, quotaKey.getIdentifier()) @@ -152,7 +96,7 @@ public Mono decrease(QuotaKey quotaKey, long amount) { }); } - public Mono getQuotaCurrentValue(QuotaKey quotaKey) { + public Mono getQuotaCurrentValue(QuotaCurrentValue.Key quotaKey) { return queryExecutor.executeSingleRow(getQuotaCurrentValueStatement.bind() .setString(QUOTA_COMPONENT, quotaKey.getQuotaComponent().getValue()) .setString(IDENTIFIER, quotaKey.getIdentifier()) @@ -160,7 +104,7 @@ public Mono getQuotaCurrentValue(QuotaKey quotaKey) { .map(row -> convertRowToModel(row)); } - public Mono deleteQuotaCurrentValue(QuotaKey quotaKey) { + public Mono deleteQuotaCurrentValue(QuotaCurrentValue.Key quotaKey) { return queryExecutor.executeVoid(deleteQuotaCurrentValueStatement.bind() .setString(QUOTA_COMPONENT, quotaKey.getQuotaComponent().getValue()) .setString(IDENTIFIER, quotaKey.getIdentifier()) diff --git a/backends-common/cassandra/src/test/java/org/apache/james/backends/cassandra/quota/CassandraQuotaCurrentValueDaoTest.java b/backends-common/cassandra/src/test/java/org/apache/james/backends/cassandra/quota/CassandraQuotaCurrentValueDaoTest.java index e22b6d4c923..ae7817e42b0 100644 --- a/backends-common/cassandra/src/test/java/org/apache/james/backends/cassandra/quota/CassandraQuotaCurrentValueDaoTest.java +++ b/backends-common/cassandra/src/test/java/org/apache/james/backends/cassandra/quota/CassandraQuotaCurrentValueDaoTest.java @@ -26,7 +26,6 @@ import org.apache.james.backends.cassandra.CassandraClusterExtension; import org.apache.james.backends.cassandra.components.CassandraMutualizedQuotaModule; import org.apache.james.backends.cassandra.components.CassandraQuotaCurrentValueDao; -import org.apache.james.backends.cassandra.components.CassandraQuotaCurrentValueDao.QuotaKey; import org.apache.james.core.quota.QuotaComponent; import org.apache.james.core.quota.QuotaCurrentValue; import org.apache.james.core.quota.QuotaType; @@ -36,7 +35,7 @@ import org.junit.jupiter.api.extension.RegisterExtension; public class CassandraQuotaCurrentValueDaoTest { - private static final QuotaKey QUOTA_KEY = QuotaKey.of(QuotaComponent.MAILBOX, "james@abc.com", QuotaType.SIZE); + private static final QuotaCurrentValue.Key QUOTA_KEY = QuotaCurrentValue.Key.of(QuotaComponent.MAILBOX, "james@abc.com", QuotaType.SIZE); private CassandraQuotaCurrentValueDao cassandraQuotaCurrentValueDao; @@ -92,7 +91,7 @@ void decreaseQuotaCurrentValueShouldDecreaseValueSuccessfully() { @Test void deleteQuotaCurrentValueShouldDeleteSuccessfully() { - QuotaKey quotaKey = QuotaKey.of(QuotaComponent.MAILBOX, "andre@abc.com", QuotaType.SIZE); + QuotaCurrentValue.Key quotaKey = QuotaCurrentValue.Key.of(QuotaComponent.MAILBOX, "andre@abc.com", QuotaType.SIZE); cassandraQuotaCurrentValueDao.increase(quotaKey, 100L).block(); cassandraQuotaCurrentValueDao.deleteQuotaCurrentValue(quotaKey).block(); @@ -125,7 +124,7 @@ void decreaseQuotaCurrentValueShouldNotThrowExceptionWhenQueryExecutorThrowExcep @Test void getQuotasByComponentShouldGetAllQuotaTypesSuccessfully() { - QuotaKey countQuotaKey = QuotaKey.of(QuotaComponent.MAILBOX, "james@abc.com", QuotaType.COUNT); + QuotaCurrentValue.Key countQuotaKey = QuotaCurrentValue.Key.of(QuotaComponent.MAILBOX, "james@abc.com", QuotaType.COUNT); QuotaCurrentValue expectedQuotaSize = QuotaCurrentValue.builder().quotaComponent(QUOTA_KEY.getQuotaComponent()) .identifier(QUOTA_KEY.getIdentifier()).quotaType(QUOTA_KEY.getQuotaType()).currentValue(100L).build(); diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java new file mode 100644 index 00000000000..8f5c7eea6c0 --- /dev/null +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java @@ -0,0 +1,120 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres.quota; + +import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaCurrentValueTable.COMPONENT; +import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaCurrentValueTable.CURRENT_VALUE; +import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaCurrentValueTable.IDENTIFIER; +import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaCurrentValueTable.PRIMARY_KEY_CONSTRAINT_NAME; +import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaCurrentValueTable.TABLE_NAME; +import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaCurrentValueTable.TYPE; + +import java.util.function.Function; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.core.quota.QuotaComponent; +import org.apache.james.core.quota.QuotaCurrentValue; +import org.apache.james.core.quota.QuotaType; +import org.jooq.Record; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresQuotaCurrentValueDAO { + private static final Logger LOGGER = LoggerFactory.getLogger(PostgresQuotaCurrentValueDAO.class); + + private final PostgresExecutor postgresExecutor; + + public PostgresQuotaCurrentValueDAO(PostgresExecutor postgresExecutor) { + this.postgresExecutor = postgresExecutor; + } + + public Mono increase(QuotaCurrentValue.Key quotaKey, long amount) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME) + .set(IDENTIFIER, quotaKey.getIdentifier()) + .set(COMPONENT, quotaKey.getQuotaComponent().getValue()) + .set(TYPE, quotaKey.getQuotaType().getValue()) + .set(CURRENT_VALUE, amount) + .onConflictOnConstraint(PRIMARY_KEY_CONSTRAINT_NAME) + .doUpdate() + .set(CURRENT_VALUE, CURRENT_VALUE.plus(amount)))) + .onErrorResume(ex -> { + LOGGER.warn("Failure when increasing {} {} quota for {}. Quota current value is thus not updated and needs re-computation", + quotaKey.getQuotaComponent().getValue(), quotaKey.getQuotaType().getValue(), quotaKey.getIdentifier(), ex); + return Mono.empty(); + }); + } + + public Mono decrease(QuotaCurrentValue.Key quotaKey, long amount) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME) + .set(IDENTIFIER, quotaKey.getIdentifier()) + .set(COMPONENT, quotaKey.getQuotaComponent().getValue()) + .set(TYPE, quotaKey.getQuotaType().getValue()) + .set(CURRENT_VALUE, 0L) + .onConflictOnConstraint(PRIMARY_KEY_CONSTRAINT_NAME) + .doUpdate() + .set(CURRENT_VALUE, CURRENT_VALUE.minus(amount)))) + .onErrorResume(ex -> { + LOGGER.warn("Failure when decreasing {} {} quota for {}. Quota current value is thus not updated and needs re-computation", + quotaKey.getQuotaComponent().getValue(), quotaKey.getQuotaType().getValue(), quotaKey.getIdentifier(), ex); + return Mono.empty(); + }); + } + + public Mono getQuotaCurrentValue(QuotaCurrentValue.Key quotaKey) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select(CURRENT_VALUE) + .from(TABLE_NAME) + .where(IDENTIFIER.eq(quotaKey.getIdentifier()), + COMPONENT.eq(quotaKey.getQuotaComponent().getValue()), + TYPE.eq(quotaKey.getQuotaType().getValue())))) + .map(toQuotaCurrentValue(quotaKey)); + } + + public Mono deleteQuotaCurrentValue(QuotaCurrentValue.Key quotaKey) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(TABLE_NAME) + .where(IDENTIFIER.eq(quotaKey.getIdentifier()), + COMPONENT.eq(quotaKey.getQuotaComponent().getValue()), + TYPE.eq(quotaKey.getQuotaType().getValue())))); + } + + public Flux getQuotaCurrentValues(QuotaComponent quotaComponent, String identifier) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(TYPE, CURRENT_VALUE) + .from(TABLE_NAME) + .where(IDENTIFIER.eq(identifier), + COMPONENT.eq(quotaComponent.getValue())))) + .map(toQuotaCurrentValue(quotaComponent, identifier)); + } + + private Function toQuotaCurrentValue(QuotaCurrentValue.Key quotaKey) { + return record -> QuotaCurrentValue.builder().quotaComponent(quotaKey.getQuotaComponent()) + .identifier(quotaKey.getIdentifier()) + .quotaType(quotaKey.getQuotaType()) + .currentValue(record.get(CURRENT_VALUE)).build(); + } + + private static Function toQuotaCurrentValue(QuotaComponent quotaComponent, String identifier) { + return record -> QuotaCurrentValue.builder().quotaComponent(quotaComponent) + .identifier(identifier) + .quotaType(QuotaType.of(record.get(TYPE))) + .currentValue(record.get(CURRENT_VALUE)).build(); + } +} diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaModule.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaModule.java new file mode 100644 index 00000000000..dad84108d04 --- /dev/null +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaModule.java @@ -0,0 +1,59 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres.quota; + +import static org.jooq.impl.DSL.name; +import static org.jooq.impl.SQLDataType.BIGINT; + +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.Name; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresQuotaModule { + interface PostgresQuotaCurrentValueTable { + Table TABLE_NAME = DSL.table("quota_current_value"); + + Field IDENTIFIER = DSL.field("identifier", SQLDataType.VARCHAR.notNull()); + Field COMPONENT = DSL.field("component", SQLDataType.VARCHAR.notNull()); + Field TYPE = DSL.field("type", SQLDataType.VARCHAR.notNull()); + Field CURRENT_VALUE = DSL.field(name(TABLE_NAME.getName(), "current_value"), BIGINT.notNull()); + + Name PRIMARY_KEY_CONSTRAINT_NAME = DSL.name("quota_current_value_primary_key"); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(IDENTIFIER) + .column(COMPONENT) + .column(TYPE) + .column(CURRENT_VALUE) + .constraint(DSL.constraint(PRIMARY_KEY_CONSTRAINT_NAME) + .primaryKey(IDENTIFIER, COMPONENT, TYPE)))) + .disableRowLevelSecurity(); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(PostgresQuotaCurrentValueTable.TABLE) + .build(); +} diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAOTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAOTest.java new file mode 100644 index 00000000000..0164d3bab62 --- /dev/null +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAOTest.java @@ -0,0 +1,147 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres.quota; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.core.quota.QuotaComponent; +import org.apache.james.core.quota.QuotaCurrentValue; +import org.apache.james.core.quota.QuotaType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +class PostgresQuotaCurrentValueDAOTest { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresQuotaModule.MODULE); + + private static final QuotaCurrentValue.Key QUOTA_KEY = QuotaCurrentValue.Key.of(QuotaComponent.MAILBOX, "james@abc.com", QuotaType.SIZE); + + private PostgresQuotaCurrentValueDAO postgresQuotaCurrentValueDAO; + + @BeforeEach + void setup() { + postgresQuotaCurrentValueDAO = new PostgresQuotaCurrentValueDAO(postgresExtension.getPostgresExecutor()); + } + + @Test + void increaseQuotaCurrentValueShouldCreateNewRowSuccessfully() { + postgresQuotaCurrentValueDAO.increase(QUOTA_KEY, 100L).block(); + + assertThat(postgresQuotaCurrentValueDAO.getQuotaCurrentValue(QUOTA_KEY).block().getCurrentValue()) + .isEqualTo(100L); + } + + @Test + void increaseQuotaCurrentValueShouldCreateNewRowSuccessfullyWhenIncreaseAmountIsZero() { + postgresQuotaCurrentValueDAO.increase(QUOTA_KEY, 0L).block(); + + assertThat(postgresQuotaCurrentValueDAO.getQuotaCurrentValue(QUOTA_KEY).block().getCurrentValue()) + .isZero(); + } + + @Test + void increaseQuotaCurrentValueShouldIncreaseValueSuccessfully() { + assertThat(postgresQuotaCurrentValueDAO.getQuotaCurrentValue(QUOTA_KEY).block()).isNull(); + + postgresQuotaCurrentValueDAO.increase(QUOTA_KEY, 100L).block(); + postgresQuotaCurrentValueDAO.increase(QUOTA_KEY, 100L).block(); + + assertThat(postgresQuotaCurrentValueDAO.getQuotaCurrentValue(QUOTA_KEY).block().getCurrentValue()) + .isEqualTo(200L); + } + + @Test + void increaseQuotaCurrentValueShouldDecreaseValueSuccessfullyWhenIncreaseAmountIsNegative() { + postgresQuotaCurrentValueDAO.increase(QUOTA_KEY, 200L).block(); + postgresQuotaCurrentValueDAO.increase(QUOTA_KEY, -100L).block(); + + assertThat(postgresQuotaCurrentValueDAO.getQuotaCurrentValue(QUOTA_KEY).block().getCurrentValue()) + .isEqualTo(100L); + } + + @Test + void decreaseQuotaCurrentValueShouldDecreaseValueSuccessfully() { + postgresQuotaCurrentValueDAO.increase(QUOTA_KEY, 200L).block(); + postgresQuotaCurrentValueDAO.decrease(QUOTA_KEY, 100L).block(); + + assertThat(postgresQuotaCurrentValueDAO.getQuotaCurrentValue(QUOTA_KEY).block().getCurrentValue()) + .isEqualTo(100L); + } + + @Test + void decreaseQuotaCurrentValueDownToNegativeShouldAllowNegativeValue() { + postgresQuotaCurrentValueDAO.increase(QUOTA_KEY, 100L).block(); + postgresQuotaCurrentValueDAO.decrease(QUOTA_KEY, 1000L).block(); + + assertThat(postgresQuotaCurrentValueDAO.getQuotaCurrentValue(QUOTA_KEY).block().getCurrentValue()) + .isEqualTo(-900L); + } + + @Test + void decreaseQuotaCurrentValueWhenNoRecordYetShouldNotFailAndSetValueToZero() { + postgresQuotaCurrentValueDAO.decrease(QUOTA_KEY, 1000L).block(); + + assertThat(postgresQuotaCurrentValueDAO.getQuotaCurrentValue(QUOTA_KEY).block().getCurrentValue()) + .isZero(); + } + + @Test + void deleteQuotaCurrentValueShouldDeleteSuccessfully() { + QuotaCurrentValue.Key quotaKey = QuotaCurrentValue.Key.of(QuotaComponent.MAILBOX, "andre@abc.com", QuotaType.SIZE); + postgresQuotaCurrentValueDAO.increase(quotaKey, 100L).block(); + postgresQuotaCurrentValueDAO.deleteQuotaCurrentValue(quotaKey).block(); + + assertThat(postgresQuotaCurrentValueDAO.getQuotaCurrentValue(quotaKey).block()) + .isNull(); + } + + @Test + void deleteQuotaCurrentValueShouldResetCounterForever() { + postgresQuotaCurrentValueDAO.increase(QUOTA_KEY, 100L).block(); + postgresQuotaCurrentValueDAO.deleteQuotaCurrentValue(QUOTA_KEY).block(); + postgresQuotaCurrentValueDAO.increase(QUOTA_KEY, 100L).block(); + + assertThat(postgresQuotaCurrentValueDAO.getQuotaCurrentValue(QUOTA_KEY).block().getCurrentValue()) + .isEqualTo(100L); + } + + @Test + void getQuotasByComponentShouldGetAllQuotaTypesSuccessfully() { + QuotaCurrentValue.Key countQuotaKey = QuotaCurrentValue.Key.of(QuotaComponent.MAILBOX, "james@abc.com", QuotaType.COUNT); + + QuotaCurrentValue expectedQuotaSize = QuotaCurrentValue.builder().quotaComponent(QUOTA_KEY.getQuotaComponent()) + .identifier(QUOTA_KEY.getIdentifier()).quotaType(QUOTA_KEY.getQuotaType()).currentValue(100L).build(); + QuotaCurrentValue expectedQuotaCount = QuotaCurrentValue.builder().quotaComponent(countQuotaKey.getQuotaComponent()) + .identifier(countQuotaKey.getIdentifier()).quotaType(countQuotaKey.getQuotaType()).currentValue(56L).build(); + + postgresQuotaCurrentValueDAO.increase(QUOTA_KEY, 100L).block(); + postgresQuotaCurrentValueDAO.increase(countQuotaKey, 56L).block(); + + List actual = postgresQuotaCurrentValueDAO.getQuotaCurrentValues(QUOTA_KEY.getQuotaComponent(), QUOTA_KEY.getIdentifier()) + .collectList() + .block(); + + assertThat(actual).containsExactlyInAnyOrder(expectedQuotaSize, expectedQuotaCount); + } +} diff --git a/core/src/main/java/org/apache/james/core/quota/QuotaCurrentValue.java b/core/src/main/java/org/apache/james/core/quota/QuotaCurrentValue.java index 682f10c7bcb..c1b38bb819f 100644 --- a/core/src/main/java/org/apache/james/core/quota/QuotaCurrentValue.java +++ b/core/src/main/java/org/apache/james/core/quota/QuotaCurrentValue.java @@ -26,6 +26,59 @@ public class QuotaCurrentValue { + public static class Key { + + public static Key of(QuotaComponent component, String identifier, QuotaType quotaType) { + return new Key(component, identifier, quotaType); + } + + private final QuotaComponent quotaComponent; + private final String identifier; + private final QuotaType quotaType; + + public QuotaComponent getQuotaComponent() { + return quotaComponent; + } + + public String getIdentifier() { + return identifier; + } + + public QuotaType getQuotaType() { + return quotaType; + } + + private Key(QuotaComponent quotaComponent, String identifier, QuotaType quotaType) { + this.quotaComponent = quotaComponent; + this.identifier = identifier; + this.quotaType = quotaType; + } + + @Override + public final int hashCode() { + return Objects.hash(quotaComponent, identifier, quotaType); + } + + @Override + public final boolean equals(Object o) { + if (o instanceof Key) { + Key other = (Key) o; + return Objects.equals(quotaComponent, other.quotaComponent) + && Objects.equals(identifier, other.identifier) + && Objects.equals(quotaType, other.quotaType); + } + return false; + } + + public String toString() { + return MoreObjects.toStringHelper(this) + .add("quotaComponent", quotaComponent) + .add("identifier", identifier) + .add("quotaType", quotaType) + .toString(); + } + } + public static class Builder { private QuotaComponent quotaComponent; private String identifier; diff --git a/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraCurrentQuotaManagerV2.java b/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraCurrentQuotaManagerV2.java index fba7cc01ab6..d455706f429 100644 --- a/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraCurrentQuotaManagerV2.java +++ b/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraCurrentQuotaManagerV2.java @@ -26,7 +26,6 @@ import jakarta.inject.Inject; import org.apache.james.backends.cassandra.components.CassandraQuotaCurrentValueDao; -import org.apache.james.backends.cassandra.components.CassandraQuotaCurrentValueDao.QuotaKey; import org.apache.james.core.quota.QuotaComponent; import org.apache.james.core.quota.QuotaCountUsage; import org.apache.james.core.quota.QuotaCurrentValue; @@ -117,16 +116,16 @@ public Mono setCurrentQuotas(QuotaOperation quotaOperation) { }); } - private QuotaKey asQuotaKeyCount(QuotaRoot quotaRoot) { + private QuotaCurrentValue.Key asQuotaKeyCount(QuotaRoot quotaRoot) { return asQuotaKey(quotaRoot, QuotaType.COUNT); } - private QuotaKey asQuotaKeySize(QuotaRoot quotaRoot) { + private QuotaCurrentValue.Key asQuotaKeySize(QuotaRoot quotaRoot) { return asQuotaKey(quotaRoot, QuotaType.SIZE); } - private QuotaKey asQuotaKey(QuotaRoot quotaRoot, QuotaType quotaType) { - return QuotaKey.of( + private QuotaCurrentValue.Key asQuotaKey(QuotaRoot quotaRoot, QuotaType quotaType) { + return QuotaCurrentValue.Key.of( QuotaComponent.MAILBOX, quotaRoot.asString(), quotaType); diff --git a/server/data/data-cassandra/src/main/java/org/apache/james/sieve/cassandra/CassandraSieveQuotaDAOV2.java b/server/data/data-cassandra/src/main/java/org/apache/james/sieve/cassandra/CassandraSieveQuotaDAOV2.java index 668ffd71646..f3ef61f722e 100644 --- a/server/data/data-cassandra/src/main/java/org/apache/james/sieve/cassandra/CassandraSieveQuotaDAOV2.java +++ b/server/data/data-cassandra/src/main/java/org/apache/james/sieve/cassandra/CassandraSieveQuotaDAOV2.java @@ -51,14 +51,14 @@ public CassandraSieveQuotaDAOV2(CassandraQuotaCurrentValueDao currentValueDao, C @Override public Mono spaceUsedBy(Username username) { - CassandraQuotaCurrentValueDao.QuotaKey quotaKey = asQuotaKey(username); + QuotaCurrentValue.Key quotaKey = asQuotaKey(username); return currentValueDao.getQuotaCurrentValue(quotaKey).map(QuotaCurrentValue::getCurrentValue) .switchIfEmpty(Mono.just(0L)); } - private CassandraQuotaCurrentValueDao.QuotaKey asQuotaKey(Username username) { - return CassandraQuotaCurrentValueDao.QuotaKey.of( + private QuotaCurrentValue.Key asQuotaKey(Username username) { + return QuotaCurrentValue.Key.of( QUOTA_COMPONENT, username.asString(), QuotaType.SIZE); @@ -66,7 +66,7 @@ private CassandraQuotaCurrentValueDao.QuotaKey asQuotaKey(Username username) { @Override public Mono updateSpaceUsed(Username username, long spaceUsed) { - CassandraQuotaCurrentValueDao.QuotaKey quotaKey = asQuotaKey(username); + QuotaCurrentValue.Key quotaKey = asQuotaKey(username); return currentValueDao.deleteQuotaCurrentValue(quotaKey) .then(currentValueDao.increase(quotaKey, spaceUsed)); diff --git a/server/data/data-jmap-cassandra/src/main/java/org/apache/james/jmap/cassandra/upload/CassandraUploadUsageRepository.java b/server/data/data-jmap-cassandra/src/main/java/org/apache/james/jmap/cassandra/upload/CassandraUploadUsageRepository.java index 6978cfc8971..513100b9cfc 100644 --- a/server/data/data-jmap-cassandra/src/main/java/org/apache/james/jmap/cassandra/upload/CassandraUploadUsageRepository.java +++ b/server/data/data-jmap-cassandra/src/main/java/org/apache/james/jmap/cassandra/upload/CassandraUploadUsageRepository.java @@ -24,6 +24,7 @@ import org.apache.james.backends.cassandra.components.CassandraQuotaCurrentValueDao; import org.apache.james.core.Username; import org.apache.james.core.quota.QuotaComponent; +import org.apache.james.core.quota.QuotaCurrentValue; import org.apache.james.core.quota.QuotaSizeUsage; import org.apache.james.core.quota.QuotaType; import org.apache.james.jmap.api.upload.UploadUsageRepository; @@ -43,19 +44,19 @@ public CassandraUploadUsageRepository(CassandraQuotaCurrentValueDao cassandraQuo @Override public Mono increaseSpace(Username username, QuotaSizeUsage usage) { - return cassandraQuotaCurrentValueDao.increase(CassandraQuotaCurrentValueDao.QuotaKey.of(QuotaComponent.JMAP_UPLOADS, username.asString(), QuotaType.SIZE), + return cassandraQuotaCurrentValueDao.increase(QuotaCurrentValue.Key.of(QuotaComponent.JMAP_UPLOADS, username.asString(), QuotaType.SIZE), usage.asLong()); } @Override public Mono decreaseSpace(Username username, QuotaSizeUsage usage) { - return cassandraQuotaCurrentValueDao.decrease(CassandraQuotaCurrentValueDao.QuotaKey.of(QuotaComponent.JMAP_UPLOADS, username.asString(), QuotaType.SIZE), + return cassandraQuotaCurrentValueDao.decrease(QuotaCurrentValue.Key.of(QuotaComponent.JMAP_UPLOADS, username.asString(), QuotaType.SIZE), usage.asLong()); } @Override public Mono getSpaceUsage(Username username) { - return cassandraQuotaCurrentValueDao.getQuotaCurrentValue(CassandraQuotaCurrentValueDao.QuotaKey.of(QuotaComponent.JMAP_UPLOADS, username.asString(), QuotaType.SIZE)) + return cassandraQuotaCurrentValueDao.getQuotaCurrentValue(QuotaCurrentValue.Key.of(QuotaComponent.JMAP_UPLOADS, username.asString(), QuotaType.SIZE)) .map(quotaCurrentValue -> QuotaSizeUsage.size(quotaCurrentValue.getCurrentValue())).defaultIfEmpty(DEFAULT_QUOTA_SIZE_USAGE); } From 209082e6530af25366a679deb171268383aa9091 Mon Sep 17 00:00:00 2001 From: hung phan Date: Fri, 24 Nov 2023 10:52:29 +0700 Subject: [PATCH 075/341] JAMES-2586 Implement PostgresQuotaLimitDAO --- .../components/CassandraQuotaLimitDao.java | 72 +------------- .../quota/CassandraQuotaLimitDaoTest.java | 8 +- .../postgres/quota/PostgresQuotaLimitDAO.java | 98 +++++++++++++++++++ .../postgres/quota/PostgresQuotaModule.java | 25 ++++- .../quota/PostgresQuotaLimitDaoTest.java | 84 ++++++++++++++++ .../apache/james/core/quota/QuotaLimit.java | 59 +++++++++++ .../CassandraPerUserMaxQuotaManagerV2.java | 17 ++-- .../cassandra/CassandraSieveQuotaDAOV2.java | 4 +- 8 files changed, 283 insertions(+), 84 deletions(-) create mode 100644 backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaLimitDAO.java create mode 100644 backends-common/postgres/src/test/java/org/apache/james/backends/postgres/quota/PostgresQuotaLimitDaoTest.java diff --git a/backends-common/cassandra/src/main/java/org/apache/james/backends/cassandra/components/CassandraQuotaLimitDao.java b/backends-common/cassandra/src/main/java/org/apache/james/backends/cassandra/components/CassandraQuotaLimitDao.java index c43442ac5ff..2b3090a6403 100644 --- a/backends-common/cassandra/src/main/java/org/apache/james/backends/cassandra/components/CassandraQuotaLimitDao.java +++ b/backends-common/cassandra/src/main/java/org/apache/james/backends/cassandra/components/CassandraQuotaLimitDao.java @@ -31,8 +31,6 @@ import static org.apache.james.backends.cassandra.components.CassandraQuotaLimitTable.QUOTA_TYPE; import static org.apache.james.backends.cassandra.components.CassandraQuotaLimitTable.TABLE_NAME; -import java.util.Objects; - import jakarta.inject.Inject; import org.apache.james.backends.cassandra.utils.CassandraAsyncExecutor; @@ -47,74 +45,11 @@ import com.datastax.oss.driver.api.querybuilder.delete.Delete; import com.datastax.oss.driver.api.querybuilder.insert.Insert; import com.datastax.oss.driver.api.querybuilder.select.Select; -import com.google.common.base.MoreObjects; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; public class CassandraQuotaLimitDao { - - public static class QuotaLimitKey { - - public static QuotaLimitKey of(QuotaComponent component, QuotaScope scope, String identifier, QuotaType quotaType) { - return new QuotaLimitKey(component, scope, identifier, quotaType); - } - - private final QuotaComponent quotaComponent; - private final QuotaScope quotaScope; - private final String identifier; - private final QuotaType quotaType; - - public QuotaComponent getQuotaComponent() { - return quotaComponent; - } - - public QuotaScope getQuotaScope() { - return quotaScope; - } - - public String getIdentifier() { - return identifier; - } - - public QuotaType getQuotaType() { - return quotaType; - } - - private QuotaLimitKey(QuotaComponent quotaComponent, QuotaScope quotaScope, String identifier, QuotaType quotaType) { - this.quotaComponent = quotaComponent; - this.quotaScope = quotaScope; - this.identifier = identifier; - this.quotaType = quotaType; - } - - @Override - public final int hashCode() { - return Objects.hash(quotaComponent, quotaScope, identifier, quotaType); - } - - @Override - public final boolean equals(Object o) { - if (o instanceof QuotaLimitKey) { - QuotaLimitKey other = (QuotaLimitKey) o; - return Objects.equals(quotaComponent, other.quotaComponent) - && Objects.equals(quotaScope, other.quotaScope) - && Objects.equals(identifier, other.identifier) - && Objects.equals(quotaType, other.quotaType); - } - return false; - } - - public String toString() { - return MoreObjects.toStringHelper(this) - .add("quotaComponent", quotaComponent) - .add("quotaScope", quotaScope) - .add("identifier", identifier) - .add("quotaType", quotaType) - .toString(); - } - } - private final CassandraAsyncExecutor queryExecutor; private final PreparedStatement getQuotaLimitStatement; private final PreparedStatement getQuotaLimitsStatement; @@ -130,7 +65,7 @@ public CassandraQuotaLimitDao(CqlSession session) { this.deleteQuotaLimitStatement = session.prepare((deleteQuotaLimitStatement().build())); } - public Mono getQuotaLimit(QuotaLimitKey quotaKey) { + public Mono getQuotaLimit(QuotaLimit.QuotaLimitKey quotaKey) { return queryExecutor.executeSingleRow(getQuotaLimitStatement.bind() .setString(QUOTA_COMPONENT, quotaKey.getQuotaComponent().getValue()) .setString(QUOTA_SCOPE, quotaKey.getQuotaScope().getValue()) @@ -156,7 +91,7 @@ public Mono setQuotaLimit(QuotaLimit quotaLimit) { .set(QUOTA_LIMIT, quotaLimit.getQuotaLimit().orElse(null), Long.class)); } - public Mono deleteQuotaLimit(QuotaLimitKey quotaKey) { + public Mono deleteQuotaLimit(QuotaLimit.QuotaLimitKey quotaKey) { return queryExecutor.executeVoid(deleteQuotaLimitStatement.bind() .setString(QUOTA_COMPONENT, quotaKey.getQuotaComponent().getValue()) .setString(QUOTA_SCOPE, quotaKey.getQuotaScope().getValue()) @@ -203,7 +138,8 @@ private QuotaLimit convertRowToModel(Row row) { .quotaScope(QuotaScope.of(row.get(QUOTA_SCOPE, String.class))) .identifier(row.get(IDENTIFIER, String.class)) .quotaType(QuotaType.of(row.get(QUOTA_TYPE, String.class))) - .quotaLimit(row.get(QUOTA_LIMIT, Long.class)).build(); + .quotaLimit(row.get(QUOTA_LIMIT, Long.class)) + .build(); } } \ No newline at end of file diff --git a/backends-common/cassandra/src/test/java/org/apache/james/backends/cassandra/quota/CassandraQuotaLimitDaoTest.java b/backends-common/cassandra/src/test/java/org/apache/james/backends/cassandra/quota/CassandraQuotaLimitDaoTest.java index 2c421471756..92127c412a6 100644 --- a/backends-common/cassandra/src/test/java/org/apache/james/backends/cassandra/quota/CassandraQuotaLimitDaoTest.java +++ b/backends-common/cassandra/src/test/java/org/apache/james/backends/cassandra/quota/CassandraQuotaLimitDaoTest.java @@ -61,7 +61,7 @@ void setQuotaLimitShouldSaveObjectSuccessfully() { QuotaLimit expected = QuotaLimit.builder().quotaComponent(QuotaComponent.MAILBOX).quotaScope(QuotaScope.DOMAIN).identifier("A").quotaType(QuotaType.COUNT).quotaLimit(100L).build(); cassandraQuotaLimitDao.setQuotaLimit(expected).block(); - assertThat(cassandraQuotaLimitDao.getQuotaLimit(CassandraQuotaLimitDao.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.DOMAIN, "A", QuotaType.COUNT)).block()) + assertThat(cassandraQuotaLimitDao.getQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.DOMAIN, "A", QuotaType.COUNT)).block()) .isEqualTo(expected); } @@ -79,7 +79,7 @@ void setQuotaLimitShouldSaveObjectSuccessfullyWhenLimitIsMinusOne() { QuotaLimit expected = QuotaLimit.builder().quotaComponent(QuotaComponent.MAILBOX).quotaScope(QuotaScope.DOMAIN).identifier("A").quotaType(QuotaType.COUNT).quotaLimit(-1L).build(); cassandraQuotaLimitDao.setQuotaLimit(expected).block(); - assertThat(cassandraQuotaLimitDao.getQuotaLimit(CassandraQuotaLimitDao.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.DOMAIN, "A", QuotaType.COUNT)).block()) + assertThat(cassandraQuotaLimitDao.getQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.DOMAIN, "A", QuotaType.COUNT)).block()) .isEqualTo(expected); } @@ -87,9 +87,9 @@ void setQuotaLimitShouldSaveObjectSuccessfullyWhenLimitIsMinusOne() { void deleteQuotaLimitShouldDeleteObjectSuccessfully() { QuotaLimit quotaLimit = QuotaLimit.builder().quotaComponent(QuotaComponent.MAILBOX).quotaScope(QuotaScope.DOMAIN).identifier("A").quotaType(QuotaType.COUNT).quotaLimit(100L).build(); cassandraQuotaLimitDao.setQuotaLimit(quotaLimit).block(); - cassandraQuotaLimitDao.deleteQuotaLimit(CassandraQuotaLimitDao.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.DOMAIN, "A", QuotaType.COUNT)).block(); + cassandraQuotaLimitDao.deleteQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.DOMAIN, "A", QuotaType.COUNT)).block(); - assertThat(cassandraQuotaLimitDao.getQuotaLimit(CassandraQuotaLimitDao.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.DOMAIN, "A", QuotaType.COUNT)).block()) + assertThat(cassandraQuotaLimitDao.getQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.DOMAIN, "A", QuotaType.COUNT)).block()) .isNull(); } diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaLimitDAO.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaLimitDAO.java new file mode 100644 index 00000000000..ff17e948411 --- /dev/null +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaLimitDAO.java @@ -0,0 +1,98 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres.quota; + +import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaLimitTable.IDENTIFIER; +import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaLimitTable.PK_CONSTRAINT_NAME; +import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaLimitTable.QUOTA_COMPONENT; +import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaLimitTable.QUOTA_LIMIT; +import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaLimitTable.QUOTA_SCOPE; +import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaLimitTable.QUOTA_TYPE; +import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaLimitTable.TABLE_NAME; + +import javax.inject.Inject; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.core.quota.QuotaComponent; +import org.apache.james.core.quota.QuotaLimit; +import org.apache.james.core.quota.QuotaScope; +import org.apache.james.core.quota.QuotaType; +import org.jooq.Record; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresQuotaLimitDAO { + private static final Long EMPTY_QUOTA_LIMIT = null; + + private final PostgresExecutor postgresExecutor; + + @Inject + public PostgresQuotaLimitDAO(PostgresExecutor postgresExecutor) { + this.postgresExecutor = postgresExecutor; + } + + public Mono getQuotaLimit(QuotaLimit.QuotaLimitKey quotaKey) { + return postgresExecutor.executeRow(dsl -> Mono.from(dsl.selectFrom(TABLE_NAME) + .where(QUOTA_COMPONENT.eq(quotaKey.getQuotaComponent().getValue())) + .and(QUOTA_SCOPE.eq(quotaKey.getQuotaScope().getValue())) + .and(IDENTIFIER.eq(quotaKey.getIdentifier())) + .and(QUOTA_TYPE.eq(quotaKey.getQuotaType().getValue())))) + .map(this::asQuotaLimit); + } + + public Flux getQuotaLimits(QuotaComponent quotaComponent, QuotaScope quotaScope, String identifier) { + return postgresExecutor.executeRows(dsl -> Flux.from(dsl.selectFrom(TABLE_NAME) + .where(QUOTA_COMPONENT.eq(quotaComponent.getValue())) + .and(QUOTA_SCOPE.eq(quotaScope.getValue())) + .and(IDENTIFIER.eq(identifier)))) + .map(this::asQuotaLimit); + } + + public Mono setQuotaLimit(QuotaLimit quotaLimit) { + return postgresExecutor.executeVoid(dslContext -> + Mono.from(dslContext.insertInto(TABLE_NAME, QUOTA_SCOPE, IDENTIFIER, QUOTA_COMPONENT, QUOTA_TYPE, QUOTA_LIMIT) + .values(quotaLimit.getQuotaScope().getValue(), + quotaLimit.getIdentifier(), + quotaLimit.getQuotaComponent().getValue(), + quotaLimit.getQuotaType().getValue(), + quotaLimit.getQuotaLimit().orElse(EMPTY_QUOTA_LIMIT)) + .onConflictOnConstraint(PK_CONSTRAINT_NAME) + .doUpdate() + .set(QUOTA_LIMIT, quotaLimit.getQuotaLimit().orElse(EMPTY_QUOTA_LIMIT)))); + } + + public Mono deleteQuotaLimit(QuotaLimit.QuotaLimitKey quotaKey) { + return postgresExecutor.executeVoid(dsl -> Mono.from(dsl.deleteFrom(TABLE_NAME) + .where(QUOTA_COMPONENT.eq(quotaKey.getQuotaComponent().getValue())) + .and(QUOTA_SCOPE.eq(quotaKey.getQuotaScope().getValue())) + .and(IDENTIFIER.eq(quotaKey.getIdentifier())) + .and(QUOTA_TYPE.eq(quotaKey.getQuotaType().getValue())))); + } + + private QuotaLimit asQuotaLimit(Record record) { + return QuotaLimit.builder().quotaComponent(QuotaComponent.of(record.get(QUOTA_COMPONENT))) + .quotaScope(QuotaScope.of(record.get(QUOTA_SCOPE))) + .identifier(record.get(IDENTIFIER)) + .quotaType(QuotaType.of(record.get(QUOTA_TYPE))) + .quotaLimit(record.get(QUOTA_LIMIT)) + .build(); + } +} diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaModule.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaModule.java index dad84108d04..a3ffe8597ae 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaModule.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaModule.java @@ -53,7 +53,30 @@ interface PostgresQuotaCurrentValueTable { .disableRowLevelSecurity(); } + interface PostgresQuotaLimitTable { + Table TABLE_NAME = DSL.table("quota_limit"); + + Field QUOTA_SCOPE = DSL.field("quota_scope", SQLDataType.VARCHAR.notNull()); + Field IDENTIFIER = DSL.field("identifier", SQLDataType.VARCHAR.notNull()); + Field QUOTA_COMPONENT = DSL.field("quota_component", SQLDataType.VARCHAR.notNull()); + Field QUOTA_TYPE = DSL.field("quota_type", SQLDataType.VARCHAR.notNull()); + Field QUOTA_LIMIT = DSL.field("quota_limit", SQLDataType.BIGINT); + + Name PK_CONSTRAINT_NAME = DSL.name("quota_limit_pkey"); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTable(tableName) + .column(QUOTA_SCOPE) + .column(IDENTIFIER) + .column(QUOTA_COMPONENT) + .column(QUOTA_TYPE) + .column(QUOTA_LIMIT) + .constraint(DSL.constraint(PK_CONSTRAINT_NAME).primaryKey(QUOTA_SCOPE, IDENTIFIER, QUOTA_COMPONENT, QUOTA_TYPE)))) + .supportsRowLevelSecurity(); + } + PostgresModule MODULE = PostgresModule.builder() .addTable(PostgresQuotaCurrentValueTable.TABLE) + .addTable(PostgresQuotaLimitTable.TABLE) .build(); -} +} \ No newline at end of file diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/quota/PostgresQuotaLimitDaoTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/quota/PostgresQuotaLimitDaoTest.java new file mode 100644 index 00000000000..4e382ef3d39 --- /dev/null +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/quota/PostgresQuotaLimitDaoTest.java @@ -0,0 +1,84 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres.quota; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.core.quota.QuotaComponent; +import org.apache.james.core.quota.QuotaLimit; +import org.apache.james.core.quota.QuotaScope; +import org.apache.james.core.quota.QuotaType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.assertj.core.api.Assertions.assertThat; + +public class PostgresQuotaLimitDaoTest { + + private PostgresQuotaLimitDAO postgresQuotaLimitDao; + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresQuotaModule.MODULE); + + @BeforeEach + void setup() { + postgresQuotaLimitDao = new PostgresQuotaLimitDAO(postgresExtension.getPostgresExecutor()); + } + + @Test + void getQuotaLimitsShouldGetSomeQuotaLimitsSuccessfully() { + QuotaLimit expectedOne = QuotaLimit.builder().quotaComponent(QuotaComponent.MAILBOX).quotaScope(QuotaScope.DOMAIN).identifier("A").quotaType(QuotaType.COUNT).quotaLimit(200l).build(); + QuotaLimit expectedTwo = QuotaLimit.builder().quotaComponent(QuotaComponent.MAILBOX).quotaScope(QuotaScope.DOMAIN).identifier("A").quotaType(QuotaType.SIZE).quotaLimit(100l).build(); + postgresQuotaLimitDao.setQuotaLimit(expectedOne).block(); + postgresQuotaLimitDao.setQuotaLimit(expectedTwo).block(); + + assertThat(postgresQuotaLimitDao.getQuotaLimits(QuotaComponent.MAILBOX, QuotaScope.DOMAIN, "A").collectList().block()) + .containsExactlyInAnyOrder(expectedOne, expectedTwo); + } + + @Test + void setQuotaLimitShouldSaveObjectSuccessfully() { + QuotaLimit expected = QuotaLimit.builder().quotaComponent(QuotaComponent.MAILBOX).quotaScope(QuotaScope.DOMAIN).identifier("A").quotaType(QuotaType.COUNT).quotaLimit(100l).build(); + postgresQuotaLimitDao.setQuotaLimit(expected).block(); + + assertThat(postgresQuotaLimitDao.getQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.DOMAIN, "A", QuotaType.COUNT)).block()) + .isEqualTo(expected); + } + + @Test + void setQuotaLimitShouldSaveObjectSuccessfullyWhenLimitIsMinusOne() { + QuotaLimit expected = QuotaLimit.builder().quotaComponent(QuotaComponent.MAILBOX).quotaScope(QuotaScope.DOMAIN).identifier("A").quotaType(QuotaType.COUNT).quotaLimit(-1l).build(); + postgresQuotaLimitDao.setQuotaLimit(expected).block(); + + assertThat(postgresQuotaLimitDao.getQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.DOMAIN, "A", QuotaType.COUNT)).block()) + .isEqualTo(expected); + } + + @Test + void deleteQuotaLimitShouldDeleteObjectSuccessfully() { + QuotaLimit quotaLimit = QuotaLimit.builder().quotaComponent(QuotaComponent.MAILBOX).quotaScope(QuotaScope.DOMAIN).identifier("A").quotaType(QuotaType.COUNT).quotaLimit(100l).build(); + postgresQuotaLimitDao.setQuotaLimit(quotaLimit).block(); + postgresQuotaLimitDao.deleteQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.DOMAIN, "A", QuotaType.COUNT)).block(); + + assertThat(postgresQuotaLimitDao.getQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.DOMAIN, "A", QuotaType.COUNT)).block()) + .isNull(); + } + +} \ No newline at end of file diff --git a/core/src/main/java/org/apache/james/core/quota/QuotaLimit.java b/core/src/main/java/org/apache/james/core/quota/QuotaLimit.java index 5d49216be7d..0f371a5d051 100644 --- a/core/src/main/java/org/apache/james/core/quota/QuotaLimit.java +++ b/core/src/main/java/org/apache/james/core/quota/QuotaLimit.java @@ -26,6 +26,65 @@ import com.google.common.base.Preconditions; public class QuotaLimit { + public static class QuotaLimitKey { + public static QuotaLimitKey of(QuotaComponent component, QuotaScope scope, String identifier, QuotaType quotaType) { + return new QuotaLimitKey(component, scope, identifier, quotaType); + } + + private final QuotaComponent quotaComponent; + private final QuotaScope quotaScope; + private final String identifier; + private final QuotaType quotaType; + + public QuotaComponent getQuotaComponent() { + return quotaComponent; + } + + public QuotaScope getQuotaScope() { + return quotaScope; + } + + public String getIdentifier() { + return identifier; + } + + public QuotaType getQuotaType() { + return quotaType; + } + + private QuotaLimitKey(QuotaComponent quotaComponent, QuotaScope quotaScope, String identifier, QuotaType quotaType) { + this.quotaComponent = quotaComponent; + this.quotaScope = quotaScope; + this.identifier = identifier; + this.quotaType = quotaType; + } + + @Override + public final int hashCode() { + return Objects.hash(quotaComponent, quotaScope, identifier, quotaType); + } + + @Override + public final boolean equals(Object o) { + if (o instanceof QuotaLimitKey) { + QuotaLimitKey other = (QuotaLimitKey) o; + return Objects.equals(quotaComponent, other.quotaComponent) + && Objects.equals(quotaScope, other.quotaScope) + && Objects.equals(identifier, other.identifier) + && Objects.equals(quotaType, other.quotaType); + } + return false; + } + + public String toString() { + return MoreObjects.toStringHelper(this) + .add("quotaComponent", quotaComponent) + .add("quotaScope", quotaScope) + .add("identifier", identifier) + .add("quotaType", quotaType) + .toString(); + } + } public static class Builder { private QuotaComponent quotaComponent; diff --git a/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraPerUserMaxQuotaManagerV2.java b/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraPerUserMaxQuotaManagerV2.java index e310aa93430..6dc23e14229 100644 --- a/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraPerUserMaxQuotaManagerV2.java +++ b/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraPerUserMaxQuotaManagerV2.java @@ -19,7 +19,6 @@ package org.apache.james.mailbox.cassandra.quota; -import static org.apache.james.backends.cassandra.components.CassandraQuotaLimitDao.QuotaLimitKey; import static org.apache.james.util.ReactorUtils.publishIfPresent; import java.util.Map; @@ -130,7 +129,7 @@ public void removeDomainMaxMessage(Domain domain) { @Override public Mono removeDomainMaxMessageReactive(Domain domain) { - return cassandraQuotaLimitDao.deleteQuotaLimit(QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.DOMAIN, domain.asString(), QuotaType.COUNT)); + return cassandraQuotaLimitDao.deleteQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.DOMAIN, domain.asString(), QuotaType.COUNT)); } @Override @@ -140,7 +139,7 @@ public void removeDomainMaxStorage(Domain domain) { @Override public Mono removeDomainMaxStorageReactive(Domain domain) { - return cassandraQuotaLimitDao.deleteQuotaLimit(QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.DOMAIN, domain.asString(), QuotaType.SIZE)); + return cassandraQuotaLimitDao.deleteQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.DOMAIN, domain.asString(), QuotaType.SIZE)); } @Override @@ -170,7 +169,7 @@ public void removeMaxMessage(QuotaRoot quotaRoot) { @Override public Mono removeMaxMessageReactive(QuotaRoot quotaRoot) { - return cassandraQuotaLimitDao.deleteQuotaLimit(QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.USER, quotaRoot.getValue(), QuotaType.COUNT)); + return cassandraQuotaLimitDao.deleteQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.USER, quotaRoot.getValue(), QuotaType.COUNT)); } @Override @@ -180,7 +179,7 @@ public void removeMaxStorage(QuotaRoot quotaRoot) { @Override public Mono removeMaxStorageReactive(QuotaRoot quotaRoot) { - return cassandraQuotaLimitDao.deleteQuotaLimit(QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.USER, quotaRoot.getValue(), QuotaType.SIZE)); + return cassandraQuotaLimitDao.deleteQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.USER, quotaRoot.getValue(), QuotaType.SIZE)); } @Override @@ -205,7 +204,7 @@ public void removeGlobalMaxStorage() { @Override public Mono removeGlobalMaxStorageReactive() { - return cassandraQuotaLimitDao.deleteQuotaLimit(QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.GLOBAL, GLOBAL_IDENTIFIER, QuotaType.SIZE)); + return cassandraQuotaLimitDao.deleteQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.GLOBAL, GLOBAL_IDENTIFIER, QuotaType.SIZE)); } @Override @@ -230,7 +229,7 @@ public void removeGlobalMaxMessage() { @Override public Mono removeGlobalMaxMessageReactive() { - return cassandraQuotaLimitDao.deleteQuotaLimit(QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.GLOBAL, GLOBAL_IDENTIFIER, QuotaType.COUNT)); + return cassandraQuotaLimitDao.deleteQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.GLOBAL, GLOBAL_IDENTIFIER, QuotaType.COUNT)); } @Override @@ -322,7 +321,7 @@ private Mono getLimits(QuotaScope quotaScope, String identifier) { } private Mono getMaxMessageReactive(QuotaScope quotaScope, String identifier) { - return cassandraQuotaLimitDao.getQuotaLimit(QuotaLimitKey.of(QuotaComponent.MAILBOX, quotaScope, identifier, QuotaType.COUNT)) + return cassandraQuotaLimitDao.getQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, quotaScope, identifier, QuotaType.COUNT)) .map(QuotaLimit::getQuotaLimit) .handle(publishIfPresent()) .map(QuotaCodec::longToQuotaCount) @@ -330,7 +329,7 @@ private Mono getMaxMessageReactive(QuotaScope quotaScope, Strin } public Mono getMaxStorageReactive(QuotaScope quotaScope, String identifier) { - return cassandraQuotaLimitDao.getQuotaLimit(QuotaLimitKey.of(QuotaComponent.MAILBOX, quotaScope, identifier, QuotaType.SIZE)) + return cassandraQuotaLimitDao.getQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, quotaScope, identifier, QuotaType.SIZE)) .map(QuotaLimit::getQuotaLimit) .handle(publishIfPresent()) .map(QuotaCodec::longToQuotaSize) diff --git a/server/data/data-cassandra/src/main/java/org/apache/james/sieve/cassandra/CassandraSieveQuotaDAOV2.java b/server/data/data-cassandra/src/main/java/org/apache/james/sieve/cassandra/CassandraSieveQuotaDAOV2.java index f3ef61f722e..798b23d13bc 100644 --- a/server/data/data-cassandra/src/main/java/org/apache/james/sieve/cassandra/CassandraSieveQuotaDAOV2.java +++ b/server/data/data-cassandra/src/main/java/org/apache/james/sieve/cassandra/CassandraSieveQuotaDAOV2.java @@ -93,7 +93,7 @@ public Mono setQuota(QuotaSizeLimit quota) { @Override public Mono removeQuota() { - return limitDao.deleteQuotaLimit(CassandraQuotaLimitDao.QuotaLimitKey.of(QUOTA_COMPONENT, QuotaScope.GLOBAL, GLOBAL, QuotaType.SIZE)); + return limitDao.deleteQuotaLimit(QuotaLimit.QuotaLimitKey.of(QUOTA_COMPONENT, QuotaScope.GLOBAL, GLOBAL, QuotaType.SIZE)); } @Override @@ -117,7 +117,7 @@ public Mono setQuota(Username username, QuotaSizeLimit quota) { @Override public Mono removeQuota(Username username) { - return limitDao.deleteQuotaLimit(CassandraQuotaLimitDao.QuotaLimitKey.of( + return limitDao.deleteQuotaLimit(QuotaLimit.QuotaLimitKey.of( QUOTA_COMPONENT, QuotaScope.USER, username.asString(), QuotaType.SIZE)); } From b0e361daafaf25f20d35012385bd4db76f902269 Mon Sep 17 00:00:00 2001 From: vttran Date: Mon, 27 Nov 2023 14:49:27 +0700 Subject: [PATCH 076/341] =?UTF-8?q?JAMES-2586=20Clean=20Code=20=E2=80=93?= =?UTF-8?q?=20the=20using=20PostgresExecutor.Factory=20(#1816)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../postgres/PostgresTableManager.java | 8 ++++---- .../postgres/utils/PostgresExecutor.java | 4 +++- .../backends/postgres/PostgresExtension.java | 20 ++++++++++++++++--- .../postgres/PostgresTableManagerTest.java | 6 ++++-- .../PostgresMailboxSessionMapperFactory.java | 11 +++++----- .../postgres/JpaMailboxManagerProvider.java | 5 ++--- .../PostgresSubscriptionManagerTest.java | 3 +-- ...gresMailboxMapperRowLevelSecurityTest.java | 5 ++--- .../JPARecomputeCurrentQuotasServiceTest.java | 5 ++--- ...ubscriptionMapperRowLevelSecurityTest.java | 5 ++--- .../postgres/host/PostgresHostSystem.java | 6 +----- .../modules/data/PostgresCommonModule.java | 14 +++++++++++-- .../james/user/postgres/PostgresUsersDAO.java | 7 ++++--- .../postgres/PostgresUsersRepositoryTest.java | 2 +- 14 files changed, 60 insertions(+), 41 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java index a46e6b36a25..a7277dc414f 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java @@ -19,11 +19,11 @@ package org.apache.james.backends.postgres; -import java.util.Optional; +import static org.apache.james.backends.postgres.utils.PostgresExecutor.DEFAULT_INJECT; import javax.inject.Inject; +import javax.inject.Named; -import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.lifecycle.api.Startable; import org.jooq.exception.DataAccessException; @@ -43,10 +43,10 @@ public class PostgresTableManager implements Startable { private final boolean rowLevelSecurityEnabled; @Inject - public PostgresTableManager(JamesPostgresConnectionFactory postgresConnectionFactory, + public PostgresTableManager(@Named(DEFAULT_INJECT) PostgresExecutor postgresExecutor, PostgresModule module, PostgresConfiguration postgresConfiguration) { - this.postgresExecutor = new PostgresExecutor(postgresConnectionFactory.getConnection(Optional.empty())); + this.postgresExecutor = postgresExecutor; this.module = module; this.rowLevelSecurityEnabled = postgresConfiguration.rowLevelSecurityEnabled(); } diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index 3b3fd015694..7a6485108f5 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -41,6 +41,8 @@ public class PostgresExecutor { + public static final String DEFAULT_INJECT = "default"; + public static class Factory { private final JamesPostgresConnectionFactory jamesPostgresConnectionFactory; @@ -65,7 +67,7 @@ public PostgresExecutor create() { .withStatementType(StatementType.PREPARED_STATEMENT); private final Mono connection; - public PostgresExecutor(Mono connection) { + private PostgresExecutor(Mono connection) { this.connection = connection; } diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index 4f9ba51094a..476b5819eec 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -29,7 +29,9 @@ import org.apache.http.client.utils.URIBuilder; import org.apache.james.GuiceModuleTestExtension; +import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.backends.postgres.utils.SinglePostgresConnectionFactory; import org.junit.jupiter.api.extension.ExtensionContext; import org.testcontainers.containers.PostgreSQLContainer; @@ -65,6 +67,7 @@ public static PostgresExtension empty() { private PostgresConfiguration postgresConfiguration; private PostgresExecutor postgresExecutor; private PostgresqlConnectionFactory connectionFactory; + private PostgresExecutor.Factory executorFactory; private PostgresExtension(PostgresModule postgresModule, boolean rlsEnabled) { this.postgresModule = postgresModule; @@ -124,9 +127,16 @@ private void initPostgresSession() throws URISyntaxException { .schema(postgresConfiguration.getDatabaseSchema()) .build()); - postgresExecutor = new PostgresExecutor(connectionFactory.create() - .cache() - .cast(Connection.class)); + + if (rlsEnabled) { + executorFactory = new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(connectionFactory)); + } else { + executorFactory = new PostgresExecutor.Factory(new SinglePostgresConnectionFactory(connectionFactory.create() + .cache() + .cast(Connection.class).block())); + } + + postgresExecutor = executorFactory.create(); } @Override @@ -180,6 +190,10 @@ public ConnectionFactory getConnectionFactory() { return connectionFactory; } + public PostgresExecutor.Factory getExecutorFactory() { + return executorFactory; + } + private void initTablesAndIndexes() { PostgresTableManager postgresTableManager = new PostgresTableManager(postgresExecutor, postgresModule, postgresConfiguration.rowLevelSecurityEnabled()); postgresTableManager.initializeTables().block(); diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java index ac5d73c2d7a..e0150d79dbd 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java @@ -42,7 +42,7 @@ class PostgresTableManagerTest { static PostgresExtension postgresExtension = PostgresExtension.empty(); Function tableManagerFactory = - module -> new PostgresTableManager(new PostgresExecutor(postgresExtension.getConnection()), module, true); + module -> new PostgresTableManager(postgresExtension.getPostgresExecutor(), module, true); @Test void initializeTableShouldSuccessWhenModuleHasSingleTable() { @@ -330,7 +330,9 @@ void createTableShouldNotCreateRlsColumnWhenDisableRLS() { PostgresModule module = PostgresModule.table(table); boolean disabledRLS = false; - PostgresTableManager testee = new PostgresTableManager(new PostgresExecutor(postgresExtension.getConnection()), module, disabledRLS); + + + PostgresTableManager testee = new PostgresTableManager(postgresExtension.getPostgresExecutor(), module, disabledRLS); testee.initializeTables() .block(); diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java index 7b20e1996fa..34f5aa17b61 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java @@ -25,7 +25,6 @@ import org.apache.commons.lang3.NotImplementedException; import org.apache.james.backends.jpa.EntityManagerUtils; import org.apache.james.backends.jpa.JPAConfiguration; -import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.mailbox.MailboxSession; import org.apache.james.mailbox.postgres.mail.JPAAnnotationMapper; @@ -58,19 +57,20 @@ public class PostgresMailboxSessionMapperFactory extends MailboxSessionMapperFac private final JPAModSeqProvider modSeqProvider; private final AttachmentMapper attachmentMapper; private final JPAConfiguration jpaConfiguration; - private final JamesPostgresConnectionFactory postgresConnectionFactory; + + private final PostgresExecutor.Factory executorFactory; @Inject public PostgresMailboxSessionMapperFactory(EntityManagerFactory entityManagerFactory, JPAUidProvider uidProvider, JPAModSeqProvider modSeqProvider, JPAConfiguration jpaConfiguration, - JamesPostgresConnectionFactory postgresConnectionFactory) { + PostgresExecutor.Factory executorFactory) { this.entityManagerFactory = entityManagerFactory; this.uidProvider = uidProvider; this.modSeqProvider = modSeqProvider; EntityManagerUtils.safelyClose(createEntityManager()); this.attachmentMapper = new JPAAttachmentMapper(entityManagerFactory); this.jpaConfiguration = jpaConfiguration; - this.postgresConnectionFactory = postgresConnectionFactory; + this.executorFactory = executorFactory; } @Override @@ -90,8 +90,7 @@ public MessageIdMapper createMessageIdMapper(MailboxSession session) { @Override public SubscriptionMapper createSubscriptionMapper(MailboxSession session) { - return new PostgresSubscriptionMapper(new PostgresSubscriptionDAO(new PostgresExecutor( - postgresConnectionFactory.getConnection(session.getUser().getDomainPart())))); + return new PostgresSubscriptionMapper(new PostgresSubscriptionDAO(executorFactory.create(session.getUser().getDomainPart()))); } /** diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerProvider.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerProvider.java index 980804d2ccd..d6100b2ade8 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerProvider.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerProvider.java @@ -26,7 +26,6 @@ import org.apache.james.backends.jpa.JPAConfiguration; import org.apache.james.backends.jpa.JpaTestCluster; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; import org.apache.james.events.EventBusTestFixture; import org.apache.james.events.InVMEventBus; import org.apache.james.events.MemoryEventDeadLetters; @@ -65,8 +64,8 @@ public static OpenJPAMailboxManager provideMailboxManager(JpaTestCluster jpaTest .attachmentStorage(true) .build(); - PostgresMailboxSessionMapperFactory mf = new PostgresMailboxSessionMapperFactory(entityManagerFactory, new JPAUidProvider(entityManagerFactory), new JPAModSeqProvider(entityManagerFactory), jpaConfiguration, - new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory())); + PostgresMailboxSessionMapperFactory mf = new PostgresMailboxSessionMapperFactory(entityManagerFactory, new JPAUidProvider(entityManagerFactory), + new JPAModSeqProvider(entityManagerFactory), jpaConfiguration, postgresExtension.getExecutorFactory()); MailboxACLResolver aclResolver = new UnionMailboxACLResolver(); MessageParser messageParser = new MessageParser(); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresSubscriptionManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresSubscriptionManagerTest.java index ebf07bf37f1..c68ed09b84d 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresSubscriptionManagerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresSubscriptionManagerTest.java @@ -23,7 +23,6 @@ import org.apache.james.backends.jpa.JPAConfiguration; import org.apache.james.backends.jpa.JpaTestCluster; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; import org.apache.james.events.EventBusTestFixture; import org.apache.james.events.InVMEventBus; import org.apache.james.events.MemoryEventDeadLetters; @@ -66,7 +65,7 @@ void setUp() { new JPAUidProvider(entityManagerFactory), new JPAModSeqProvider(entityManagerFactory), jpaConfiguration, - new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory())); + postgresExtension.getExecutorFactory()); InVMEventBus eventBus = new InVMEventBus(new InVmEventDelivery(new RecordingMetricFactory()), EventBusTestFixture.RETRY_BACKOFF_CONFIGURATION, new MemoryEventDeadLetters()); subscriptionManager = new StoreSubscriptionManager(mapperFactory, mapperFactory, eventBus); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperRowLevelSecurityTest.java index 3eb23fe07e1..bdf719dfe23 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperRowLevelSecurityTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperRowLevelSecurityTest.java @@ -44,9 +44,8 @@ public class PostgresMailboxMapperRowLevelSecurityTest { @BeforeEach public void setUp() { - mailboxMapperFactory = session -> new PostgresMailboxMapper(new PostgresMailboxDAO(new PostgresExecutor( - new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory()) - .getConnection(session.getUser().getDomainPart())))); + PostgresExecutor.Factory executorFactory = new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory())); + mailboxMapperFactory = session -> new PostgresMailboxMapper(new PostgresMailboxDAO(executorFactory.create(session.getUser().getDomainPart()))); } @Test diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/JPARecomputeCurrentQuotasServiceTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/JPARecomputeCurrentQuotasServiceTest.java index ca3b89df12b..077c249c19c 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/JPARecomputeCurrentQuotasServiceTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/JPARecomputeCurrentQuotasServiceTest.java @@ -25,14 +25,13 @@ import org.apache.james.backends.jpa.JPAConfiguration; import org.apache.james.backends.jpa.JpaTestCluster; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; import org.apache.james.domainlist.api.DomainList; import org.apache.james.domainlist.jpa.model.JPADomain; import org.apache.james.mailbox.MailboxManager; import org.apache.james.mailbox.SessionProvider; import org.apache.james.mailbox.postgres.JPAMailboxFixture; -import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; import org.apache.james.mailbox.postgres.JpaMailboxManagerProvider; +import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; import org.apache.james.mailbox.postgres.mail.JPAModSeqProvider; import org.apache.james.mailbox.postgres.mail.JPAUidProvider; import org.apache.james.mailbox.postgres.quota.JpaCurrentQuotaManager; @@ -89,7 +88,7 @@ void setUp() throws Exception { new JPAUidProvider(entityManagerFactory), new JPAModSeqProvider(entityManagerFactory), jpaConfiguration, - new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory())); + postgresExtension.getExecutorFactory()); usersRepository = new JPAUsersRepository(NO_DOMAIN_LIST); usersRepository.setEntityManagerFactory(JPA_TEST_CLUSTER.getEntityManagerFactory()); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java index b9c1c2caa09..553d605612b 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java @@ -41,9 +41,8 @@ public class PostgresSubscriptionMapperRowLevelSecurityTest { @BeforeEach public void setUp() { - subscriptionMapperFactory = session -> new PostgresSubscriptionMapper(new PostgresSubscriptionDAO(new PostgresExecutor( - new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory()) - .getConnection(session.getUser().getDomainPart())))); + PostgresExecutor.Factory executorFactory = new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory())); + subscriptionMapperFactory = session -> new PostgresSubscriptionMapper(new PostgresSubscriptionDAO(executorFactory.create(session.getUser().getDomainPart()))); } @Test diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java index 5c98591f0ee..9fc4823f9a3 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java @@ -26,8 +26,6 @@ import org.apache.james.backends.jpa.JPAConfiguration; import org.apache.james.backends.jpa.JpaTestCluster; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; -import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; import org.apache.james.core.quota.QuotaCountLimit; import org.apache.james.core.quota.QuotaSizeLimit; import org.apache.james.events.EventBusTestFixture; @@ -99,14 +97,12 @@ static PostgresHostSystem build(PostgresExtension postgresExtension) { private JPAPerUserMaxQuotaManager maxQuotaManager; private OpenJPAMailboxManager mailboxManager; private final PostgresExtension postgresExtension; - private static JamesPostgresConnectionFactory postgresConnectionFactory; public PostgresHostSystem(PostgresExtension postgresExtension) { this.postgresExtension = postgresExtension; } public void beforeAll() { Preconditions.checkNotNull(postgresExtension.getConnectionFactory()); - postgresConnectionFactory = new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory()); } @Override @@ -119,7 +115,7 @@ public void beforeTest() throws Exception { .driverName("driverName") .driverURL("driverUrl") .build(); - PostgresMailboxSessionMapperFactory mapperFactory = new PostgresMailboxSessionMapperFactory(entityManagerFactory, uidProvider, modSeqProvider, jpaConfiguration, postgresConnectionFactory); + PostgresMailboxSessionMapperFactory mapperFactory = new PostgresMailboxSessionMapperFactory(entityManagerFactory, uidProvider, modSeqProvider, jpaConfiguration, postgresExtension.getExecutorFactory()); MailboxACLResolver aclResolver = new UnionMailboxACLResolver(); MessageParser messageParser = new MessageParser(); diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index d33097bc4f0..30dcf74a093 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -18,6 +18,8 @@ ****************************************************************/ package org.apache.james.modules.data; +import static org.apache.james.backends.postgres.utils.PostgresExecutor.DEFAULT_INJECT; + import java.io.FileNotFoundException; import java.util.Set; @@ -41,6 +43,7 @@ import com.google.inject.Singleton; import com.google.inject.multibindings.Multibinder; import com.google.inject.multibindings.ProvidesIntoSet; +import com.google.inject.name.Named; import io.r2dbc.postgresql.PostgresqlConnectionConfiguration; import io.r2dbc.postgresql.PostgresqlConnectionFactory; @@ -95,10 +98,17 @@ PostgresModule composePostgresDataDefinitions(Set modules) { @Provides @Singleton - PostgresTableManager postgresTableManager(JamesPostgresConnectionFactory jamesPostgresConnectionFactory, + PostgresTableManager postgresTableManager(@Named(DEFAULT_INJECT) PostgresExecutor defaultPostgresExecutor, PostgresModule postgresModule, PostgresConfiguration postgresConfiguration) { - return new PostgresTableManager(jamesPostgresConnectionFactory, postgresModule, postgresConfiguration); + return new PostgresTableManager(defaultPostgresExecutor, postgresModule, postgresConfiguration); + } + + @Provides + @Named(DEFAULT_INJECT) + @Singleton + PostgresExecutor defaultPostgresExecutor(PostgresExecutor.Factory factory) { + return factory.create(); } @ProvidesIntoSet diff --git a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java index 67c998b09a6..d8447e527fb 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java @@ -19,6 +19,7 @@ package org.apache.james.user.postgres; +import static org.apache.james.backends.postgres.utils.PostgresExecutor.DEFAULT_INJECT; import static org.apache.james.backends.postgres.utils.PostgresUtils.UNIQUE_CONSTRAINT_VIOLATION_PREDICATE; import static org.apache.james.user.postgres.PostgresUserModule.PostgresUserTable.ALGORITHM; import static org.apache.james.user.postgres.PostgresUserModule.PostgresUserTable.HASHED_PASSWORD; @@ -30,8 +31,8 @@ import java.util.Optional; import javax.inject.Inject; +import javax.inject.Named; -import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.Username; import org.apache.james.user.api.AlreadyExistInUsersRepositoryException; @@ -52,9 +53,9 @@ public class PostgresUsersDAO implements UsersDAO { private final Algorithm.HashingMode fallbackHashingMode; @Inject - public PostgresUsersDAO(JamesPostgresConnectionFactory jamesPostgresConnectionFactory, + public PostgresUsersDAO(@Named(DEFAULT_INJECT) PostgresExecutor postgresExecutor, PostgresUsersRepositoryConfiguration postgresUsersRepositoryConfiguration) { - this.postgresExecutor = new PostgresExecutor(jamesPostgresConnectionFactory.getConnection(Optional.empty())); + this.postgresExecutor = postgresExecutor; this.algorithm = postgresUsersRepositoryConfiguration.getPreferredAlgorithm(); this.fallbackHashingMode = postgresUsersRepositoryConfiguration.getFallbackHashingMode(); } diff --git a/server/data/data-postgres/src/test/java/org/apache/james/user/postgres/PostgresUsersRepositoryTest.java b/server/data/data-postgres/src/test/java/org/apache/james/user/postgres/PostgresUsersRepositoryTest.java index e83f03bf107..00c250104d7 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/user/postgres/PostgresUsersRepositoryTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/user/postgres/PostgresUsersRepositoryTest.java @@ -89,7 +89,7 @@ public UsersRepository testee(Optional administrator) throws Exception } private static UsersRepositoryImpl getUsersRepository(DomainList domainList, boolean enableVirtualHosting, Optional administrator) throws Exception { - PostgresUsersDAO usersDAO = new PostgresUsersDAO(new SinglePostgresConnectionFactory(postgresExtension.getConnection().block()), + PostgresUsersDAO usersDAO = new PostgresUsersDAO(postgresExtension.getPostgresExecutor(), PostgresUsersRepositoryConfiguration.DEFAULT); BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); configuration.addProperty("enableVirtualHosting", String.valueOf(enableVirtualHosting)); From 5c524ae6e5cd6010cdd31796d8207af34cce0e19 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Mon, 27 Nov 2023 18:02:53 +0700 Subject: [PATCH 077/341] JAMES-2586 Implement SieveQuotaRepository backed by Postgres --- .../main/resources/META-INF/persistence.xml | 3 +- server/data/data-postgres/pom.xml | 8 +- .../james/sieve/jpa/model/JPASieveQuota.java | 97 ----------- .../sieve/postgres/PostgresSieveQuotaDAO.java | 114 ++++++++++++ .../PostgresSieveRepository.java} | 134 ++++++-------- .../model/JPASieveScript.java | 2 +- .../postgres/PostgresSieveQuotaDAOTest.java | 163 ++++++++++++++++++ .../PostgresSieveRepositoryTest.java} | 23 ++- .../src/test/resources/persistence.xml | 4 +- 9 files changed, 351 insertions(+), 197 deletions(-) delete mode 100644 server/data/data-postgres/src/main/java/org/apache/james/sieve/jpa/model/JPASieveQuota.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveQuotaDAO.java rename server/data/data-postgres/src/main/java/org/apache/james/sieve/{jpa/JPASieveRepository.java => postgres/PostgresSieveRepository.java} (73%) rename server/data/data-postgres/src/main/java/org/apache/james/sieve/{jpa => postgres}/model/JPASieveScript.java (99%) create mode 100644 server/data/data-postgres/src/test/java/org/apache/james/sieve/postgres/PostgresSieveQuotaDAOTest.java rename server/data/data-postgres/src/test/java/org/apache/james/sieve/{jpa/JpaSieveRepositoryTest.java => postgres/PostgresSieveRepositoryTest.java} (60%) diff --git a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml index 5d55f9b7673..9573f6e5f64 100644 --- a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml +++ b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml @@ -34,8 +34,7 @@ org.apache.james.mailrepository.jpa.model.JPAUrl org.apache.james.mailrepository.jpa.model.JPAMail org.apache.james.rrt.jpa.model.JPARecipientRewrite - org.apache.james.sieve.jpa.model.JPASieveQuota - org.apache.james.sieve.jpa.model.JPASieveScript + org.apache.james.sieve.postgres.model.JPASieveScript org.apache.james.mailbox.postgres.quota.model.MaxDomainMessageCount org.apache.james.mailbox.postgres.quota.model.MaxDomainStorage diff --git a/server/data/data-postgres/pom.xml b/server/data/data-postgres/pom.xml index 82e0bec73be..223cf0a8027 100644 --- a/server/data/data-postgres/pom.xml +++ b/server/data/data-postgres/pom.xml @@ -155,9 +155,7 @@ openjpa-maven-plugin ${apache.openjpa.version} - org/apache/james/sieve/jpa/model/JPASieveQuota.class, - org/apache/james/sieve/jpa/model/JPASieveScript.class, - org/apache/james/user/jpa/model/JPAUser.class, + org/apache/james/sieve/postgres/model/JPASieveScript.class, org/apache/james/rrt/jpa/model/JPARecipientRewrite.class, org/apache/james/domainlist/jpa/model/JPADomain.class, org/apache/james/mailrepository/jpa/model/JPAUrl.class, @@ -171,9 +169,7 @@ metaDataFactory - jpa(Types=org.apache.james.sieve.jpa.model.JPASieveQuota; - org.apache.james.sieve.jpa.model.JPASieveScript; - org.apache.james.user.jpa.model.JPAUser; + jpa(Types=org.apache.james.sieve.postgres.model.JPASieveScript; org.apache.james.rrt.jpa.model.JPARecipientRewrite; org.apache.james.domainlist.jpa.model.JPADomain; org.apache.james.mailrepository.jpa.model.JPAUrl; diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/jpa/model/JPASieveQuota.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/jpa/model/JPASieveQuota.java deleted file mode 100644 index 52485c12ec1..00000000000 --- a/server/data/data-postgres/src/main/java/org/apache/james/sieve/jpa/model/JPASieveQuota.java +++ /dev/null @@ -1,97 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.sieve.jpa.model; - -import java.util.Objects; - -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.Id; -import javax.persistence.NamedQueries; -import javax.persistence.NamedQuery; -import javax.persistence.Table; - -import org.apache.james.core.quota.QuotaSizeLimit; - -import com.google.common.base.MoreObjects; - -@Entity(name = "JamesSieveQuota") -@Table(name = "JAMES_SIEVE_QUOTA") -@NamedQueries({ - @NamedQuery(name = "findByUsername", query = "SELECT sieveQuota FROM JamesSieveQuota sieveQuota WHERE sieveQuota.username=:username") -}) -public class JPASieveQuota { - - @Id - @Column(name = "USER_NAME", nullable = false, length = 100) - private String username; - - @Column(name = "SIZE", nullable = false) - private long size; - - /** - * @deprecated enhancement only - */ - @Deprecated - protected JPASieveQuota() { - } - - public JPASieveQuota(String username, long size) { - this.username = username; - this.size = size; - } - - public long getSize() { - return size; - } - - public void setSize(QuotaSizeLimit quotaSize) { - this.size = quotaSize.asLong(); - } - - public QuotaSizeLimit toQuotaSize() { - return QuotaSizeLimit.size(size); - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - JPASieveQuota that = (JPASieveQuota) o; - return Objects.equals(username, that.username); - } - - @Override - public int hashCode() { - return Objects.hash(username); - } - - @Override - public String toString() { - return MoreObjects.toStringHelper(this) - .add("username", username) - .add("size", size) - .toString(); - } -} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveQuotaDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveQuotaDAO.java new file mode 100644 index 00000000000..dff7d4ef713 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveQuotaDAO.java @@ -0,0 +1,114 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.sieve.postgres; + +import static org.apache.james.core.quota.QuotaType.SIZE; + +import java.util.Optional; + +import org.apache.james.backends.postgres.quota.PostgresQuotaCurrentValueDAO; +import org.apache.james.backends.postgres.quota.PostgresQuotaLimitDAO; +import org.apache.james.core.Username; +import org.apache.james.core.quota.QuotaComponent; +import org.apache.james.core.quota.QuotaCurrentValue; +import org.apache.james.core.quota.QuotaLimit; +import org.apache.james.core.quota.QuotaScope; +import org.apache.james.core.quota.QuotaSizeLimit; + +import reactor.core.publisher.Mono; + +public class PostgresSieveQuotaDAO { + public static final QuotaComponent QUOTA_COMPONENT = QuotaComponent.of("SIEVE"); + public static final String GLOBAL = "GLOBAL"; + + private final PostgresQuotaCurrentValueDAO currentValueDao; + private final PostgresQuotaLimitDAO limitDao; + + public PostgresSieveQuotaDAO(PostgresQuotaCurrentValueDAO currentValueDao, PostgresQuotaLimitDAO limitDao) { + this.currentValueDao = currentValueDao; + this.limitDao = limitDao; + } + + public Mono spaceUsedBy(Username username) { + QuotaCurrentValue.Key quotaKey = asQuotaKey(username); + + return currentValueDao.getQuotaCurrentValue(quotaKey).map(QuotaCurrentValue::getCurrentValue) + .switchIfEmpty(Mono.just(0L)); + } + + private QuotaCurrentValue.Key asQuotaKey(Username username) { + return QuotaCurrentValue.Key.of( + QUOTA_COMPONENT, + username.asString(), + SIZE); + } + + public Mono updateSpaceUsed(Username username, long spaceUsed) { + QuotaCurrentValue.Key quotaKey = asQuotaKey(username); + + return currentValueDao.increase(quotaKey, spaceUsed); + } + + public Mono> getGlobalQuota() { + return limitDao.getQuotaLimit(QuotaLimit.QuotaLimitKey.of(QUOTA_COMPONENT, QuotaScope.GLOBAL, GLOBAL, SIZE)) + .map(v -> v.getQuotaLimit().map(QuotaSizeLimit::size)) + .switchIfEmpty(Mono.just(Optional.empty())); + } + + public Mono setGlobalQuota(QuotaSizeLimit quota) { + return limitDao.setQuotaLimit(QuotaLimit.builder() + .quotaComponent(QUOTA_COMPONENT) + .quotaScope(QuotaScope.GLOBAL) + .quotaType(SIZE) + .identifier(GLOBAL) + .quotaLimit(quota.asLong()) + .build()); + } + + public Mono removeGlobalQuota() { + return limitDao.deleteQuotaLimit(QuotaLimit.QuotaLimitKey.of(QUOTA_COMPONENT, QuotaScope.GLOBAL, GLOBAL, SIZE)); + } + + public Mono> getQuota(Username username) { + return limitDao.getQuotaLimits(QUOTA_COMPONENT, QuotaScope.USER, username.asString()) + .map(v -> v.getQuotaLimit().map(QuotaSizeLimit::size)) + .switchIfEmpty(Mono.just(Optional.empty())) + .single(); + } + + public Mono setQuota(Username username, QuotaSizeLimit quota) { + return limitDao.setQuotaLimit(QuotaLimit.builder() + .quotaComponent(QUOTA_COMPONENT) + .quotaScope(QuotaScope.USER) + .quotaType(SIZE) + .identifier(username.asString()) + .quotaLimit(quota.asLong()) + .build()); + } + + public Mono removeQuota(Username username) { + return limitDao.deleteQuotaLimit(QuotaLimit.QuotaLimitKey.of( + QUOTA_COMPONENT, QuotaScope.USER, username.asString(), SIZE)); + } + + public Mono resetSpaceUsed(Username username, long spaceUsed) { + return spaceUsedBy(username).flatMap(currentSpace -> currentValueDao.increase(asQuotaKey(username), spaceUsed - currentSpace)); + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/jpa/JPASieveRepository.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveRepository.java similarity index 73% rename from server/data/data-postgres/src/main/java/org/apache/james/sieve/jpa/JPASieveRepository.java rename to server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveRepository.java index 53c96fc2637..544ef43999e 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/sieve/jpa/JPASieveRepository.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveRepository.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.sieve.jpa; +package org.apache.james.sieve.postgres; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -39,8 +39,7 @@ import org.apache.james.core.Username; import org.apache.james.core.quota.QuotaSizeLimit; import org.apache.james.core.quota.QuotaSizeUsage; -import org.apache.james.sieve.jpa.model.JPASieveQuota; -import org.apache.james.sieve.jpa.model.JPASieveScript; +import org.apache.james.sieve.postgres.model.JPASieveScript; import org.apache.james.sieverepository.api.ScriptContent; import org.apache.james.sieverepository.api.ScriptName; import org.apache.james.sieverepository.api.ScriptSummary; @@ -60,16 +59,17 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -public class JPASieveRepository implements SieveRepository { +public class PostgresSieveRepository implements SieveRepository { - private static final Logger LOGGER = LoggerFactory.getLogger(JPASieveRepository.class); - private static final String DEFAULT_SIEVE_QUOTA_USERNAME = "default.quota"; + private static final Logger LOGGER = LoggerFactory.getLogger(PostgresSieveRepository.class); private final TransactionRunner transactionRunner; + private final PostgresSieveQuotaDAO postgresSieveQuotaDAO; @Inject - public JPASieveRepository(EntityManagerFactory entityManagerFactory) { + public PostgresSieveRepository(EntityManagerFactory entityManagerFactory, PostgresSieveQuotaDAO postgresSieveQuotaDAO) { this.transactionRunner = new TransactionRunner(entityManagerFactory); + this.postgresSieveQuotaDAO = postgresSieveQuotaDAO; } @Override @@ -85,10 +85,11 @@ public void haveSpace(Username username, ScriptName name, long size) throws Quot } } - private QuotaSizeLimit limitToUser(Username username) throws StorageException { - return findQuotaForUser(username.asString()) - .or(Throwing.supplier(() -> findQuotaForUser(DEFAULT_SIEVE_QUOTA_USERNAME)).sneakyThrow()) - .map(JPASieveQuota::toQuotaSize) + private QuotaSizeLimit limitToUser(Username username) { + return postgresSieveQuotaDAO.getQuota(username) + .filter(Optional::isPresent) + .switchIfEmpty(postgresSieveQuotaDAO.getGlobalQuota()) + .block() .orElse(QuotaSizeLimit.unlimited()); } @@ -99,7 +100,7 @@ private boolean overQuotaAfterModification(long usedSpace, long size, QuotaSizeL } @Override - public void putScript(Username username, ScriptName name, ScriptContent content) throws StorageException, QuotaExceededException { + public void putScript(Username username, ScriptName name, ScriptContent content) { transactionRunner.runAndHandleException(Throwing.consumer(entityManager -> { try { haveSpace(username, name, content.length()); @@ -117,7 +118,7 @@ public void putScript(Username username, ScriptName name, ScriptContent content) } @Override - public List listScripts(Username username) throws StorageException { + public List listScripts(Username username) { return findAllSieveScriptsForUser(username).stream() .map(JPASieveScript::toSummary) .collect(ImmutableList.toImmutableList()); @@ -128,7 +129,7 @@ public Flux listScriptsReactive(Username username) { return Mono.fromCallable(() -> listScripts(username)).flatMapMany(Flux::fromIterable); } - private List findAllSieveScriptsForUser(Username username) throws StorageException { + private List findAllSieveScriptsForUser(Username username) { return transactionRunner.runAndRetrieveResult(entityManager -> { List sieveScripts = entityManager.createNamedQuery("findAllByUsername", JPASieveScript.class) .setParameter("username", username.asString()).getResultList(); @@ -137,26 +138,26 @@ private List findAllSieveScriptsForUser(Username username) throw } @Override - public ZonedDateTime getActivationDateForActiveScript(Username username) throws StorageException, ScriptNotFoundException { + public ZonedDateTime getActivationDateForActiveScript(Username username) throws ScriptNotFoundException { Optional script = findActiveSieveScript(username); JPASieveScript activeSieveScript = script.orElseThrow(() -> new ScriptNotFoundException("Unable to find active script for user " + username.asString())); return activeSieveScript.getActivationDateTime().toZonedDateTime(); } @Override - public InputStream getActive(Username username) throws ScriptNotFoundException, StorageException { + public InputStream getActive(Username username) throws ScriptNotFoundException { Optional script = findActiveSieveScript(username); JPASieveScript activeSieveScript = script.orElseThrow(() -> new ScriptNotFoundException("Unable to find active script for user " + username.asString())); return IOUtils.toInputStream(activeSieveScript.getScriptContent(), StandardCharsets.UTF_8); } - private Optional findActiveSieveScript(Username username) throws StorageException { + private Optional findActiveSieveScript(Username username) { return transactionRunner.runAndRetrieveResult( Throwing.>function(entityManager -> findActiveSieveScript(username, entityManager)).sneakyThrow(), throwStorageException("Unable to find active script for user " + username.asString())); } - private Optional findActiveSieveScript(Username username, EntityManager entityManager) throws StorageException { + private Optional findActiveSieveScript(Username username, EntityManager entityManager) { try { JPASieveScript activeSieveScript = entityManager.createNamedQuery("findActiveByUsername", JPASieveScript.class) .setParameter("username", username.asString()).getSingleResult(); @@ -168,7 +169,7 @@ private Optional findActiveSieveScript(Username username, Entity } @Override - public void setActive(Username username, ScriptName name) throws ScriptNotFoundException, StorageException { + public void setActive(Username username, ScriptName name) { transactionRunner.runAndHandleException(Throwing.consumer(entityManager -> { try { if (SieveRepository.NO_SCRIPT_NAME.equals(name)) { @@ -196,13 +197,13 @@ private void setActiveScript(Username username, ScriptName name, EntityManager e } @Override - public InputStream getScript(Username username, ScriptName name) throws ScriptNotFoundException, StorageException { + public InputStream getScript(Username username, ScriptName name) throws ScriptNotFoundException { Optional script = findSieveScript(username, name); JPASieveScript sieveScript = script.orElseThrow(() -> new ScriptNotFoundException("Unable to find script " + name.getValue() + " for user " + username.asString())); return IOUtils.toInputStream(sieveScript.getScriptContent(), StandardCharsets.UTF_8); } - private Optional findSieveScript(Username username, ScriptName scriptName) throws StorageException { + private Optional findSieveScript(Username username, ScriptName scriptName) { return transactionRunner.runAndRetrieveResult(entityManager -> findSieveScript(username, scriptName, entityManager), throwStorageException("Unable to find script " + scriptName.getValue() + " for user " + username.asString())); } @@ -220,7 +221,7 @@ private Optional findSieveScript(Username username, ScriptName s } @Override - public void deleteScript(Username username, ScriptName name) throws ScriptNotFoundException, IsActiveException, StorageException { + public void deleteScript(Username username, ScriptName name) { transactionRunner.runAndHandleException(Throwing.consumer(entityManager -> { Optional sieveScript = findSieveScript(username, name, entityManager); if (!sieveScript.isPresent()) { @@ -237,7 +238,7 @@ public void deleteScript(Username username, ScriptName name) throws ScriptNotFou } @Override - public void renameScript(Username username, ScriptName oldName, ScriptName newName) throws ScriptNotFoundException, DuplicateException, StorageException { + public void renameScript(Username username, ScriptName oldName, ScriptName newName) { transactionRunner.runAndHandleException(Throwing.consumer(entityManager -> { Optional sieveScript = findSieveScript(username, oldName, entityManager); if (!sieveScript.isPresent()) { @@ -263,54 +264,56 @@ private void rollbackTransactionIfActive(EntityTransaction transaction) { } @Override - public boolean hasDefaultQuota() throws StorageException { - Optional defaultQuota = findQuotaForUser(DEFAULT_SIEVE_QUOTA_USERNAME); - return defaultQuota.isPresent(); + public boolean hasDefaultQuota() { + return postgresSieveQuotaDAO.getGlobalQuota() + .block() + .isPresent(); } @Override - public QuotaSizeLimit getDefaultQuota() throws QuotaNotFoundException, StorageException { - JPASieveQuota jpaSieveQuota = findQuotaForUser(DEFAULT_SIEVE_QUOTA_USERNAME) - .orElseThrow(() -> new QuotaNotFoundException("Unable to find quota for default user")); - return QuotaSizeLimit.size(jpaSieveQuota.getSize()); + public QuotaSizeLimit getDefaultQuota() throws QuotaNotFoundException { + return postgresSieveQuotaDAO.getGlobalQuota() + .block() + .orElseThrow(() -> new QuotaNotFoundException("Unable to find quota for default user")); } @Override - public void setDefaultQuota(QuotaSizeLimit quota) throws StorageException { - setQuotaForUser(DEFAULT_SIEVE_QUOTA_USERNAME, quota); + public void setDefaultQuota(QuotaSizeLimit quota) { + postgresSieveQuotaDAO.setGlobalQuota(quota) + .block(); } @Override - public void removeQuota() throws QuotaNotFoundException, StorageException { - removeQuotaForUser(DEFAULT_SIEVE_QUOTA_USERNAME); + public void removeQuota() { + postgresSieveQuotaDAO.removeGlobalQuota() + .block(); } @Override - public boolean hasQuota(Username username) throws StorageException { - Optional quotaForUser = findQuotaForUser(username.asString()); - return quotaForUser.isPresent(); - } + public boolean hasQuota(Username username) { + Mono hasUserQuota = postgresSieveQuotaDAO.getQuota(username).map(Optional::isPresent); + Mono hasGlobalQuota = postgresSieveQuotaDAO.getGlobalQuota().map(Optional::isPresent); - @Override - public QuotaSizeLimit getQuota(Username username) throws QuotaNotFoundException, StorageException { - JPASieveQuota jpaSieveQuota = findQuotaForUser(username.asString()) - .orElseThrow(() -> new QuotaNotFoundException("Unable to find quota for user " + username.asString())); - return QuotaSizeLimit.size(jpaSieveQuota.getSize()); + return hasUserQuota.zipWith(hasGlobalQuota, (a, b) -> a || b) + .block(); } @Override - public void setQuota(Username username, QuotaSizeLimit quota) throws StorageException { - setQuotaForUser(username.asString(), quota); + public QuotaSizeLimit getQuota(Username username) throws QuotaNotFoundException { + return postgresSieveQuotaDAO.getQuota(username) + .block() + .orElseThrow(() -> new QuotaNotFoundException("Unable to find quota for user " + username.asString())); } @Override - public void removeQuota(Username username) throws QuotaNotFoundException, StorageException { - removeQuotaForUser(username.asString()); + public void setQuota(Username username, QuotaSizeLimit quota) { + postgresSieveQuotaDAO.setQuota(username, quota) + .block(); } - private Optional findQuotaForUser(String username) throws StorageException { - return transactionRunner.runAndRetrieveResult(entityManager -> findQuotaForUser(username, entityManager), - throwStorageException("Unable to find quota for user " + username)); + @Override + public void removeQuota(Username username) { + postgresSieveQuotaDAO.removeQuota(username).block(); } private Function throwStorageException(String message) { @@ -325,37 +328,6 @@ private Consumer throwStorageExceptionConsumer(String mess }).sneakyThrow(); } - private Optional findQuotaForUser(String username, EntityManager entityManager) { - try { - JPASieveQuota sieveQuota = entityManager.createNamedQuery("findByUsername", JPASieveQuota.class) - .setParameter("username", username).getSingleResult(); - return Optional.of(sieveQuota); - } catch (NoResultException e) { - return Optional.empty(); - } - } - - private void setQuotaForUser(String username, QuotaSizeLimit quota) throws StorageException { - transactionRunner.runAndHandleException(Throwing.consumer(entityManager -> { - Optional sieveQuota = findQuotaForUser(username, entityManager); - if (sieveQuota.isPresent()) { - JPASieveQuota jpaSieveQuota = sieveQuota.get(); - jpaSieveQuota.setSize(quota); - entityManager.merge(jpaSieveQuota); - } else { - JPASieveQuota jpaSieveQuota = new JPASieveQuota(username, quota.asLong()); - entityManager.persist(jpaSieveQuota); - } - }), throwStorageExceptionConsumer("Unable to set quota for user " + username)); - } - - private void removeQuotaForUser(String username) throws StorageException { - transactionRunner.runAndHandleException(Throwing.consumer(entityManager -> { - Optional quotaForUser = findQuotaForUser(username, entityManager); - quotaForUser.ifPresent(entityManager::remove); - }), throwStorageExceptionConsumer("Unable to remove quota for user " + username)); - } - @Override public Mono resetSpaceUsedReactive(Username username, long spaceUsed) { return Mono.error(new UnsupportedOperationException()); diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/jpa/model/JPASieveScript.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/model/JPASieveScript.java similarity index 99% rename from server/data/data-postgres/src/main/java/org/apache/james/sieve/jpa/model/JPASieveScript.java rename to server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/model/JPASieveScript.java index 72b5ba53f51..8575b34a171 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/sieve/jpa/model/JPASieveScript.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/model/JPASieveScript.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.sieve.jpa.model; +package org.apache.james.sieve.postgres.model; import java.time.OffsetDateTime; import java.util.Objects; diff --git a/server/data/data-postgres/src/test/java/org/apache/james/sieve/postgres/PostgresSieveQuotaDAOTest.java b/server/data/data-postgres/src/test/java/org/apache/james/sieve/postgres/PostgresSieveQuotaDAOTest.java new file mode 100644 index 00000000000..aaeb02af06f --- /dev/null +++ b/server/data/data-postgres/src/test/java/org/apache/james/sieve/postgres/PostgresSieveQuotaDAOTest.java @@ -0,0 +1,163 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.sieve.postgres; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.quota.PostgresQuotaCurrentValueDAO; +import org.apache.james.backends.postgres.quota.PostgresQuotaLimitDAO; +import org.apache.james.backends.postgres.quota.PostgresQuotaModule; +import org.apache.james.core.Username; +import org.apache.james.core.quota.QuotaSizeLimit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +class PostgresSieveQuotaDAOTest { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresModule.aggregateModules(PostgresQuotaModule.MODULE)); + + private static final Username USERNAME = Username.of("user"); + private static final QuotaSizeLimit QUOTA_SIZE = QuotaSizeLimit.size(15L); + + private PostgresSieveQuotaDAO testee; + + @BeforeEach + void setup() { + testee = new PostgresSieveQuotaDAO(new PostgresQuotaCurrentValueDAO(postgresExtension.getPostgresExecutor()), + new PostgresQuotaLimitDAO(postgresExtension.getPostgresExecutor())); + } + + @Test + void getQuotaShouldReturnEmptyByDefault() { + assertThat(testee.getGlobalQuota().block()) + .isEmpty(); + } + + @Test + void getQuotaUserShouldReturnEmptyByDefault() { + assertThat(testee.getQuota(USERNAME).block()) + .isEmpty(); + } + + @Test + void getQuotaShouldReturnStoredValue() { + testee.setGlobalQuota(QUOTA_SIZE).block(); + + assertThat(testee.getGlobalQuota().block()) + .contains(QUOTA_SIZE); + } + + @Test + void getQuotaUserShouldReturnStoredValue() { + testee.setQuota(USERNAME, QUOTA_SIZE).block(); + + assertThat(testee.getQuota(USERNAME).block()) + .contains(QUOTA_SIZE); + } + + @Test + void removeQuotaShouldDeleteQuota() { + testee.setGlobalQuota(QUOTA_SIZE).block(); + + testee.removeGlobalQuota().block(); + + assertThat(testee.getGlobalQuota().block()) + .isEmpty(); + } + + @Test + void removeQuotaUserShouldDeleteQuotaUser() { + testee.setQuota(USERNAME, QUOTA_SIZE).block(); + + testee.removeQuota(USERNAME).block(); + + assertThat(testee.getQuota(USERNAME).block()) + .isEmpty(); + } + + @Test + void removeQuotaShouldWorkWhenNoneStore() { + testee.removeGlobalQuota().block(); + + assertThat(testee.getGlobalQuota().block()) + .isEmpty(); + } + + @Test + void removeQuotaUserShouldWorkWhenNoneStore() { + testee.removeQuota(USERNAME).block(); + + assertThat(testee.getQuota(USERNAME).block()) + .isEmpty(); + } + + @Test + void spaceUsedByShouldReturnZeroByDefault() { + assertThat(testee.spaceUsedBy(USERNAME).block()).isZero(); + } + + @Test + void spaceUsedByShouldReturnStoredValue() { + long spaceUsed = 18L; + + testee.updateSpaceUsed(USERNAME, spaceUsed).block(); + + assertThat(testee.spaceUsedBy(USERNAME).block()).isEqualTo(spaceUsed); + } + + @Test + void updateSpaceUsedShouldBeAdditive() { + long spaceUsed = 18L; + + testee.updateSpaceUsed(USERNAME, spaceUsed).block(); + testee.updateSpaceUsed(USERNAME, spaceUsed).block(); + + assertThat(testee.spaceUsedBy(USERNAME).block()).isEqualTo(2 * spaceUsed); + } + + @Test + void updateSpaceUsedShouldWorkWithNegativeValues() { + long spaceUsed = 18L; + + testee.updateSpaceUsed(USERNAME, spaceUsed).block(); + testee.updateSpaceUsed(USERNAME, -1 * spaceUsed).block(); + + assertThat(testee.spaceUsedBy(USERNAME).block()).isZero(); + } + + @Test + void resetSpaceUsedShouldResetSpaceWhenNewSpaceIsGreaterThanCurrentSpace() { + testee.updateSpaceUsed(USERNAME, 10L).block(); + testee.resetSpaceUsed(USERNAME, 15L).block(); + + assertThat(testee.spaceUsedBy(USERNAME).block()).isEqualTo(15L); + } + + @Test + void resetSpaceUsedShouldResetSpaceWhenNewSpaceIsSmallerThanCurrentSpace() { + testee.updateSpaceUsed(USERNAME, 10L).block(); + testee.resetSpaceUsed(USERNAME, 9L).block(); + + assertThat(testee.spaceUsedBy(USERNAME).block()).isEqualTo(9L); + } +} diff --git a/server/data/data-postgres/src/test/java/org/apache/james/sieve/jpa/JpaSieveRepositoryTest.java b/server/data/data-postgres/src/test/java/org/apache/james/sieve/postgres/PostgresSieveRepositoryTest.java similarity index 60% rename from server/data/data-postgres/src/test/java/org/apache/james/sieve/jpa/JpaSieveRepositoryTest.java rename to server/data/data-postgres/src/test/java/org/apache/james/sieve/postgres/PostgresSieveRepositoryTest.java index ab59dc651cc..b31b1e173aa 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/sieve/jpa/JpaSieveRepositoryTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/sieve/postgres/PostgresSieveRepositoryTest.java @@ -17,30 +17,39 @@ * under the License. * ****************************************************************/ -package org.apache.james.sieve.jpa; +package org.apache.james.sieve.postgres; import org.apache.james.backends.jpa.JpaTestCluster; -import org.apache.james.sieve.jpa.model.JPASieveQuota; -import org.apache.james.sieve.jpa.model.JPASieveScript; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.quota.PostgresQuotaCurrentValueDAO; +import org.apache.james.backends.postgres.quota.PostgresQuotaLimitDAO; +import org.apache.james.backends.postgres.quota.PostgresQuotaModule; +import org.apache.james.sieve.postgres.model.JPASieveScript; import org.apache.james.sieverepository.api.SieveRepository; import org.apache.james.sieverepository.lib.SieveRepositoryContract; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; -class JpaSieveRepositoryTest implements SieveRepositoryContract { +class PostgresSieveRepositoryTest implements SieveRepositoryContract { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresModule.aggregateModules(PostgresQuotaModule.MODULE)); - final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPASieveScript.class, JPASieveQuota.class); + final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPASieveScript.class); SieveRepository sieveRepository; @BeforeEach void setUp() { - sieveRepository = new JPASieveRepository(JPA_TEST_CLUSTER.getEntityManagerFactory()); + sieveRepository = new PostgresSieveRepository(JPA_TEST_CLUSTER.getEntityManagerFactory(), + new PostgresSieveQuotaDAO(new PostgresQuotaCurrentValueDAO(postgresExtension.getPostgresExecutor()), + new PostgresQuotaLimitDAO(postgresExtension.getPostgresExecutor()))); } @AfterEach void tearDown() { - JPA_TEST_CLUSTER.clear("JAMES_SIEVE_SCRIPT", "JAMES_SIEVE_QUOTA"); + JPA_TEST_CLUSTER.clear("JAMES_SIEVE_SCRIPT"); } @Override diff --git a/server/data/data-postgres/src/test/resources/persistence.xml b/server/data/data-postgres/src/test/resources/persistence.xml index 6224adb74fb..962146a5432 100644 --- a/server/data/data-postgres/src/test/resources/persistence.xml +++ b/server/data/data-postgres/src/test/resources/persistence.xml @@ -27,12 +27,10 @@ org.apache.openjpa.persistence.PersistenceProviderImpl osgi:service/javax.sql.DataSource/(osgi.jndi.service.name=jdbc/james) org.apache.james.domainlist.jpa.model.JPADomain - org.apache.james.user.jpa.model.JPAUser org.apache.james.rrt.jpa.model.JPARecipientRewrite org.apache.james.mailrepository.jpa.model.JPAUrl org.apache.james.mailrepository.jpa.model.JPAMail - org.apache.james.sieve.jpa.model.JPASieveQuota - org.apache.james.sieve.jpa.model.JPASieveScript + org.apache.james.sieve.postgres.model.JPASieveScript true From 322ae51ae9ec91c741e516c2dd1a8b1e5dc2c4d9 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 28 Nov 2023 15:58:11 +0700 Subject: [PATCH 078/341] JAMES-2586 Guice binding for SieveQuotaRepository backed by Postgres --- .../quota/PostgresQuotaCurrentValueDAO.java | 7 ++- .../postgres/quota/PostgresQuotaLimitDAO.java | 4 +- pom.xml | 11 ++++ server/apps/postgres-app/pom.xml | 2 +- .../apache/james/PostgresJamesServerMain.java | 4 +- server/container/guice/pom.xml | 6 +++ server/container/guice/sieve-postgres/pom.xml | 53 +++++++++++++++++++ .../data/SievePostgresRepositoryModules.java | 37 +++++++++++++ .../sieve/postgres/PostgresSieveQuotaDAO.java | 3 ++ 9 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 server/container/guice/sieve-postgres/pom.xml create mode 100644 server/container/guice/sieve-postgres/src/main/java/org/apache/james/modules/data/SievePostgresRepositoryModules.java diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java index 8f5c7eea6c0..70b471a117e 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java @@ -25,9 +25,13 @@ import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaCurrentValueTable.PRIMARY_KEY_CONSTRAINT_NAME; import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaCurrentValueTable.TABLE_NAME; import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaCurrentValueTable.TYPE; +import static org.apache.james.backends.postgres.utils.PostgresExecutor.DEFAULT_INJECT; import java.util.function.Function; +import javax.inject.Inject; +import javax.inject.Named; + import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.quota.QuotaComponent; import org.apache.james.core.quota.QuotaCurrentValue; @@ -44,7 +48,8 @@ public class PostgresQuotaCurrentValueDAO { private final PostgresExecutor postgresExecutor; - public PostgresQuotaCurrentValueDAO(PostgresExecutor postgresExecutor) { + @Inject + public PostgresQuotaCurrentValueDAO(@Named(DEFAULT_INJECT) PostgresExecutor postgresExecutor) { this.postgresExecutor = postgresExecutor; } diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaLimitDAO.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaLimitDAO.java index ff17e948411..ee851a75d9f 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaLimitDAO.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaLimitDAO.java @@ -26,8 +26,10 @@ import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaLimitTable.QUOTA_SCOPE; import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaLimitTable.QUOTA_TYPE; import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaLimitTable.TABLE_NAME; +import static org.apache.james.backends.postgres.utils.PostgresExecutor.DEFAULT_INJECT; import javax.inject.Inject; +import javax.inject.Named; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.quota.QuotaComponent; @@ -45,7 +47,7 @@ public class PostgresQuotaLimitDAO { private final PostgresExecutor postgresExecutor; @Inject - public PostgresQuotaLimitDAO(PostgresExecutor postgresExecutor) { + public PostgresQuotaLimitDAO(@Named(DEFAULT_INJECT) PostgresExecutor postgresExecutor) { this.postgresExecutor = postgresExecutor; } diff --git a/pom.xml b/pom.xml index 5b18beece54..2ed99a405eb 100644 --- a/pom.xml +++ b/pom.xml @@ -1577,6 +1577,17 @@ ${project.version} test-jar + + ${james.groupId} + james-server-guice-sieve-postgres + ${project.version} + + + ${james.groupId} + james-server-guice-sieve-postgres + ${project.version} + test-jar + ${james.groupId} james-server-guice-smtp diff --git a/server/apps/postgres-app/pom.xml b/server/apps/postgres-app/pom.xml index 66e4105cfa0..3ce2ab8e599 100644 --- a/server/apps/postgres-app/pom.xml +++ b/server/apps/postgres-app/pom.xml @@ -125,7 +125,7 @@ ${james.groupId} - james-server-guice-sieve-jpa + james-server-guice-sieve-postgres ${james.groupId} diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index 7c5f47c086d..8ecba40bb71 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -25,7 +25,7 @@ import org.apache.james.modules.RunArgumentsModule; import org.apache.james.modules.data.PostgresDataModule; import org.apache.james.modules.data.PostgresUsersRepositoryModule; -import org.apache.james.modules.data.SieveJPARepositoryModules; +import org.apache.james.modules.data.SievePostgresRepositoryModules; import org.apache.james.modules.mailbox.DefaultEventModule; import org.apache.james.modules.mailbox.JPAMailboxModule; import org.apache.james.modules.mailbox.LuceneSearchMailboxModule; @@ -89,7 +89,7 @@ public class PostgresJamesServerMain implements JamesServerMain { new LuceneSearchMailboxModule(), new NoJwtModule(), new RawPostDequeueDecoratorModule(), - new SieveJPARepositoryModules(), + new SievePostgresRepositoryModules(), new DefaultEventModule(), new TaskManagerModule(), new MemoryDeadLetterModule()); diff --git a/server/container/guice/pom.xml b/server/container/guice/pom.xml index 40a437596f7..96b681617a3 100644 --- a/server/container/guice/pom.xml +++ b/server/container/guice/pom.xml @@ -79,6 +79,7 @@ queue/rabbitmq sieve-file sieve-jpa + sieve-postgres testing utils @@ -192,6 +193,11 @@ james-server-guice-sieve-jpa ${project.version} + + ${james.groupId} + james-server-guice-sieve-postgres + ${project.version} + ${james.groupId} james-server-guice-smtp diff --git a/server/container/guice/sieve-postgres/pom.xml b/server/container/guice/sieve-postgres/pom.xml new file mode 100644 index 00000000000..512875ef11f --- /dev/null +++ b/server/container/guice/sieve-postgres/pom.xml @@ -0,0 +1,53 @@ + + + + + 4.0.0 + + + org.apache.james + james-server-guice + 3.9.0-SNAPSHOT + + + james-server-guice-sieve-postgres + jar + + Apache James :: Server :: Guice :: Sieve :: Postgres + Sieve Postgres modules for Guice implementation of James server + + + + ${james.groupId} + james-server-data-postgres + + + + ${james.groupId} + james-server-testing + test + + + com.google.inject + guice + + + + diff --git a/server/container/guice/sieve-postgres/src/main/java/org/apache/james/modules/data/SievePostgresRepositoryModules.java b/server/container/guice/sieve-postgres/src/main/java/org/apache/james/modules/data/SievePostgresRepositoryModules.java new file mode 100644 index 00000000000..b2784c6be7b --- /dev/null +++ b/server/container/guice/sieve-postgres/src/main/java/org/apache/james/modules/data/SievePostgresRepositoryModules.java @@ -0,0 +1,37 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.data; + +import org.apache.james.sieve.postgres.PostgresSieveRepository; +import org.apache.james.sieverepository.api.SieveQuotaRepository; +import org.apache.james.sieverepository.api.SieveRepository; + +import com.google.inject.AbstractModule; +import com.google.inject.Scopes; + +public class SievePostgresRepositoryModules extends AbstractModule { + @Override + protected void configure() { + bind(PostgresSieveRepository.class).in(Scopes.SINGLETON); + + bind(SieveRepository.class).to(PostgresSieveRepository.class); + bind(SieveQuotaRepository.class).to(PostgresSieveRepository.class); + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveQuotaDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveQuotaDAO.java index dff7d4ef713..dd894cb9114 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveQuotaDAO.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveQuotaDAO.java @@ -23,6 +23,8 @@ import java.util.Optional; +import javax.inject.Inject; + import org.apache.james.backends.postgres.quota.PostgresQuotaCurrentValueDAO; import org.apache.james.backends.postgres.quota.PostgresQuotaLimitDAO; import org.apache.james.core.Username; @@ -41,6 +43,7 @@ public class PostgresSieveQuotaDAO { private final PostgresQuotaCurrentValueDAO currentValueDao; private final PostgresQuotaLimitDAO limitDao; + @Inject public PostgresSieveQuotaDAO(PostgresQuotaCurrentValueDAO currentValueDao, PostgresQuotaLimitDAO limitDao) { this.currentValueDao = currentValueDao; this.limitDao = limitDao; From 66366376f024e339195d9bf4139b9ea50aef760c Mon Sep 17 00:00:00 2001 From: vttran Date: Wed, 29 Nov 2023 17:47:32 +0700 Subject: [PATCH 079/341] JAMES-2586 Implement PostgresMailboxMessageDAO (#1812) --- .../backends/postgres/PostgresCommons.java | 70 +++ .../postgres/utils/PostgresExecutor.java | 7 + mailbox/postgres/pom.xml | 14 + .../PostgresMailboxAggregateModule.java | 4 +- .../mailbox/postgres/PostgresMessageId.java | 88 ++++ .../postgres/mail/PostgresMessageMapper.java | 448 ++++++++++++++++++ .../postgres/mail/PostgresMessageModule.java | 164 +++++++ .../postgres/mail/dao/PostgresMailboxDAO.java | 11 + .../mail/dao/PostgresMailboxMessageDAO.java | 378 +++++++++++++++ .../dao/PostgresMailboxMessageDAOUtils.java | 186 ++++++++ .../postgres/mail/dao/PostgresMessageDAO.java | 91 ++++ .../postgres/mail/JpaMessageMapperTest.java | 156 ------ .../postgres/mail/PostgresMapperProvider.java | 135 ++++++ .../mail/PostgresMessageMapperTest.java | 46 ++ ...Test.java => PostgresMessageMoveTest.java} | 21 +- .../store/mail/model/MessageMapperTest.java | 2 +- 16 files changed, 1650 insertions(+), 171 deletions(-) create mode 100644 backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresCommons.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMessageId.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAOUtils.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java delete mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaMessageMapperTest.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMapperProvider.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperTest.java rename mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/{JpaMessageMoveTest.java => PostgresMessageMoveTest.java} (74%) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresCommons.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresCommons.java new file mode 100644 index 00000000000..ae4b8ebf5e9 --- /dev/null +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresCommons.java @@ -0,0 +1,70 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Date; +import java.util.Optional; +import java.util.function.Function; + +import org.jooq.DataType; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.DefaultDataType; +import org.jooq.impl.SQLDataType; +import org.jooq.postgres.extensions.bindings.HstoreBinding; +import org.jooq.postgres.extensions.types.Hstore; + +public class PostgresCommons { + + public interface DataTypes { + + // hstore + DataType HSTORE = DefaultDataType.getDefaultDataType("hstore").asConvertedDataType(new HstoreBinding()); + + // timestamp(6) + DataType TIMESTAMP = SQLDataType.LOCALDATETIME(6); + + // text[] + DataType STRING_ARRAY = SQLDataType.CLOB.getArrayDataType(); + } + + public interface SimpleTableField { + Field of(Table table, Field field); + } + + public static final SimpleTableField TABLE_FIELD = (table, field) -> DSL.field(table.getName() + "." + field.getName()); + + public static final Function DATE_TO_LOCAL_DATE_TIME = date -> Optional.ofNullable(date) + .map(value -> LocalDateTime.ofInstant(value.toInstant(), ZoneOffset.UTC)) + .orElse(null); + public static final Function LOCAL_DATE_TIME_DATE_FUNCTION = localDateTime -> Optional.ofNullable(localDateTime) + .map(value -> value.toInstant(ZoneOffset.UTC)) + .map(Date::from) + .orElse(null); + + public static final Function, Field> UNNEST_FIELD = field -> DSL.function("unnest", field.getType().getComponentType(), field); + + public static final int IN_CLAUSE_MAX_SIZE = 32; + +} diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index 7a6485108f5..05a3556ad0d 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -28,6 +28,7 @@ import org.apache.james.core.Domain; import org.jooq.DSLContext; import org.jooq.Record; +import org.jooq.Record1; import org.jooq.SQLDialect; import org.jooq.conf.Settings; import org.jooq.conf.StatementType; @@ -96,6 +97,12 @@ public Mono executeRow(Function> queryFunction) .flatMap(queryFunction); } + public Mono executeCount(Function>> queryFunction) { + return dslContext() + .flatMap(queryFunction) + .map(Record1::value1); + } + public Mono connection() { return connection; } diff --git a/mailbox/postgres/pom.xml b/mailbox/postgres/pom.xml index 690389fab59..1c638412735 100644 --- a/mailbox/postgres/pom.xml +++ b/mailbox/postgres/pom.xml @@ -83,6 +83,20 @@ test-jar test + + ${james.groupId} + blob-api + + + ${james.groupId} + blob-memory + test + + + ${james.groupId} + blob-storage-strategy + test + ${james.groupId} event-bus-api diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAggregateModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAggregateModule.java index db208dd9750..9ec68fd6fd6 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAggregateModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAggregateModule.java @@ -21,11 +21,13 @@ import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.mailbox.postgres.mail.PostgresMailboxModule; +import org.apache.james.mailbox.postgres.mail.PostgresMessageModule; import org.apache.james.mailbox.postgres.user.PostgresSubscriptionModule; public interface PostgresMailboxAggregateModule { PostgresModule MODULE = PostgresModule.aggregateModules( PostgresMailboxModule.MODULE, - PostgresSubscriptionModule.MODULE); + PostgresSubscriptionModule.MODULE, + PostgresMessageModule.MODULE); } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMessageId.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMessageId.java new file mode 100644 index 00000000000..c4012f19993 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMessageId.java @@ -0,0 +1,88 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres; + +import java.util.Objects; +import java.util.UUID; + +import org.apache.james.mailbox.model.MessageId; + +import com.google.common.base.MoreObjects; + +public class PostgresMessageId implements MessageId { + + public static class Factory implements MessageId.Factory { + + @Override + public PostgresMessageId generate() { + return of(UUID.randomUUID()); + } + + public static PostgresMessageId of(UUID uuid) { + return new PostgresMessageId(uuid); + } + + @Override + public PostgresMessageId fromString(String serialized) { + return of(UUID.fromString(serialized)); + } + } + + private final UUID uuid; + + private PostgresMessageId(UUID uuid) { + this.uuid = uuid; + } + + @Override + public String serialize() { + return uuid.toString(); + } + + public UUID asUuid() { + return uuid; + } + + @Override + public boolean isSerializable() { + return true; + } + + @Override + public final boolean equals(Object o) { + if (o instanceof PostgresMessageId) { + PostgresMessageId other = (PostgresMessageId) o; + return Objects.equals(uuid, other.uuid); + } + return false; + } + + @Override + public final int hashCode() { + return Objects.hash(uuid); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("uuid", uuid) + .toString(); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java new file mode 100644 index 00000000000..620d48df7cf --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java @@ -0,0 +1,448 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import static org.apache.james.blob.api.BlobStore.StoragePolicy.LOW_COST; +import static org.apache.james.blob.api.BlobStore.StoragePolicy.SIZE_BASED; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.time.Clock; +import java.util.Date; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +import javax.mail.Flags; + +import org.apache.commons.lang3.tuple.Pair; +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BlobStore; +import org.apache.james.mailbox.ApplicableFlagBuilder; +import org.apache.james.mailbox.FlagsBuilder; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.model.ComposedMessageId; +import org.apache.james.mailbox.model.ComposedMessageIdWithMetaData; +import org.apache.james.mailbox.model.Content; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxCounters; +import org.apache.james.mailbox.model.MessageMetaData; +import org.apache.james.mailbox.model.MessageRange; +import org.apache.james.mailbox.model.UpdatedFlags; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.apache.james.mailbox.store.FlagsUpdateCalculator; +import org.apache.james.mailbox.store.MailboxReactorUtils; +import org.apache.james.mailbox.store.mail.MessageMapper; +import org.apache.james.mailbox.store.mail.model.MailboxMessage; +import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; +import org.apache.james.util.streams.Limit; + +import com.google.common.io.ByteSource; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresMessageMapper implements MessageMapper { + + private static final Function MESSAGE_FULL_CONTENT_LOADER = (mailboxMessage) -> new ByteSource() { + @Override + public InputStream openStream() { + try { + return mailboxMessage.getFullContent(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public long size() { + return mailboxMessage.metaData().getSize(); + } + }; + + + private final PostgresMessageDAO messageDAO; + private final PostgresMailboxMessageDAO mailboxMessageDAO; + private final PostgresMailboxDAO mailboxDAO; + private final PostgresModSeqProvider modSeqProvider; + private final PostgresUidProvider uidProvider; + private final BlobStore blobStore; + private final Clock clock; + private final BlobId.Factory blobIdFactory; + + public PostgresMessageMapper(PostgresExecutor postgresExecutor, + PostgresModSeqProvider modSeqProvider, + PostgresUidProvider uidProvider, + BlobStore blobStore, + Clock clock, + BlobId.Factory blobIdFactory) { + this.messageDAO = new PostgresMessageDAO(postgresExecutor); + this.mailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExecutor); + this.mailboxDAO = new PostgresMailboxDAO(postgresExecutor); + this.modSeqProvider = modSeqProvider; + this.uidProvider = uidProvider; + this.blobStore = blobStore; + this.clock = clock; + this.blobIdFactory = blobIdFactory; + } + + + @Override + public Iterator findInMailbox(Mailbox mailbox, MessageRange set, FetchType type, int limit) { + return findInMailboxReactive(mailbox, set, type, limit) + .toIterable() + .iterator(); + } + + @Override + public Flux listMessagesMetadata(Mailbox mailbox, MessageRange set) { + return mailboxMessageDAO.findMessagesMetadata((PostgresMailboxId) mailbox.getMailboxId(), set); + } + + @Override + public Flux findInMailboxReactive(Mailbox mailbox, MessageRange messageRange, FetchType fetchType, int limitAsInt) { + return Mono.just(messageRange) + .flatMapMany(range -> { + Limit limit = Limit.from(limitAsInt); + switch (messageRange.getType()) { + case ALL: + return mailboxMessageDAO.findMessagesByMailboxId((PostgresMailboxId) mailbox.getMailboxId(), limit); + case FROM: + return mailboxMessageDAO.findMessagesByMailboxIdAndAfterUID((PostgresMailboxId) mailbox.getMailboxId(), range.getUidFrom(), limit); + case ONE: + return mailboxMessageDAO.findMessageByMailboxIdAndUid((PostgresMailboxId) mailbox.getMailboxId(), range.getUidFrom()) + .flatMapMany(Flux::just); + case RANGE: + return mailboxMessageDAO.findMessagesByMailboxIdAndBetweenUIDs((PostgresMailboxId) mailbox.getMailboxId(), range.getUidFrom(), range.getUidTo(), limit); + default: + throw new RuntimeException("Unknown MessageRange range " + range.getType()); + } + }).flatMap(messageBuilderAndBlobId -> { + SimpleMailboxMessage.Builder messageBuilder = messageBuilderAndBlobId.getLeft(); + String blobIdAsString = messageBuilderAndBlobId.getRight(); + switch (fetchType) { + case METADATA: + case ATTACHMENTS_METADATA: + case HEADERS: + return Mono.just(messageBuilder.build()); + case FULL: + return retrieveFullContent(blobIdAsString) + .map(content -> messageBuilder.content(content).build()); + default: + return Flux.error(new RuntimeException("Unknown FetchType " + fetchType)); + } + }); + } + + private Mono retrieveFullContent(String blobIdString) { + return Mono.from(blobStore.readBytes(blobStore.getDefaultBucketName(), blobIdFactory.from(blobIdString), SIZE_BASED)) + .map(contentAsBytes -> new Content() { + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(contentAsBytes); + } + + @Override + public long size() { + return contentAsBytes.length; + } + }); + } + + @Override + public List retrieveMessagesMarkedForDeletion(Mailbox mailbox, MessageRange messageRange) { + return retrieveMessagesMarkedForDeletionReactive(mailbox, messageRange) + .collectList() + .block(); + } + + @Override + public Flux retrieveMessagesMarkedForDeletionReactive(Mailbox mailbox, MessageRange messageRange) { + return Mono.just(messageRange) + .flatMapMany(range -> { + switch (messageRange.getType()) { + case ALL: + return mailboxMessageDAO.findDeletedMessagesByMailboxId((PostgresMailboxId) mailbox.getMailboxId()); + case FROM: + return mailboxMessageDAO.findDeletedMessagesByMailboxIdAndAfterUID((PostgresMailboxId) mailbox.getMailboxId(), range.getUidFrom()); + case ONE: + return mailboxMessageDAO.findDeletedMessageByMailboxIdAndUid((PostgresMailboxId) mailbox.getMailboxId(), range.getUidFrom()) + .flatMapMany(Flux::just); + case RANGE: + return mailboxMessageDAO.findDeletedMessagesByMailboxIdAndBetweenUIDs((PostgresMailboxId) mailbox.getMailboxId(), range.getUidFrom(), range.getUidTo()); + default: + throw new RuntimeException("Unknown MessageRange type " + range.getType()); + } + }); + } + + @Override + public long countMessagesInMailbox(Mailbox mailbox) { + return mailboxMessageDAO.countTotalMessagesByMailboxId((PostgresMailboxId) mailbox.getMailboxId()) + .block(); + } + + @Override + public MailboxCounters getMailboxCounters(Mailbox mailbox) { + return getMailboxCountersReactive(mailbox).block(); + } + + @Override + public Mono getMailboxCountersReactive(Mailbox mailbox) { + return mailboxMessageDAO.countTotalMessagesByMailboxId((PostgresMailboxId) mailbox.getMailboxId()) + .flatMap(totalMessage -> mailboxMessageDAO.countUnseenMessagesByMailboxId((PostgresMailboxId) mailbox.getMailboxId()) + .map(unseenMessage -> MailboxCounters.builder() + .mailboxId(mailbox.getMailboxId()) + .count(totalMessage) + .unseen(unseenMessage) + .build())); + } + + @Override + public void delete(Mailbox mailbox, MailboxMessage message) throws MailboxException { + deleteMessages(mailbox, List.of(message.getUid())); + } + + @Override + public Map deleteMessages(Mailbox mailbox, List uids) { + return deleteMessagesReactive(mailbox, uids).block(); + } + + @Override + public Mono> deleteMessagesReactive(Mailbox mailbox, List uids) { + return mailboxMessageDAO.findMessagesByMailboxIdAndUIDs((PostgresMailboxId) mailbox.getMailboxId(), uids) + .map(SimpleMailboxMessage.Builder::build) + .collectMap(MailboxMessage::getUid, MailboxMessage::metaData) + .flatMap(map -> mailboxMessageDAO.deleteByMailboxIdAndMessageUids((PostgresMailboxId) mailbox.getMailboxId(), uids) + .then(Mono.just(map))); + } + + @Override + public MessageUid findFirstUnseenMessageUid(Mailbox mailbox) { + return mailboxMessageDAO.findFirstUnseenMessageUid((PostgresMailboxId) mailbox.getMailboxId()).block(); + } + + @Override + public Mono> findFirstUnseenMessageUidReactive(Mailbox mailbox) { + return mailboxMessageDAO.findFirstUnseenMessageUid((PostgresMailboxId) mailbox.getMailboxId()) + .map(Optional::of) + .switchIfEmpty(Mono.just(Optional.empty())); + } + + @Override + public List findRecentMessageUidsInMailbox(Mailbox mailbox) { + return findRecentMessageUidsInMailboxReactive(mailbox).block(); + } + + @Override + public Mono> findRecentMessageUidsInMailboxReactive(Mailbox mailbox) { + return mailboxMessageDAO.findAllRecentMessageUid((PostgresMailboxId) mailbox.getMailboxId()) + .collectList(); + } + + @Override + public MessageMetaData add(Mailbox mailbox, MailboxMessage message) throws MailboxException { + return addReactive(mailbox, message).block(); + } + + @Override + public Mono addReactive(Mailbox mailbox, MailboxMessage message) { + return Mono.fromCallable(() -> { + message.setSaveDate(Date.from(clock.instant())); + return message; + }) + .flatMap(this::setNewUidAndModSeq) + .then(saveFullContent(message) + .flatMap(blobId -> messageDAO.insert(message, blobId.asString()))) + .then(Mono.defer(() -> mailboxMessageDAO.insert(message))) + .then(Mono.fromCallable(message::metaData)); + } + + private Mono saveFullContent(MailboxMessage message) { + return Mono.fromCallable(() -> MESSAGE_FULL_CONTENT_LOADER.apply(message)) + .flatMap(bodyByteSource -> Mono.from(blobStore.save(blobStore.getDefaultBucketName(), bodyByteSource, LOW_COST))); + } + + @Override + public Iterator updateFlags(Mailbox mailbox, FlagsUpdateCalculator flagsUpdateCalculator, MessageRange range) { + return updateFlagsPublisher(mailbox, flagsUpdateCalculator, range) + .toIterable() + .iterator(); + } + + @Override + public Mono> updateFlagsReactive(Mailbox mailbox, FlagsUpdateCalculator flagsUpdateCalculator, MessageRange range) { + return updateFlagsPublisher(mailbox, flagsUpdateCalculator, range) + .collectList(); + } + + private Flux updateFlagsPublisher(Mailbox mailbox, FlagsUpdateCalculator flagsUpdateCalculator, MessageRange range) { + return mailboxMessageDAO.findMessagesMetadata((PostgresMailboxId) mailbox.getMailboxId(), range) + .flatMap(currentMetaData -> modSeqProvider.nextModSeqReactive(mailbox.getMailboxId()) + .flatMap(newModSeq -> updateFlags(currentMetaData, flagsUpdateCalculator, newModSeq))); + } + + private Mono updateFlags(ComposedMessageIdWithMetaData currentMetaData, + FlagsUpdateCalculator flagsUpdateCalculator, + ModSeq newModSeq) { + Flags oldFlags = currentMetaData.getFlags(); + Flags newFlags = flagsUpdateCalculator.buildNewFlags(oldFlags); + + ComposedMessageId composedMessageId = currentMetaData.getComposedMessageId(); + + return Mono.just(UpdatedFlags.builder() + .messageId(composedMessageId.getMessageId()) + .oldFlags(oldFlags) + .newFlags(newFlags) + .uid(composedMessageId.getUid())) + .flatMap(builder -> { + if (oldFlags.equals(newFlags)) { + return Mono.just(builder.modSeq(currentMetaData.getModSeq()) + .build()); + } + return Mono.fromCallable(() -> builder.modSeq(newModSeq).build()) + .flatMap(updatedFlags -> mailboxMessageDAO.updateFlag((PostgresMailboxId) composedMessageId.getMailboxId(), composedMessageId.getUid(), updatedFlags) + .thenReturn(updatedFlags)); + }); + } + + @Override + public List resetRecent(Mailbox mailbox) { + return resetRecentReactive(mailbox).block(); + } + + @Override + public Mono> resetRecentReactive(Mailbox mailbox) { + return mailboxMessageDAO.findAllRecentMessageMetadata((PostgresMailboxId) mailbox.getMailboxId()) + .collectList() + .flatMapMany(mailboxMessageList -> resetRecentFlag((PostgresMailboxId) mailbox.getMailboxId(), mailboxMessageList)) + .collectList(); + } + + private Flux resetRecentFlag(PostgresMailboxId mailboxId, List messageIdWithMetaDataList) { + return Flux.fromIterable(messageIdWithMetaDataList) + .collectMap(m -> m.getComposedMessageId().getUid(), Function.identity()) + .flatMapMany(uidMapping -> modSeqProvider.nextModSeqReactive(mailboxId) + .flatMapMany(newModSeq -> mailboxMessageDAO.resetRecentFlag(mailboxId, List.copyOf(uidMapping.keySet()), newModSeq)) + .map(newMetaData -> UpdatedFlags.builder() + .messageId(newMetaData.getMessageId()) + .modSeq(newMetaData.getModSeq()) + .oldFlags(uidMapping.get(newMetaData.getUid()).getFlags()) + .newFlags(newMetaData.getFlags()) + .uid(newMetaData.getUid()) + .build())); + } + + @Override + public MessageMetaData copy(Mailbox mailbox, MailboxMessage original) throws MailboxException { + return copyReactive(mailbox, original).block(); + } + + private Mono setNewUidAndModSeq(MailboxMessage mailboxMessage) { + return mailboxDAO.incrementAndGetLastUidAndModSeq(mailboxMessage.getMailboxId()) + .defaultIfEmpty(Pair.of(MessageUid.MIN_VALUE, ModSeq.first())) + .map(pair -> { + mailboxMessage.setUid(pair.getLeft()); + mailboxMessage.setModSeq(pair.getRight()); + return pair; + }).then(); + } + + + @Override + public Mono copyReactive(Mailbox mailbox, MailboxMessage original) { + return Mono.fromCallable(() -> { + MailboxMessage copiedMessage = original.copy(mailbox); + copiedMessage.setFlags(new FlagsBuilder().add(original.createFlags()).add(Flags.Flag.RECENT).build()); + copiedMessage.setSaveDate(Date.from(clock.instant())); + return copiedMessage; + }) + .flatMap(copiedMessage -> setNewUidAndModSeq(copiedMessage) + .then(Mono.defer(() -> mailboxMessageDAO.insert(copiedMessage)) + .thenReturn(copiedMessage)) + .map(MailboxMessage::metaData)); + } + + + @Override + public MessageMetaData move(Mailbox mailbox, MailboxMessage original) { + var t = moveReactive(mailbox, original).block(); + return t; + } + + @Override + public List move(Mailbox mailbox, List original) throws MailboxException { + return MailboxReactorUtils.block(moveReactive(mailbox, original)); + } + + + @Override + public Mono moveReactive(Mailbox mailbox, MailboxMessage original) { + return copyReactive(mailbox, original) + .flatMap(copiedResult -> mailboxMessageDAO.deleteByMailboxIdAndMessageUid((PostgresMailboxId) original.getMailboxId(), original.getUid()) + .thenReturn(copiedResult)); + } + + @Override + public Optional getLastUid(Mailbox mailbox) { + return uidProvider.lastUid(mailbox); + } + + @Override + public Mono> getLastUidReactive(Mailbox mailbox) { + return uidProvider.lastUidReactive(mailbox); + } + + @Override + public ModSeq getHighestModSeq(Mailbox mailbox) { + return modSeqProvider.highestModSeq(mailbox); + } + + @Override + public Mono getHighestModSeqReactive(Mailbox mailbox) { + return modSeqProvider.highestModSeqReactive(mailbox); + } + + @Override + public Flags getApplicableFlag(Mailbox mailbox) { + return getApplicableFlagReactive(mailbox).block(); + } + + @Override + public Mono getApplicableFlagReactive(Mailbox mailbox) { + return mailboxMessageDAO.listDistinctUserFlags((PostgresMailboxId) mailbox.getMailboxId()) + .map(flags -> ApplicableFlagBuilder.builder().add(flags).build()); + } + + @Override + public Flux listAllMessageUids(Mailbox mailbox) { + return mailboxMessageDAO.listAllMessageUid((PostgresMailboxId) mailbox.getMailboxId()); + } + +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java new file mode 100644 index 00000000000..dd3cde87275 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java @@ -0,0 +1,164 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import static org.jooq.impl.DSL.foreignKey; + +import java.time.LocalDateTime; +import java.util.UUID; + +import org.apache.james.backends.postgres.PostgresCommons.DataTypes; +import org.apache.james.backends.postgres.PostgresIndex; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; +import org.jooq.postgres.extensions.types.Hstore; + +public interface PostgresMessageModule { + + Field MESSAGE_ID = DSL.field("message_id", SQLDataType.UUID.notNull()); + Field INTERNAL_DATE = DSL.field("internal_date", DataTypes.TIMESTAMP); + Field SIZE = DSL.field("size", SQLDataType.BIGINT.notNull()); + + interface MessageTable { + Table TABLE_NAME = DSL.table("message"); + Field MESSAGE_ID = PostgresMessageModule.MESSAGE_ID; + Field BLOB_ID = DSL.field("blob_id", SQLDataType.VARCHAR(200).notNull()); + Field MIME_TYPE = DSL.field("mime_type", SQLDataType.VARCHAR(200)); + Field MIME_SUBTYPE = DSL.field("mime_subtype", SQLDataType.VARCHAR(200)); + Field INTERNAL_DATE = PostgresMessageModule.INTERNAL_DATE; + Field SIZE = PostgresMessageModule.SIZE; + Field BODY_START_OCTET = DSL.field("body_start_octet", SQLDataType.INTEGER.notNull()); + Field HEADER_CONTENT = DSL.field("header_content", SQLDataType.BLOB.notNull()); + Field TEXTUAL_LINE_COUNT = DSL.field("textual_line_count", SQLDataType.INTEGER); + + Field CONTENT_DESCRIPTION = DSL.field("content_description", SQLDataType.VARCHAR(200)); + Field CONTENT_LOCATION = DSL.field("content_location", SQLDataType.VARCHAR(200)); + Field CONTENT_TRANSFER_ENCODING = DSL.field("content_transfer_encoding", SQLDataType.VARCHAR(200)); + Field CONTENT_DISPOSITION_TYPE = DSL.field("content_disposition_type", SQLDataType.VARCHAR(200)); + Field CONTENT_ID = DSL.field("content_id", SQLDataType.VARCHAR(200)); + Field CONTENT_MD5 = DSL.field("content_md5", SQLDataType.VARCHAR(200)); + Field CONTENT_LANGUAGE = DSL.field("content_language", DataTypes.STRING_ARRAY); + Field CONTENT_TYPE_PARAMETERS = DSL.field("content_type_parameters", DataTypes.HSTORE); + Field CONTENT_DISPOSITION_PARAMETERS = DSL.field("content_disposition_parameters", DataTypes.HSTORE); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(MESSAGE_ID) + .column(BLOB_ID) + .column(MIME_TYPE) + .column(MIME_SUBTYPE) + .column(INTERNAL_DATE) + .column(SIZE) + .column(BODY_START_OCTET) + .column(HEADER_CONTENT) + .column(TEXTUAL_LINE_COUNT) + .column(CONTENT_DESCRIPTION) + .column(CONTENT_LOCATION) + .column(CONTENT_TRANSFER_ENCODING) + .column(CONTENT_DISPOSITION_TYPE) + .column(CONTENT_ID) + .column(CONTENT_MD5) + .column(CONTENT_LANGUAGE) + .column(CONTENT_TYPE_PARAMETERS) + .column(CONTENT_DISPOSITION_PARAMETERS) + .constraint(DSL.primaryKey(MESSAGE_ID)) + .comment("Holds the metadata of a mail"))) + .supportsRowLevelSecurity(); + } + + interface MessageToMailboxTable { + Table TABLE_NAME = DSL.table("message_mailbox"); + Field MAILBOX_ID = DSL.field("mailbox_id", SQLDataType.UUID.notNull()); + Field MESSAGE_UID = DSL.field("message_uid", SQLDataType.BIGINT.notNull()); + Field MOD_SEQ = DSL.field("mod_seq", SQLDataType.BIGINT.notNull()); + Field MESSAGE_ID = PostgresMessageModule.MESSAGE_ID; + Field THREAD_ID = DSL.field("thread_id", SQLDataType.UUID); + Field INTERNAL_DATE = PostgresMessageModule.INTERNAL_DATE; + Field SIZE = PostgresMessageModule.SIZE; + Field IS_DELETED = DSL.field("is_deleted", SQLDataType.BOOLEAN.nullable(false) + .defaultValue(DSL.field("false", SQLDataType.BOOLEAN))); + Field IS_ANSWERED = DSL.field("is_answered", SQLDataType.BOOLEAN.nullable(false)); + Field IS_DRAFT = DSL.field("is_draft", SQLDataType.BOOLEAN.nullable(false)); + Field IS_FLAGGED = DSL.field("is_flagged", SQLDataType.BOOLEAN.nullable(false)); + Field IS_RECENT = DSL.field("is_recent", SQLDataType.BOOLEAN.nullable(false)); + Field IS_SEEN = DSL.field("is_seen", SQLDataType.BOOLEAN.nullable(false)); + Field USER_FLAGS = DSL.field("user_flags", DataTypes.STRING_ARRAY); + Field SAVE_DATE = DSL.field("save_date", DataTypes.TIMESTAMP); + + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(MAILBOX_ID) + .column(MESSAGE_UID) + .column(MOD_SEQ) + .column(MESSAGE_ID) + .column(THREAD_ID) + .column(INTERNAL_DATE) + .column(SIZE) + .column(IS_DELETED) + .column(IS_ANSWERED) + .column(IS_DRAFT) + .column(IS_FLAGGED) + .column(IS_RECENT) + .column(IS_SEEN) + .column(USER_FLAGS) + .column(SAVE_DATE) + .constraints(DSL.primaryKey(MAILBOX_ID, MESSAGE_UID), + foreignKey(MAILBOX_ID).references(PostgresMailboxTable.TABLE_NAME, PostgresMailboxTable.MAILBOX_ID), + foreignKey(MESSAGE_ID).references(MessageTable.TABLE_NAME, MessageTable.MESSAGE_ID)) + .comment("Holds mailbox and flags for each message"))) + .supportsRowLevelSecurity(); + + PostgresIndex MESSAGE_ID_INDEX = PostgresIndex.name("message_mailbox_message_id_index") + .createIndexStep((dsl, indexName) -> dsl.createIndexIfNotExists(indexName) + .on(TABLE_NAME, MESSAGE_ID)); + + PostgresIndex MAILBOX_ID_MESSAGE_UID_INDEX = PostgresIndex.name("mailbox_id_mail_uid_index") + .createIndexStep((dsl, indexName) -> dsl.createIndexIfNotExists(indexName) + .on(TABLE_NAME, MAILBOX_ID, MESSAGE_UID.asc())); + PostgresIndex MAILBOX_ID_IS_SEEN_MESSAGE_UID_INDEX = PostgresIndex.name("mailbox_id_is_seen_mail_uid_index") + .createIndexStep((dsl, indexName) -> dsl.createIndexIfNotExists(indexName) + .on(TABLE_NAME, MAILBOX_ID, IS_SEEN, MESSAGE_UID.asc())); + PostgresIndex MAILBOX_ID_IS_RECENT_MESSAGE_UID_INDEX = PostgresIndex.name("mailbox_id_is_recent_mail_uid_index") + .createIndexStep((dsl, indexName) -> dsl.createIndexIfNotExists(indexName) + .on(TABLE_NAME, MAILBOX_ID, IS_RECENT, MESSAGE_UID.asc())); + PostgresIndex MAILBOX_ID_IS_DELETE_MESSAGE_UID_INDEX = PostgresIndex.name("mailbox_id_is_delete_mail_uid_index") + .createIndexStep((dsl, indexName) -> dsl.createIndexIfNotExists(indexName) + .on(TABLE_NAME, MAILBOX_ID, IS_DELETED, MESSAGE_UID.asc())); + + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(MessageTable.TABLE) + .addTable(MessageToMailboxTable.TABLE) + .addIndex(MessageToMailboxTable.MESSAGE_ID_INDEX) + .addIndex(MessageToMailboxTable.MAILBOX_ID_MESSAGE_UID_INDEX) + .addIndex(MessageToMailboxTable.MAILBOX_ID_IS_SEEN_MESSAGE_UID_INDEX) + .addIndex(MessageToMailboxTable.MAILBOX_ID_IS_RECENT_MESSAGE_UID_INDEX) + .addIndex(MessageToMailboxTable.MAILBOX_ID_IS_DELETE_MESSAGE_UID_INDEX) + .build(); + +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java index f820a2ce075..22ccdf9e04e 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java @@ -39,6 +39,7 @@ import java.util.function.Function; import java.util.stream.Collectors; +import org.apache.commons.lang3.tuple.Pair; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.Username; import org.apache.james.mailbox.MessageUid; @@ -238,4 +239,14 @@ public Mono incrementAndGetModSeq(MailboxId mailboxId) { .map(record -> record.get(MAILBOX_HIGHEST_MODSEQ)) .map(ModSeq::of); } + + public Mono> incrementAndGetLastUidAndModSeq(MailboxId mailboxId) { + int increment = 1; + return postgresExecutor.executeRow(dsl -> Mono.from(dsl.update(TABLE_NAME) + .set(MAILBOX_LAST_UID, coalesce(MAILBOX_LAST_UID, 0L).add(increment)) + .set(MAILBOX_HIGHEST_MODSEQ, coalesce(MAILBOX_HIGHEST_MODSEQ, 0L).add(increment)) + .where(MAILBOX_ID.eq(getMailboxId(mailboxId).asUuid())) + .returning(MAILBOX_LAST_UID, MAILBOX_HIGHEST_MODSEQ))) + .map(record -> Pair.of(MessageUid.of(record.get(MAILBOX_LAST_UID)), ModSeq.of(record.get(MAILBOX_HIGHEST_MODSEQ)))); + } } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java new file mode 100644 index 00000000000..c55132b59f9 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java @@ -0,0 +1,378 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail.dao; + + +import static org.apache.james.backends.postgres.PostgresCommons.DATE_TO_LOCAL_DATE_TIME; +import static org.apache.james.backends.postgres.PostgresCommons.IN_CLAUSE_MAX_SIZE; +import static org.apache.james.backends.postgres.PostgresCommons.TABLE_FIELD; +import static org.apache.james.backends.postgres.PostgresCommons.UNNEST_FIELD; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.BLOB_ID; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.INTERNAL_DATE; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.SIZE; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.IS_ANSWERED; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.IS_DELETED; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.IS_DRAFT; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.IS_FLAGGED; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.IS_RECENT; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.IS_SEEN; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.MAILBOX_ID; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.MESSAGE_ID; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.MESSAGE_UID; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.MOD_SEQ; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.SAVE_DATE; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.TABLE_NAME; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.THREAD_ID; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.USER_FLAGS; +import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.BOOLEAN_FLAGS_MAPPING; +import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.MESSAGE_METADATA_FIELDS_REQUIRE; +import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.RECORD_TO_COMPOSED_MESSAGE_ID_WITH_META_DATA_FUNCTION; +import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.RECORD_TO_MAILBOX_MESSAGE_BUILDER_FUNCTION; +import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.RECORD_TO_MESSAGE_METADATA_FUNCTION; +import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.RECORD_TO_MESSAGE_UID_FUNCTION; + +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +import javax.mail.Flags; +import javax.mail.Flags.Flag; + +import org.apache.commons.lang3.tuple.Pair; +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.model.ComposedMessageIdWithMetaData; +import org.apache.james.mailbox.model.MessageMetaData; +import org.apache.james.mailbox.model.MessageRange; +import org.apache.james.mailbox.model.UpdatedFlags; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable; +import org.apache.james.mailbox.store.mail.model.MailboxMessage; +import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; +import org.apache.james.util.streams.Limit; +import org.jooq.Condition; +import org.jooq.DSLContext; +import org.jooq.Record; +import org.jooq.Record1; +import org.jooq.SelectFinalStep; +import org.jooq.SelectSeekStep1; +import org.jooq.SortField; +import org.jooq.TableOnConditionStep; +import org.jooq.UpdateConditionStep; +import org.jooq.UpdateSetStep; +import org.jooq.impl.DSL; + +import com.google.common.collect.Iterables; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresMailboxMessageDAO { + + private static final TableOnConditionStep MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP = TABLE_NAME.join(MessageTable.TABLE_NAME) + .on(TABLE_FIELD.of(TABLE_NAME, MESSAGE_ID).eq(TABLE_FIELD.of(MessageTable.TABLE_NAME, MessageTable.MESSAGE_ID))); + + public static final SortField DEFAULT_SORT_ORDER_BY = MESSAGE_UID.asc(); + + private static SelectFinalStep> selectMessageUidByMailboxIdAndExtraConditionQuery(PostgresMailboxId mailboxId, Condition extraCondition, Limit limit, DSLContext dslContext) { + SelectSeekStep1, Long> queryWithoutLimit = dslContext.select(MESSAGE_UID) + .from(TABLE_NAME) + .where(MAILBOX_ID.eq((mailboxId.asUuid()))) + .and(extraCondition) + .orderBy(MESSAGE_UID.asc()); + return limit.getLimit().map(limitValue -> (SelectFinalStep>) queryWithoutLimit.limit(limitValue)) + .orElse(queryWithoutLimit); + } + + private final PostgresExecutor postgresExecutor; + + public PostgresMailboxMessageDAO(PostgresExecutor postgresExecutor) { + this.postgresExecutor = postgresExecutor; + } + + public Mono findFirstUnseenMessageUid(PostgresMailboxId mailboxId) { + return postgresExecutor.executeRow(dslContext -> Mono.from(selectMessageUidByMailboxIdAndExtraConditionQuery(mailboxId, + IS_SEEN.eq(false), Limit.limit(1), dslContext))) + .map(RECORD_TO_MESSAGE_UID_FUNCTION); + } + + public Flux findAllRecentMessageUid(PostgresMailboxId mailboxId) { + return postgresExecutor.executeRows(dslContext -> Flux.from(selectMessageUidByMailboxIdAndExtraConditionQuery(mailboxId, + IS_RECENT.eq(true), Limit.unlimited(), dslContext))) + .map(RECORD_TO_MESSAGE_UID_FUNCTION); + } + + public Flux listAllMessageUid(PostgresMailboxId mailboxId) { + return postgresExecutor.executeRows(dslContext -> Flux.from(selectMessageUidByMailboxIdAndExtraConditionQuery(mailboxId, + DSL.noCondition(), Limit.unlimited(), dslContext))) + .map(RECORD_TO_MESSAGE_UID_FUNCTION); + } + + public Mono deleteByMailboxIdAndMessageUid(PostgresMailboxId mailboxId, MessageUid messageUid) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.deleteFrom(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(MESSAGE_UID.eq(messageUid.asLong())) + .returning(MESSAGE_METADATA_FIELDS_REQUIRE))) + .map(RECORD_TO_MESSAGE_METADATA_FUNCTION); + } + + public Flux deleteByMailboxIdAndMessageUids(PostgresMailboxId mailboxId, List uids) { + Function, Flux> deletePublisherFunction = uidsToDelete -> postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.deleteFrom(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(MESSAGE_UID.in(uidsToDelete.stream().map(MessageUid::asLong).toArray(Long[]::new))) + .returning(MESSAGE_METADATA_FIELDS_REQUIRE))) + .map(RECORD_TO_MESSAGE_METADATA_FUNCTION); + + if (uids.size() <= IN_CLAUSE_MAX_SIZE) { + return deletePublisherFunction.apply(uids); + } else { + return Flux.fromIterable(Iterables.partition(uids, IN_CLAUSE_MAX_SIZE)) + .flatMap(deletePublisherFunction); + } + } + + public Mono countUnseenMessagesByMailboxId(PostgresMailboxId mailboxId) { + return postgresExecutor.executeCount(dslContext -> Mono.from(dslContext.selectCount() + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(IS_SEEN.eq(false)))); + } + + public Mono countTotalMessagesByMailboxId(PostgresMailboxId mailboxId) { + return postgresExecutor.executeCount(dslContext -> Mono.from(dslContext.selectCount() + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())))); + } + + public Flux> findMessagesByMailboxId(PostgresMailboxId mailboxId, Limit limit) { + Function> queryWithoutLimit = dslContext -> dslContext.select() + .from(MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .orderBy(DEFAULT_SORT_ORDER_BY); + + return postgresExecutor.executeRows(dslContext -> limit.getLimit() + .map(limitValue -> Flux.from(queryWithoutLimit.andThen(step -> step.limit(limitValue)).apply(dslContext))) + .orElse(Flux.from(queryWithoutLimit.apply(dslContext)))) + .map(record -> Pair.of(RECORD_TO_MAILBOX_MESSAGE_BUILDER_FUNCTION.apply(record), record.get(BLOB_ID))); + } + + public Flux> findMessagesByMailboxIdAndBetweenUIDs(PostgresMailboxId mailboxId, MessageUid from, MessageUid to, Limit limit) { + Function> queryWithoutLimit = dslContext -> dslContext.select() + .from(MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(MESSAGE_UID.greaterOrEqual(from.asLong())) + .and(MESSAGE_UID.lessOrEqual(to.asLong())) + .orderBy(DEFAULT_SORT_ORDER_BY); + + return postgresExecutor.executeRows(dslContext -> limit.getLimit() + .map(limitValue -> Flux.from(queryWithoutLimit.andThen(step -> step.limit(limitValue)).apply(dslContext))) + .orElse(Flux.from(queryWithoutLimit.apply(dslContext)))) + .map(record -> Pair.of(RECORD_TO_MAILBOX_MESSAGE_BUILDER_FUNCTION.apply(record), record.get(BLOB_ID))); + } + + public Mono> findMessageByMailboxIdAndUid(PostgresMailboxId mailboxId, MessageUid uid) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select() + .from(MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(MESSAGE_UID.eq(uid.asLong())))) + .map(record -> Pair.of(RECORD_TO_MAILBOX_MESSAGE_BUILDER_FUNCTION.apply(record), record.get(BLOB_ID))); + } + + public Flux> findMessagesByMailboxIdAndAfterUID(PostgresMailboxId mailboxId, MessageUid from, Limit limit) { + Function> queryWithoutLimit = dslContext -> dslContext.select() + .from(MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(MESSAGE_UID.greaterOrEqual(from.asLong())) + .orderBy(DEFAULT_SORT_ORDER_BY); + + return postgresExecutor.executeRows(dslContext -> limit.getLimit() + .map(limitValue -> Flux.from(queryWithoutLimit.andThen(step -> step.limit(limitValue)).apply(dslContext))) + .orElse(Flux.from(queryWithoutLimit.apply(dslContext)))) + .map(record -> Pair.of(RECORD_TO_MAILBOX_MESSAGE_BUILDER_FUNCTION.apply(record), record.get(BLOB_ID))); + } + + public Flux findMessagesByMailboxIdAndUIDs(PostgresMailboxId mailboxId, List uids) { + Function, Flux> queryPublisherFunction = uidsToFetch -> postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select() + .from(MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(MESSAGE_UID.in(uidsToFetch.stream().map(MessageUid::asLong).toArray(Long[]::new))) + .orderBy(DEFAULT_SORT_ORDER_BY))) + .map(RECORD_TO_MAILBOX_MESSAGE_BUILDER_FUNCTION); + + if (uids.size() <= IN_CLAUSE_MAX_SIZE) { + return queryPublisherFunction.apply(uids); + } else { + return Flux.fromIterable(Iterables.partition(uids, IN_CLAUSE_MAX_SIZE)) + .flatMap(queryPublisherFunction); + } + } + + public Flux findDeletedMessagesByMailboxId(PostgresMailboxId mailboxId) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(MESSAGE_UID) + .from(MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(IS_DELETED.eq(true)) + .orderBy(DEFAULT_SORT_ORDER_BY))) + .map(RECORD_TO_MESSAGE_UID_FUNCTION); + } + + public Flux findDeletedMessagesByMailboxIdAndBetweenUIDs(PostgresMailboxId mailboxId, MessageUid from, MessageUid to) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(MESSAGE_UID) + .from(MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(IS_DELETED.eq(true)) + .and(MESSAGE_UID.greaterOrEqual(from.asLong())) + .and(MESSAGE_UID.lessOrEqual(to.asLong())) + .orderBy(DEFAULT_SORT_ORDER_BY))) + .map(RECORD_TO_MESSAGE_UID_FUNCTION); + } + + public Flux findDeletedMessagesByMailboxIdAndAfterUID(PostgresMailboxId mailboxId, MessageUid from) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(MESSAGE_UID) + .from(MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(IS_DELETED.eq(true)) + .and(MESSAGE_UID.greaterOrEqual(from.asLong())) + .orderBy(DEFAULT_SORT_ORDER_BY))) + .map(RECORD_TO_MESSAGE_UID_FUNCTION); + } + + public Mono findDeletedMessageByMailboxIdAndUid(PostgresMailboxId mailboxId, MessageUid uid) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select(MESSAGE_UID) + .from(MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(IS_DELETED.eq(true)) + .and(MESSAGE_UID.eq(uid.asLong())))) + .map(RECORD_TO_MESSAGE_UID_FUNCTION); + } + + public Flux findMessagesMetadata(PostgresMailboxId mailboxId, MessageRange range) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select() + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(MESSAGE_UID.greaterOrEqual(range.getUidFrom().asLong())) + .and(MESSAGE_UID.lessOrEqual(range.getUidTo().asLong())) + .orderBy(DEFAULT_SORT_ORDER_BY))) + .map(RECORD_TO_COMPOSED_MESSAGE_ID_WITH_META_DATA_FUNCTION); + } + + public Flux findMessagesMetadata(PostgresMailboxId mailboxId, List messageUids) { + Function, Flux> queryPublisherFunction = uidsToFetch -> postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select() + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(MESSAGE_UID.in(uidsToFetch.stream().map(MessageUid::asLong).toArray(Long[]::new))) + .orderBy(DEFAULT_SORT_ORDER_BY))) + .map(RECORD_TO_COMPOSED_MESSAGE_ID_WITH_META_DATA_FUNCTION); + + if (messageUids.size() <= IN_CLAUSE_MAX_SIZE) { + return queryPublisherFunction.apply(messageUids); + } else { + return Flux.fromIterable(Iterables.partition(messageUids, IN_CLAUSE_MAX_SIZE)) + .flatMap(queryPublisherFunction); + } + } + + public Flux findAllRecentMessageMetadata(PostgresMailboxId mailboxId) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select() + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(IS_RECENT.eq(true)) + .orderBy(DEFAULT_SORT_ORDER_BY))) + .map(RECORD_TO_COMPOSED_MESSAGE_ID_WITH_META_DATA_FUNCTION); + } + + public Mono updateFlag(PostgresMailboxId mailboxId, MessageUid uid, UpdatedFlags updatedFlags) { + return postgresExecutor.executeVoid(dslContext -> + Mono.from(buildUpdateFlagStatement(dslContext, updatedFlags, mailboxId, uid))); + } + + public Mono listDistinctUserFlags(PostgresMailboxId mailboxId) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.selectDistinct(UNNEST_FIELD.apply(USER_FLAGS)) + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())))) + .map(record -> record.get(0, String.class)) + .collectList() + .map(flagList -> { + Flags flags = new Flags(); + flagList.forEach(flags::add); + return flags; + }); + } + + private UpdateConditionStep buildUpdateFlagStatement(DSLContext dslContext, UpdatedFlags updatedFlags, + PostgresMailboxId mailboxId, MessageUid uid) { + AtomicReference> updateStatement = new AtomicReference<>(dslContext.update(TABLE_NAME)); + + BOOLEAN_FLAGS_MAPPING.forEach((flagColumn, flagMapped) -> { + if (updatedFlags.isChanged(flagMapped)) { + updateStatement.getAndUpdate(currentStatement -> { + if (flagMapped.equals(Flag.RECENT)) { + return currentStatement.set(flagColumn, updatedFlags.getNewFlags().contains(Flag.RECENT)); + } + return currentStatement.set(flagColumn, updatedFlags.isModifiedToSet(flagMapped)); + }); + } + }); + + return updateStatement.get() + .set(USER_FLAGS, updatedFlags.getNewFlags().getUserFlags()) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(MESSAGE_UID.eq(uid.asLong())); + } + + public Flux resetRecentFlag(PostgresMailboxId mailboxId, List uids, ModSeq newModSeq) { + Function, Flux> queryPublisherFunction = uidsMatching -> postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.update(TABLE_NAME) + .set(IS_RECENT, false) + .set(MOD_SEQ, newModSeq.asLong()) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(MESSAGE_UID.in(uidsMatching.stream().map(MessageUid::asLong).toArray(Long[]::new))) + .and(MOD_SEQ.notEqual(newModSeq.asLong())) + .returning(MESSAGE_METADATA_FIELDS_REQUIRE))) + .map(RECORD_TO_MESSAGE_METADATA_FUNCTION); + if (uids.size() <= IN_CLAUSE_MAX_SIZE) { + return queryPublisherFunction.apply(uids); + } else { + return Flux.fromIterable(Iterables.partition(uids, IN_CLAUSE_MAX_SIZE)) + .flatMap(queryPublisherFunction); + } + } + + public Mono insert(MailboxMessage mailboxMessage) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME) + .set(MAILBOX_ID, ((PostgresMailboxId) mailboxMessage.getMailboxId()).asUuid()) + .set(MESSAGE_UID, mailboxMessage.getUid().asLong()) + .set(MOD_SEQ, mailboxMessage.getModSeq().asLong()) + .set(MESSAGE_ID, ((PostgresMessageId) mailboxMessage.getMessageId()).asUuid()) + .set(THREAD_ID, ((PostgresMessageId) mailboxMessage.getThreadId().getBaseMessageId()).asUuid()) + .set(INTERNAL_DATE, DATE_TO_LOCAL_DATE_TIME.apply(mailboxMessage.getInternalDate())) + .set(SIZE, mailboxMessage.getFullContentOctets()) + .set(IS_DELETED, mailboxMessage.isDeleted()) + .set(IS_ANSWERED, mailboxMessage.isAnswered()) + .set(IS_DRAFT, mailboxMessage.isDraft()) + .set(IS_FLAGGED, mailboxMessage.isFlagged()) + .set(IS_RECENT, mailboxMessage.isRecent()) + .set(IS_SEEN, mailboxMessage.isSeen()) + .set(USER_FLAGS, mailboxMessage.createFlags().getUserFlags()) + .set(SAVE_DATE, mailboxMessage.getSaveDate().map(DATE_TO_LOCAL_DATE_TIME).orElse(null)))); + } + +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAOUtils.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAOUtils.java new file mode 100644 index 00000000000..1d832e20c52 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAOUtils.java @@ -0,0 +1,186 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail.dao; + +import static org.apache.james.backends.postgres.PostgresCommons.LOCAL_DATE_TIME_DATE_FUNCTION; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.BODY_START_OCTET; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_DISPOSITION_PARAMETERS; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_ID; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_LANGUAGE; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_LOCATION; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_MD5; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_TRANSFER_ENCODING; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_TYPE_PARAMETERS; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.HEADER_CONTENT; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.INTERNAL_DATE; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.SIZE; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.IS_ANSWERED; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.IS_DELETED; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.IS_DRAFT; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.IS_FLAGGED; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.IS_RECENT; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.IS_SEEN; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.MAILBOX_ID; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.MESSAGE_ID; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.MESSAGE_UID; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.MOD_SEQ; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.SAVE_DATE; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.THREAD_ID; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.USER_FLAGS; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +import javax.mail.Flags; + +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.model.ComposedMessageId; +import org.apache.james.mailbox.model.ComposedMessageIdWithMetaData; +import org.apache.james.mailbox.model.Content; +import org.apache.james.mailbox.model.MessageMetaData; +import org.apache.james.mailbox.model.ThreadId; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.mailbox.postgres.mail.PostgresMessageModule; +import org.apache.james.mailbox.store.mail.model.impl.Properties; +import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; +import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; +import org.jooq.Field; +import org.jooq.Record; + +interface PostgresMailboxMessageDAOUtils { + Map, Flags.Flag> BOOLEAN_FLAGS_MAPPING = Map.of( + IS_ANSWERED, Flags.Flag.ANSWERED, + IS_DELETED, Flags.Flag.DELETED, + IS_DRAFT, Flags.Flag.DRAFT, + IS_FLAGGED, Flags.Flag.FLAGGED, + IS_RECENT, Flags.Flag.RECENT, + IS_SEEN, Flags.Flag.SEEN); + Function RECORD_TO_MESSAGE_UID_FUNCTION = record -> MessageUid.of(record.get(MESSAGE_UID)); + Function RECORD_TO_FLAGS_FUNCTION = record -> { + Flags flags = new Flags(); + BOOLEAN_FLAGS_MAPPING.forEach((flagColumn, flagMapped) -> { + if (record.get(flagColumn)) { + flags.add(flagMapped); + } + }); + + Optional.ofNullable(record.get(USER_FLAGS)).stream() + .flatMap(Arrays::stream) + .forEach(flags::add); + return flags; + }; + + Function RECORD_TO_THREAD_ID_FUNCTION = record -> Optional.ofNullable(record.get(THREAD_ID)) + .map(threadIdAsUuid -> ThreadId.fromBaseMessageId(PostgresMessageId.Factory.of(threadIdAsUuid))) + .orElse(ThreadId.fromBaseMessageId(PostgresMessageId.Factory.of(record.get(MESSAGE_ID)))); + + + Field[] MESSAGE_METADATA_FIELDS_REQUIRE = new Field[] { + MESSAGE_UID, + MOD_SEQ, + SIZE, + INTERNAL_DATE, + SAVE_DATE, + MESSAGE_ID, + THREAD_ID, + IS_ANSWERED, + IS_DELETED, + IS_DRAFT, + IS_FLAGGED, + IS_RECENT, + IS_SEEN, + USER_FLAGS + }; + + Function RECORD_TO_MESSAGE_METADATA_FUNCTION = record -> + new MessageMetaData(MessageUid.of(record.get(MESSAGE_UID)), + ModSeq.of(record.get(MOD_SEQ)), + RECORD_TO_FLAGS_FUNCTION.apply(record), + record.get(SIZE), + LOCAL_DATE_TIME_DATE_FUNCTION.apply(record.get(INTERNAL_DATE)), + Optional.ofNullable(record.get(SAVE_DATE)).map(LOCAL_DATE_TIME_DATE_FUNCTION), + PostgresMessageId.Factory.of(record.get(MESSAGE_ID)), + RECORD_TO_THREAD_ID_FUNCTION.apply(record)); + + Function RECORD_TO_COMPOSED_MESSAGE_ID_WITH_META_DATA_FUNCTION = record -> ComposedMessageIdWithMetaData + .builder() + .composedMessageId(new ComposedMessageId(PostgresMailboxId.of(record.get(MAILBOX_ID)), + PostgresMessageId.Factory.of(record.get(MESSAGE_ID)), + MessageUid.of(record.get(MESSAGE_UID)))) + .threadId(RECORD_TO_THREAD_ID_FUNCTION.apply(record)) + .flags(RECORD_TO_FLAGS_FUNCTION.apply(record)) + .modSeq(ModSeq.of(record.get(MOD_SEQ))) + .build(); + + Function RECORD_TO_PROPERTIES_FUNCTION = record -> { + PropertyBuilder property = new PropertyBuilder(); + + property.setMediaType(record.get(PostgresMessageModule.MessageTable.MIME_TYPE)); + property.setSubType(record.get(PostgresMessageModule.MessageTable.MIME_SUBTYPE)); + property.setTextualLineCount(Optional.ofNullable(record.get(PostgresMessageModule.MessageTable.TEXTUAL_LINE_COUNT)) + .map(Long::valueOf) + .orElse(null)); + + property.setContentID(record.get(CONTENT_ID)); + property.setContentMD5(record.get(CONTENT_MD5)); + property.setContentTransferEncoding(record.get(CONTENT_TRANSFER_ENCODING)); + property.setContentLocation(record.get(CONTENT_LOCATION)); + property.setContentLanguage(Optional.ofNullable(record.get(CONTENT_LANGUAGE)).map(List::of).orElse(null)); + property.setContentDispositionParameters(record.get(CONTENT_DISPOSITION_PARAMETERS, LinkedHashMap.class)); + property.setContentTypeParameters(record.get(CONTENT_TYPE_PARAMETERS, LinkedHashMap.class)); + return property.build(); + }; + + Function BYTE_TO_CONTENT_FUNCTION = contentAsBytes -> new Content() { + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(contentAsBytes); + } + + @Override + public long size() { + return contentAsBytes.length; + } + }; + + Function RECORD_TO_MAILBOX_MESSAGE_BUILDER_FUNCTION = record -> SimpleMailboxMessage.builder() + .messageId(PostgresMessageId.Factory.of(record.get(MESSAGE_ID))) + .mailboxId(PostgresMailboxId.of(record.get(MAILBOX_ID))) + .uid(MessageUid.of(record.get(MESSAGE_UID))) + .threadId(RECORD_TO_THREAD_ID_FUNCTION.apply(record)) + .internalDate(LOCAL_DATE_TIME_DATE_FUNCTION.apply(record.get(PostgresMessageModule.MessageTable.INTERNAL_DATE, LocalDateTime.class))) + .saveDate(LOCAL_DATE_TIME_DATE_FUNCTION.apply(record.get(SAVE_DATE, LocalDateTime.class))) + .flags(RECORD_TO_FLAGS_FUNCTION.apply(record)) + .size(record.get(PostgresMessageModule.MessageTable.SIZE)) + .bodyStartOctet(record.get(BODY_START_OCTET)) + .content(BYTE_TO_CONTENT_FUNCTION.apply(record.get(HEADER_CONTENT))) + .properties(RECORD_TO_PROPERTIES_FUNCTION.apply(record)); + + +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java new file mode 100644 index 00000000000..54373077889 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java @@ -0,0 +1,91 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail.dao; + +import static org.apache.james.backends.postgres.PostgresCommons.DATE_TO_LOCAL_DATE_TIME; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.BLOB_ID; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.BODY_START_OCTET; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_DESCRIPTION; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_DISPOSITION_PARAMETERS; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_DISPOSITION_TYPE; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_ID; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_LANGUAGE; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_LOCATION; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_MD5; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_TRANSFER_ENCODING; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_TYPE_PARAMETERS; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.HEADER_CONTENT; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.INTERNAL_DATE; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.MESSAGE_ID; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.MIME_SUBTYPE; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.MIME_TYPE; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.SIZE; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.TABLE_NAME; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.TEXTUAL_LINE_COUNT; + +import java.util.Optional; + +import org.apache.commons.io.IOUtils; +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.mailbox.store.mail.model.MailboxMessage; +import org.jooq.postgres.extensions.types.Hstore; + +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +public class PostgresMessageDAO { + public static final long DEFAULT_LONG_VALUE = 0L; + private final PostgresExecutor postgresExecutor; + + public PostgresMessageDAO(PostgresExecutor postgresExecutor) { + this.postgresExecutor = postgresExecutor; + } + + public Mono insert(MailboxMessage message, String blobId) { + return Mono.fromCallable(() -> IOUtils.toByteArray(message.getHeaderContent(), message.getHeaderOctets())) + .subscribeOn(Schedulers.boundedElastic()) + .flatMap(headerContentAsByte -> postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME) + .set(MESSAGE_ID, ((PostgresMessageId) message.getMessageId()).asUuid()) + .set(BLOB_ID, blobId) + .set(MIME_TYPE, message.getMediaType()) + .set(MIME_SUBTYPE, message.getSubType()) + .set(INTERNAL_DATE, DATE_TO_LOCAL_DATE_TIME.apply(message.getInternalDate())) + .set(SIZE, message.getFullContentOctets()) + .set(BODY_START_OCTET, (int) (message.getFullContentOctets() - message.getBodyOctets())) + .set(TEXTUAL_LINE_COUNT, Optional.ofNullable(message.getTextualLineCount()).orElse(DEFAULT_LONG_VALUE).intValue()) + .set(CONTENT_DESCRIPTION, message.getProperties().getContentDescription()) + .set(CONTENT_DISPOSITION_TYPE, message.getProperties().getContentDispositionType()) + .set(CONTENT_ID, message.getProperties().getContentID()) + .set(CONTENT_MD5, message.getProperties().getContentMD5()) + .set(CONTENT_LANGUAGE, message.getProperties().getContentLanguage().toArray(new String[0])) + .set(CONTENT_LOCATION, message.getProperties().getContentLocation()) + .set(CONTENT_TRANSFER_ENCODING, message.getProperties().getContentTransferEncoding()) + .set(CONTENT_TYPE_PARAMETERS, Hstore.hstore(message.getProperties().getContentTypeParameters())) + .set(CONTENT_DISPOSITION_PARAMETERS, Hstore.hstore(message.getProperties().getContentDispositionParameters())) + .set(HEADER_CONTENT, headerContentAsByte)))); + } + + public Mono deleteByMessageId(PostgresMessageId messageId) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(TABLE_NAME) + .where(MESSAGE_ID.eq(messageId.asUuid())))); + } + +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaMessageMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaMessageMapperTest.java deleted file mode 100644 index 5041b743025..00000000000 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaMessageMapperTest.java +++ /dev/null @@ -1,156 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.mail; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.util.Optional; - -import javax.mail.Flags; - -import org.apache.james.backends.jpa.JpaTestCluster; -import org.apache.james.mailbox.FlagsBuilder; -import org.apache.james.mailbox.MessageManager; -import org.apache.james.mailbox.ModSeq; -import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.postgres.JPAMailboxFixture; -import org.apache.james.mailbox.model.UpdatedFlags; -import org.apache.james.mailbox.store.FlagsUpdateCalculator; -import org.apache.james.mailbox.store.mail.model.MapperProvider; -import org.apache.james.mailbox.store.mail.model.MessageMapperTest; -import org.apache.james.utils.UpdatableTickingClock; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -class JpaMessageMapperTest extends MessageMapperTest { - - static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); - - @Override - protected MapperProvider createMapperProvider() { - return new JPAMapperProvider(JPA_TEST_CLUSTER); - } - - @Override - protected UpdatableTickingClock updatableTickingClock() { - return null; - } - - @AfterEach - void cleanUp() { - JPA_TEST_CLUSTER.clear(JPAMailboxFixture.MAILBOX_TABLE_NAMES); - } - - @Test - @Override - public void flagsAdditionShouldReturnAnUpdatedFlagHighlightingTheAddition() throws MailboxException { - saveMessages(); - messageMapper.updateFlags(benwaInboxMailbox, message1.getUid(), new FlagsUpdateCalculator(new Flags(Flags.Flag.FLAGGED), MessageManager.FlagsUpdateMode.REPLACE)); - ModSeq modSeq = messageMapper.getHighestModSeq(benwaInboxMailbox); - - // JPA does not support MessageId - assertThat(messageMapper.updateFlags(benwaInboxMailbox, message1.getUid(), new FlagsUpdateCalculator(new Flags(Flags.Flag.SEEN), MessageManager.FlagsUpdateMode.ADD))) - .contains(UpdatedFlags.builder() - .uid(message1.getUid()) - .modSeq(modSeq.next()) - .oldFlags(new Flags(Flags.Flag.FLAGGED)) - .newFlags(new FlagsBuilder().add(Flags.Flag.SEEN, Flags.Flag.FLAGGED).build()) - .build()); - } - - @Test - @Override - public void flagsReplacementShouldReturnAnUpdatedFlagHighlightingTheReplacement() throws MailboxException { - saveMessages(); - ModSeq modSeq = messageMapper.getHighestModSeq(benwaInboxMailbox); - Optional updatedFlags = messageMapper.updateFlags(benwaInboxMailbox, message1.getUid(), - new FlagsUpdateCalculator(new Flags(Flags.Flag.FLAGGED), MessageManager.FlagsUpdateMode.REPLACE)); - - // JPA does not support MessageId - assertThat(updatedFlags) - .contains(UpdatedFlags.builder() - .uid(message1.getUid()) - .modSeq(modSeq.next()) - .oldFlags(new Flags()) - .newFlags(new Flags(Flags.Flag.FLAGGED)) - .build()); - } - - @Test - @Override - public void flagsRemovalShouldReturnAnUpdatedFlagHighlightingTheRemoval() throws MailboxException { - saveMessages(); - messageMapper.updateFlags(benwaInboxMailbox, message1.getUid(), new FlagsUpdateCalculator(new FlagsBuilder().add(Flags.Flag.FLAGGED, Flags.Flag.SEEN).build(), MessageManager.FlagsUpdateMode.REPLACE)); - ModSeq modSeq = messageMapper.getHighestModSeq(benwaInboxMailbox); - - // JPA does not support MessageId - assertThat(messageMapper.updateFlags(benwaInboxMailbox, message1.getUid(), new FlagsUpdateCalculator(new Flags(Flags.Flag.SEEN), MessageManager.FlagsUpdateMode.REMOVE))) - .contains( - UpdatedFlags.builder() - .uid(message1.getUid()) - .modSeq(modSeq.next()) - .oldFlags(new FlagsBuilder().add(Flags.Flag.SEEN, Flags.Flag.FLAGGED).build()) - .newFlags(new Flags(Flags.Flag.FLAGGED)) - .build()); - } - - @Test - @Override - public void userFlagsUpdateShouldReturnCorrectUpdatedFlags() throws MailboxException { - saveMessages(); - ModSeq modSeq = messageMapper.getHighestModSeq(benwaInboxMailbox); - - // JPA does not support MessageId - assertThat(messageMapper.updateFlags(benwaInboxMailbox, message1.getUid(), new FlagsUpdateCalculator(new Flags(USER_FLAG), MessageManager.FlagsUpdateMode.ADD))) - .contains( - UpdatedFlags.builder() - .uid(message1.getUid()) - .modSeq(modSeq.next()) - .oldFlags(new Flags()) - .newFlags(new Flags(USER_FLAG)) - .build()); - } - - @Test - @Override - public void userFlagsUpdateShouldReturnCorrectUpdatedFlagsWhenNoop() throws MailboxException { - saveMessages(); - - // JPA does not support MessageId - assertThat( - messageMapper.updateFlags(benwaInboxMailbox,message1.getUid(), - new FlagsUpdateCalculator(new Flags(USER_FLAG), MessageManager.FlagsUpdateMode.REMOVE))) - .contains( - UpdatedFlags.builder() - .uid(message1.getUid()) - .modSeq(message1.getModSeq()) - .oldFlags(new Flags()) - .newFlags(new Flags()) - .build()); - } - - @Nested - @Disabled("JPA does not support saveDate.") - class SaveDateTests { - - } -} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMapperProvider.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMapperProvider.java new file mode 100644 index 00000000000..7258ba7a19e --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMapperProvider.java @@ -0,0 +1,135 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import java.time.Instant; +import java.util.List; + +import org.apache.commons.lang3.NotImplementedException; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BlobStore; +import org.apache.james.blob.api.BucketName; +import org.apache.james.blob.api.HashBlobId; +import org.apache.james.blob.memory.MemoryBlobStoreDAO; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; +import org.apache.james.mailbox.store.mail.AttachmentMapper; +import org.apache.james.mailbox.store.mail.MailboxMapper; +import org.apache.james.mailbox.store.mail.MessageIdMapper; +import org.apache.james.mailbox.store.mail.MessageMapper; +import org.apache.james.mailbox.store.mail.model.MapperProvider; +import org.apache.james.server.blob.deduplication.DeDuplicationBlobStore; +import org.apache.james.utils.UpdatableTickingClock; + +import com.google.common.collect.ImmutableList; + +public class PostgresMapperProvider implements MapperProvider { + + private final PostgresMessageId.Factory messageIdFactory; + private final PostgresExtension postgresExtension; + private final UpdatableTickingClock updatableTickingClock; + private final BlobStore blobStore; + private final BlobId.Factory blobIdFactory; + + public PostgresMapperProvider(PostgresExtension postgresExtension) { + this.postgresExtension = postgresExtension; + this.updatableTickingClock = new UpdatableTickingClock(Instant.now()); + this.messageIdFactory = new PostgresMessageId.Factory(); + this.blobIdFactory = new HashBlobId.Factory(); + this.blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory); + } + + @Override + public List getSupportedCapabilities() { + return ImmutableList.of(Capabilities.ANNOTATION, Capabilities.MAILBOX, Capabilities.MESSAGE, Capabilities.MOVE, Capabilities.ATTACHMENT); + } + + @Override + public MailboxMapper createMailboxMapper() { + return new PostgresMailboxMapper(new PostgresMailboxDAO(postgresExtension.getPostgresExecutor())); + } + + @Override + public MessageMapper createMessageMapper() { + PostgresMailboxDAO mailboxDAO = new PostgresMailboxDAO(postgresExtension.getPostgresExecutor()); + + PostgresModSeqProvider modSeqProvider = new PostgresModSeqProvider(mailboxDAO); + PostgresUidProvider uidProvider = new PostgresUidProvider(mailboxDAO); + + return new PostgresMessageMapper( + postgresExtension.getPostgresExecutor(), + modSeqProvider, + uidProvider, + blobStore, + updatableTickingClock, + blobIdFactory); + } + + @Override + public MessageIdMapper createMessageIdMapper() { + throw new NotImplementedException("not implemented"); + } + + @Override + public AttachmentMapper createAttachmentMapper() { + throw new NotImplementedException("not implemented"); + } + + @Override + public MailboxId generateId() { + return PostgresMailboxId.generate(); + } + + @Override + public MessageUid generateMessageUid() { + throw new NotImplementedException("not implemented"); + } + + @Override + public ModSeq generateModSeq(Mailbox mailbox) { + throw new NotImplementedException("not implemented"); + } + + @Override + public ModSeq highestModSeq(Mailbox mailbox) { + throw new NotImplementedException("not implemented"); + } + + @Override + public boolean supportPartialAttachmentFetch() { + return false; + } + + @Override + public MessageId generateMessageId() { + return messageIdFactory.generate(); + } + + public UpdatableTickingClock getUpdatableTickingClock() { + return updatableTickingClock; + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperTest.java new file mode 100644 index 00000000000..55a6864e881 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperTest.java @@ -0,0 +1,46 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; +import org.apache.james.mailbox.store.mail.model.MapperProvider; +import org.apache.james.mailbox.store.mail.model.MessageMapperTest; +import org.apache.james.utils.UpdatableTickingClock; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresMessageMapperTest extends MessageMapperTest { + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); + + private PostgresMapperProvider postgresMapperProvider; + @Override + protected MapperProvider createMapperProvider() { + postgresMapperProvider = new PostgresMapperProvider(postgresExtension); + return postgresMapperProvider; + } + + @Override + protected UpdatableTickingClock updatableTickingClock() { + return postgresMapperProvider.getUpdatableTickingClock(); + } + +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaMessageMoveTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMoveTest.java similarity index 74% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaMessageMoveTest.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMoveTest.java index a8499468f1d..b9c87c578ff 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaMessageMoveTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMoveTest.java @@ -19,24 +19,19 @@ package org.apache.james.mailbox.postgres.mail; -import org.apache.james.backends.jpa.JpaTestCluster; -import org.apache.james.mailbox.postgres.JPAMailboxFixture; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; import org.apache.james.mailbox.store.mail.model.MapperProvider; import org.apache.james.mailbox.store.mail.model.MessageMoveTest; -import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.extension.RegisterExtension; -class JpaMessageMoveTest extends MessageMoveTest { - - static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); +class PostgresMessageMoveTest extends MessageMoveTest { + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); @Override protected MapperProvider createMapperProvider() { - return new JPAMapperProvider(JPA_TEST_CLUSTER); - } - - @AfterEach - void cleanUp() { - JPA_TEST_CLUSTER.clear(JPAMailboxFixture.MAILBOX_TABLE_NAMES); + return new PostgresMapperProvider(postgresExtension); } - } diff --git a/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MessageMapperTest.java b/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MessageMapperTest.java index 3d16316189f..2bd85ce574c 100644 --- a/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MessageMapperTest.java +++ b/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MessageMapperTest.java @@ -1265,7 +1265,7 @@ void getUidsShouldNotReturnUidsOfDeletedMessages() throws Exception { messageMapper.updateFlags(benwaInboxMailbox, new FlagsUpdateCalculator(new Flags(Flag.DELETED), FlagsUpdateMode.ADD), - MessageRange.range(message2.getUid(), message4.getUid())); + MessageRange.range(message2.getUid(), message4.getUid())).forEachRemaining(any -> {}); List uids = messageMapper.retrieveMessagesMarkedForDeletion(benwaInboxMailbox, MessageRange.all()); messageMapper.deleteMessages(benwaInboxMailbox, uids); From e4ec6de1e77df488c2c958519b9181e796c601b2 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 29 Nov 2023 17:05:18 +0700 Subject: [PATCH 080/341] JAMES-2586 Implement Postgres Current Quota manager --- .../quota/PostgresQuotaCurrentValueDAO.java | 2 +- .../PostgresQuotaCurrentValueDAOTest.java | 4 +- .../quota/PostgresCurrentQuotaManager.java | 127 ++++++++++++++++++ ...a => PostgresCurrentQuotaManagerTest.java} | 29 ++-- 4 files changed, 146 insertions(+), 16 deletions(-) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/PostgresCurrentQuotaManager.java rename mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/{JPACurrentQuotaManagerTest.java => PostgresCurrentQuotaManagerTest.java} (64%) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java index 70b471a117e..a3a539b1cb8 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java @@ -74,7 +74,7 @@ public Mono decrease(QuotaCurrentValue.Key quotaKey, long amount) { .set(IDENTIFIER, quotaKey.getIdentifier()) .set(COMPONENT, quotaKey.getQuotaComponent().getValue()) .set(TYPE, quotaKey.getQuotaType().getValue()) - .set(CURRENT_VALUE, 0L) + .set(CURRENT_VALUE, -amount) .onConflictOnConstraint(PRIMARY_KEY_CONSTRAINT_NAME) .doUpdate() .set(CURRENT_VALUE, CURRENT_VALUE.minus(amount)))) diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAOTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAOTest.java index 0164d3bab62..b8d782fe371 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAOTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAOTest.java @@ -99,11 +99,11 @@ void decreaseQuotaCurrentValueDownToNegativeShouldAllowNegativeValue() { } @Test - void decreaseQuotaCurrentValueWhenNoRecordYetShouldNotFailAndSetValueToZero() { + void decreaseQuotaCurrentValueWhenNoRecordYetShouldNotFail() { postgresQuotaCurrentValueDAO.decrease(QUOTA_KEY, 1000L).block(); assertThat(postgresQuotaCurrentValueDAO.getQuotaCurrentValue(QUOTA_KEY).block().getCurrentValue()) - .isZero(); + .isEqualTo(-1000L); } @Test diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/PostgresCurrentQuotaManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/PostgresCurrentQuotaManager.java new file mode 100644 index 00000000000..6e7a2ee33e7 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/PostgresCurrentQuotaManager.java @@ -0,0 +1,127 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.quota; + +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; + +import org.apache.james.backends.postgres.quota.PostgresQuotaCurrentValueDAO; +import org.apache.james.core.quota.QuotaComponent; +import org.apache.james.core.quota.QuotaCountUsage; +import org.apache.james.core.quota.QuotaCurrentValue; +import org.apache.james.core.quota.QuotaSizeUsage; +import org.apache.james.core.quota.QuotaType; +import org.apache.james.mailbox.model.CurrentQuotas; +import org.apache.james.mailbox.model.QuotaOperation; +import org.apache.james.mailbox.model.QuotaRoot; +import org.apache.james.mailbox.quota.CurrentQuotaManager; + +import reactor.core.publisher.Mono; + +public class PostgresCurrentQuotaManager implements CurrentQuotaManager { + + private final PostgresQuotaCurrentValueDAO currentValueDao; + + public PostgresCurrentQuotaManager(PostgresQuotaCurrentValueDAO currentValueDao) { + this.currentValueDao = currentValueDao; + } + + @Override + public Mono getCurrentMessageCount(QuotaRoot quotaRoot) { + return currentValueDao.getQuotaCurrentValue(asQuotaKeyCount(quotaRoot)) + .map(QuotaCurrentValue::getCurrentValue) + .map(QuotaCountUsage::count) + .defaultIfEmpty(QuotaCountUsage.count(0L)); + } + + @Override + public Mono getCurrentStorage(QuotaRoot quotaRoot) { + return currentValueDao.getQuotaCurrentValue(asQuotaKeySize(quotaRoot)) + .map(QuotaCurrentValue::getCurrentValue) + .map(QuotaSizeUsage::size) + .defaultIfEmpty(QuotaSizeUsage.size(0L)); + } + + @Override + public Mono getCurrentQuotas(QuotaRoot quotaRoot) { + return currentValueDao.getQuotaCurrentValues(QuotaComponent.MAILBOX, quotaRoot.asString()) + .collectList() + .map(this::buildCurrentQuotas); + } + + @Override + public Mono increase(QuotaOperation quotaOperation) { + return currentValueDao.increase(asQuotaKeyCount(quotaOperation.quotaRoot()), quotaOperation.count().asLong()) + .then(currentValueDao.increase(asQuotaKeySize(quotaOperation.quotaRoot()), quotaOperation.size().asLong())); + } + + @Override + public Mono decrease(QuotaOperation quotaOperation) { + return currentValueDao.decrease(asQuotaKeyCount(quotaOperation.quotaRoot()), quotaOperation.count().asLong()) + .then(currentValueDao.decrease(asQuotaKeySize(quotaOperation.quotaRoot()), quotaOperation.size().asLong())); + } + + @Override + public Mono setCurrentQuotas(QuotaOperation quotaOperation) { + return getCurrentQuotas(quotaOperation.quotaRoot()) + .filter(Predicate.not(Predicate.isEqual(CurrentQuotas.from(quotaOperation)))) + .flatMap(storedQuotas -> { + long count = quotaOperation.count().asLong() - storedQuotas.count().asLong(); + long size = quotaOperation.size().asLong() - storedQuotas.size().asLong(); + + return currentValueDao.increase(asQuotaKeyCount(quotaOperation.quotaRoot()), count) + .then(currentValueDao.increase(asQuotaKeySize(quotaOperation.quotaRoot()), size)); + }); + } + + private QuotaCurrentValue.Key asQuotaKeyCount(QuotaRoot quotaRoot) { + return asQuotaKey(quotaRoot, QuotaType.COUNT); + } + + private QuotaCurrentValue.Key asQuotaKeySize(QuotaRoot quotaRoot) { + return asQuotaKey(quotaRoot, QuotaType.SIZE); + } + + private QuotaCurrentValue.Key asQuotaKey(QuotaRoot quotaRoot, QuotaType quotaType) { + return QuotaCurrentValue.Key.of( + QuotaComponent.MAILBOX, + quotaRoot.asString(), + quotaType); + } + + private CurrentQuotas buildCurrentQuotas(List quotaCurrentValues) { + QuotaCountUsage count = extractQuotaByType(quotaCurrentValues, QuotaType.COUNT) + .map(value -> QuotaCountUsage.count(value.getCurrentValue())) + .orElse(QuotaCountUsage.count(0L)); + + QuotaSizeUsage size = extractQuotaByType(quotaCurrentValues, QuotaType.SIZE) + .map(value -> QuotaSizeUsage.size(value.getCurrentValue())) + .orElse(QuotaSizeUsage.size(0L)); + + return new CurrentQuotas(count, size); + } + + private Optional extractQuotaByType(List quotaCurrentValues, QuotaType quotaType) { + return quotaCurrentValues.stream() + .filter(quotaValue -> quotaValue.getQuotaType().equals(quotaType)) + .findAny(); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/JPACurrentQuotaManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/PostgresCurrentQuotaManagerTest.java similarity index 64% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/JPACurrentQuotaManagerTest.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/PostgresCurrentQuotaManagerTest.java index b4011bb6498..4e725af7d58 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/JPACurrentQuotaManagerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/PostgresCurrentQuotaManagerTest.java @@ -19,25 +19,28 @@ package org.apache.james.mailbox.postgres.quota; -import org.apache.james.backends.jpa.JpaTestCluster; -import org.apache.james.mailbox.postgres.JPAMailboxFixture; -import org.apache.james.mailbox.postgres.quota.JpaCurrentQuotaManager; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.quota.PostgresQuotaCurrentValueDAO; +import org.apache.james.backends.postgres.quota.PostgresQuotaModule; import org.apache.james.mailbox.quota.CurrentQuotaManager; import org.apache.james.mailbox.store.quota.CurrentQuotaManagerContract; -import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; -class JPACurrentQuotaManagerTest implements CurrentQuotaManagerContract { +class PostgresCurrentQuotaManagerTest implements CurrentQuotaManagerContract { - static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.QUOTA_PERSISTANCE_CLASSES); + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresQuotaModule.MODULE); - @Override - public CurrentQuotaManager testee() { - return new JpaCurrentQuotaManager(JPA_TEST_CLUSTER.getEntityManagerFactory()); - } + private PostgresCurrentQuotaManager currentQuotaManager; - @AfterEach - void tearDown() { - JPA_TEST_CLUSTER.clear(JPAMailboxFixture.QUOTA_TABLES_NAMES); + @BeforeEach + void setup() { + currentQuotaManager = new PostgresCurrentQuotaManager(new PostgresQuotaCurrentValueDAO(postgresExtension.getPostgresExecutor())); } + @Override + public CurrentQuotaManager testee() { + return currentQuotaManager; + } } From 0c9bce3449af258c005b19c6b09be1b37c0293f0 Mon Sep 17 00:00:00 2001 From: hungphan227 <45198168+hungphan227@users.noreply.github.com> Date: Fri, 1 Dec 2023 16:04:49 +0700 Subject: [PATCH 081/341] JAMES-2586 postgres mailbox annotation dao and mapper (#1822) --- .../PostgresMailboxAggregateModule.java | 3 +- .../PostgresMailboxAnnotationModule.java | 56 +++++++ .../mail/PostgresAnnotationMapper.java | 140 +++++++++++++++++ .../dao/PostgresMailboxAnnotationDAO.java | 145 ++++++++++++++++++ .../mail/PostgresAnnotationMapperTest.java | 53 +++++++ .../mail/model/AnnotationMapperTest.java | 7 + 6 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAnnotationModule.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapper.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxAnnotationDAO.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperTest.java diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAggregateModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAggregateModule.java index 9ec68fd6fd6..807adddbd4f 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAggregateModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAggregateModule.java @@ -29,5 +29,6 @@ public interface PostgresMailboxAggregateModule { PostgresModule MODULE = PostgresModule.aggregateModules( PostgresMailboxModule.MODULE, PostgresSubscriptionModule.MODULE, - PostgresMessageModule.MODULE); + PostgresMessageModule.MODULE, + PostgresMailboxAnnotationModule.MODULE); } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAnnotationModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAnnotationModule.java new file mode 100644 index 00000000000..4bfae6678bd --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAnnotationModule.java @@ -0,0 +1,56 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres; + +import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable; + +import java.util.UUID; + +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.DefaultDataType; +import org.jooq.impl.SQLDataType; +import org.jooq.postgres.extensions.bindings.HstoreBinding; +import org.jooq.postgres.extensions.types.Hstore; + +public interface PostgresMailboxAnnotationModule { + interface PostgresMailboxAnnotationTable { + Table TABLE_NAME = DSL.table("mailbox_annotations"); + + Field MAILBOX_ID = DSL.field("mailbox_id", SQLDataType.UUID.notNull()); + Field ANNOTATIONS = DSL.field("annotations", DefaultDataType.getDefaultDataType("hstore").asConvertedDataType(new HstoreBinding()).notNull()); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(MAILBOX_ID) + .column(ANNOTATIONS) + .primaryKey(MAILBOX_ID) + .constraints(DSL.constraint().foreignKey(MAILBOX_ID).references(PostgresMailboxTable.TABLE_NAME, PostgresMailboxTable.MAILBOX_ID).onDeleteCascade()))) + .supportsRowLevelSecurity(); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(PostgresMailboxAnnotationModule.PostgresMailboxAnnotationTable.TABLE) + .build(); +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapper.java new file mode 100644 index 00000000000..867f24fdf4a --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapper.java @@ -0,0 +1,140 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import java.util.List; +import java.util.Set; + +import javax.inject.Inject; + +import org.apache.james.mailbox.model.MailboxAnnotation; +import org.apache.james.mailbox.model.MailboxAnnotationKey; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxAnnotationDAO; +import org.apache.james.mailbox.store.mail.AnnotationMapper; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresAnnotationMapper implements AnnotationMapper { + private final PostgresMailboxAnnotationDAO annotationDAO; + + @Inject + public PostgresAnnotationMapper(PostgresMailboxAnnotationDAO annotationDAO) { + this.annotationDAO = annotationDAO; + } + + @Override + public List getAllAnnotations(MailboxId mailboxId) { + return getAllAnnotationsReactive(mailboxId) + .collectList() + .block(); + } + + @Override + public Flux getAllAnnotationsReactive(MailboxId mailboxId) { + return annotationDAO.getAllAnnotations((PostgresMailboxId) mailboxId); + } + + @Override + public List getAnnotationsByKeys(MailboxId mailboxId, Set keys) { + return getAnnotationsByKeysReactive(mailboxId, keys) + .collectList() + .block(); + } + + @Override + public Flux getAnnotationsByKeysReactive(MailboxId mailboxId, Set keys) { + return annotationDAO.getAnnotationsByKeys((PostgresMailboxId) mailboxId, keys); + } + + @Override + public List getAnnotationsByKeysWithOneDepth(MailboxId mailboxId, Set keys) { + return getAnnotationsByKeysWithOneDepthReactive(mailboxId, keys) + .collectList() + .block(); + } + + @Override + public Flux getAnnotationsByKeysWithOneDepthReactive(MailboxId mailboxId, Set keys) { + return Flux.fromIterable(keys).flatMap(mailboxAnnotationKey -> + annotationDAO.getAnnotationsByKeyLike((PostgresMailboxId) mailboxId, mailboxAnnotationKey) + .filter(annotation -> mailboxAnnotationKey.isParentOrIsEqual(annotation.getKey()))); + } + + @Override + public List getAnnotationsByKeysWithAllDepth(MailboxId mailboxId, Set keys) { + return getAnnotationsByKeysWithAllDepthReactive(mailboxId, keys) + .collectList() + .block(); + } + + @Override + public Flux getAnnotationsByKeysWithAllDepthReactive(MailboxId mailboxId, Set keys) { + return Flux.fromIterable(keys).flatMap(mailboxAnnotationKey -> + annotationDAO.getAnnotationsByKeyLike((PostgresMailboxId) mailboxId, mailboxAnnotationKey) + .filter(annotation -> mailboxAnnotationKey.isAncestorOrIsEqual(annotation.getKey()))); + } + + @Override + public void deleteAnnotation(MailboxId mailboxId, MailboxAnnotationKey key) { + deleteAnnotationReactive(mailboxId, key) + .block(); + } + + @Override + public Mono deleteAnnotationReactive(MailboxId mailboxId, MailboxAnnotationKey key) { + return annotationDAO.deleteAnnotation((PostgresMailboxId) mailboxId, key); + } + + @Override + public void insertAnnotation(MailboxId mailboxId, MailboxAnnotation mailboxAnnotation) { + insertAnnotationReactive(mailboxId, mailboxAnnotation) + .block(); + } + + @Override + public Mono insertAnnotationReactive(MailboxId mailboxId, MailboxAnnotation mailboxAnnotation) { + return annotationDAO.insertAnnotation((PostgresMailboxId) mailboxId, mailboxAnnotation); + } + + @Override + public boolean exist(MailboxId mailboxId, MailboxAnnotation mailboxAnnotation) { + return existReactive(mailboxId, mailboxAnnotation) + .block(); + } + + @Override + public Mono existReactive(MailboxId mailboxId, MailboxAnnotation mailboxAnnotation) { + return annotationDAO.exist((PostgresMailboxId) mailboxId, mailboxAnnotation.getKey()); + } + + @Override + public int countAnnotations(MailboxId mailboxId) { + return countAnnotationsReactive(mailboxId) + .block(); + } + + @Override + public Mono countAnnotationsReactive(MailboxId mailboxId) { + return annotationDAO.countAnnotations((PostgresMailboxId) mailboxId); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxAnnotationDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxAnnotationDAO.java new file mode 100644 index 00000000000..845e9cf5e51 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxAnnotationDAO.java @@ -0,0 +1,145 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail.dao; + +import static org.apache.james.mailbox.postgres.PostgresMailboxAnnotationModule.PostgresMailboxAnnotationTable.ANNOTATIONS; +import static org.apache.james.mailbox.postgres.PostgresMailboxAnnotationModule.PostgresMailboxAnnotationTable.MAILBOX_ID; +import static org.apache.james.mailbox.postgres.PostgresMailboxAnnotationModule.PostgresMailboxAnnotationTable.TABLE_NAME; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.mailbox.model.MailboxAnnotation; +import org.apache.james.mailbox.model.MailboxAnnotationKey; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.jooq.impl.DSL; +import org.jooq.impl.DefaultDataType; +import org.jooq.postgres.extensions.types.Hstore; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableMap; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresMailboxAnnotationDAO { + private static final char SQL_WILDCARD_CHAR = '%'; + private static final String ANNOTATION_KEY_FIELD_NAME = "annotation_key"; + private static final String ANNOTATION_VALUE_FIELD_NAME = "annotation_value"; + private static final String EMPTY_ANNOTATION_VALUE = null; + + private final PostgresExecutor postgresExecutor; + + public PostgresMailboxAnnotationDAO(PostgresExecutor postgresExecutor) { + this.postgresExecutor = postgresExecutor; + } + + public Flux getAllAnnotations(PostgresMailboxId mailboxId) { + return postgresExecutor.executeRows(dslContext -> + Flux.from(dslContext.selectFrom(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())))) + .singleOrEmpty() + .map(record -> record.get(ANNOTATIONS, LinkedHashMap.class)) + .flatMapIterable(this::hstoreToAnnotations); + } + + public Flux getAnnotationsByKeys(PostgresMailboxId mailboxId, Set keys) { + return postgresExecutor.executeRows(dslContext -> + Flux.from(dslContext.select(DSL.function("slice", + DefaultDataType.getDefaultDataType("hstore"), + ANNOTATIONS, + DSL.array(keys.stream().map(mailboxAnnotationKey -> DSL.val(mailboxAnnotationKey.asString())).collect(Collectors.toUnmodifiableList())))) + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())))) + .singleOrEmpty() + .map(record -> record.get(0, LinkedHashMap.class)) + .flatMapIterable(this::hstoreToAnnotations); + } + + public Mono exist(PostgresMailboxId mailboxId, MailboxAnnotationKey key) { + return postgresExecutor.executeRows(dslContext -> + Flux.from(dslContext.select(DSL.field(" exist(" + ANNOTATIONS.getName() + ",?)", key.asString())) + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())))) + .singleOrEmpty() + .map(record -> record.get(0, Boolean.class)) + .defaultIfEmpty(false); + } + + public Flux getAnnotationsByKeyLike(PostgresMailboxId mailboxId, MailboxAnnotationKey key) { + return postgresExecutor.executeRows(dslContext -> + Flux.from(dslContext.selectFrom( + dslContext.select(DSL.field("(each(annotations)).key").as(ANNOTATION_KEY_FIELD_NAME), + DSL.field("(each(annotations)).value").as(ANNOTATION_VALUE_FIELD_NAME)) + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())).asTable()) + .where(DSL.field(ANNOTATION_KEY_FIELD_NAME).like(key.asString() + SQL_WILDCARD_CHAR)))) + .map(record -> MailboxAnnotation.newInstance(new MailboxAnnotationKey(record.get(ANNOTATION_KEY_FIELD_NAME, String.class)), + record.get(ANNOTATION_VALUE_FIELD_NAME, String.class))); + } + + public Mono insertAnnotation(PostgresMailboxId mailboxId, MailboxAnnotation mailboxAnnotation) { + Preconditions.checkArgument(!mailboxAnnotation.isNil()); + + return postgresExecutor.executeVoid(dslContext -> + Mono.from(dslContext.insertInto(TABLE_NAME, MAILBOX_ID, ANNOTATIONS) + .values(mailboxId.asUuid(), annotationAsHstore(mailboxAnnotation)) + .onConflict(MAILBOX_ID) + .doUpdate() + .set(DSL.field(ANNOTATIONS.getName() + "[?]", + mailboxAnnotation.getKey().asString()), + mailboxAnnotation.getValue().orElse(EMPTY_ANNOTATION_VALUE)))); + } + + public Mono deleteAnnotation(PostgresMailboxId mailboxId, MailboxAnnotationKey key) { + return postgresExecutor.executeVoid(dslContext -> + Mono.from(dslContext.update(TABLE_NAME) + .set(DSL.field(ANNOTATIONS.getName()), + (Object) DSL.function("delete", + DefaultDataType.getDefaultDataType("hstore"), + ANNOTATIONS, + DSL.val(key.asString()))) + .where(MAILBOX_ID.eq(mailboxId.asUuid())))); + } + + public Mono countAnnotations(PostgresMailboxId mailboxId) { + return postgresExecutor.executeRows(dslContext -> + Flux.from(dslContext.select(DSL.field("array_length(akeys(" + ANNOTATIONS.getName() + "), 1)")) + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())))) + .singleOrEmpty() + .map(record -> record.get(0, Integer.class)) + .defaultIfEmpty(0); + } + + private List hstoreToAnnotations(LinkedHashMap hstore) { + return hstore.entrySet() + .stream() + .map(entry -> MailboxAnnotation.newInstance(new MailboxAnnotationKey(entry.getKey()), entry.getValue())) + .collect(Collectors.toList()); + } + + private Hstore annotationAsHstore(MailboxAnnotation mailboxAnnotation) { + return Hstore.hstore(ImmutableMap.of(mailboxAnnotation.getKey().asString(), mailboxAnnotation.getValue().get())); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperTest.java new file mode 100644 index 00000000000..0b2d75ba29e --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperTest.java @@ -0,0 +1,53 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.core.Username; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.UidValidity; +import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxAnnotationDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; +import org.apache.james.mailbox.store.mail.AnnotationMapper; +import org.apache.james.mailbox.store.mail.MailboxMapper; +import org.apache.james.mailbox.store.mail.model.AnnotationMapperTest; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresAnnotationMapperTest extends AnnotationMapperTest { + private static final UidValidity UID_VALIDITY = UidValidity.of(42); + private static final Username BENWA = Username.of("benwa"); + protected static final MailboxPath benwaInboxPath = MailboxPath.forUser(BENWA, "INBOX"); + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); + + @Override + protected AnnotationMapper createAnnotationMapper() { + return new PostgresAnnotationMapper(new PostgresMailboxAnnotationDAO(postgresExtension.getPostgresExecutor())); + } + + @Override + protected MailboxId generateMailboxId() { + MailboxMapper mailboxMapper = new PostgresMailboxMapper(new PostgresMailboxDAO(postgresExtension.getPostgresExecutor())); + return mailboxMapper.create(benwaInboxPath, UID_VALIDITY).block().getMailboxId(); + } +} diff --git a/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/AnnotationMapperTest.java b/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/AnnotationMapperTest.java index c00d6b26396..974edd0fc91 100644 --- a/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/AnnotationMapperTest.java +++ b/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/AnnotationMapperTest.java @@ -204,6 +204,13 @@ void isExistedShouldReturnFalseIfAnnotationIsNotStored() { assertThat(annotationMapper.exist(mailboxId, PRIVATE_ANNOTATION)).isFalse(); } + @Test + void isExistedShouldReturnFalseIfMailboxIdExistAndAnnotationIsNotStored() { + annotationMapper.insertAnnotation(mailboxId, PRIVATE_ANNOTATION); + + assertThat(annotationMapper.exist(mailboxId, PRIVATE_USER_ANNOTATION)).isFalse(); + } + @Test void countAnnotationShouldReturnZeroIfNoMoreAnnotationBelongToMailbox() { assertThat(annotationMapper.countAnnotations(mailboxId)).isEqualTo(0); From ca054801e104ded650985ce32f2d131ff9908558 Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Tue, 21 Nov 2023 14:22:46 +0700 Subject: [PATCH 082/341] JAMES-2586 Remove unused method in PostgresExecutor --- .../james/backends/postgres/utils/PostgresExecutor.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index 05a3556ad0d..1fa3ccb4103 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -19,7 +19,6 @@ package org.apache.james.backends.postgres.utils; -import java.util.List; import java.util.Optional; import java.util.function.Function; @@ -87,11 +86,6 @@ public Flux executeRows(Function> queryFunction .flatMapMany(queryFunction); } - public Mono> executeSingleRowList(Function>> queryFunction) { - return dslContext() - .flatMap(queryFunction); - } - public Mono executeRow(Function> queryFunction) { return dslContext() .flatMap(queryFunction); From 71f01f422ddb4c7722fda65516fe11f65b2b2d23 Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Tue, 21 Nov 2023 15:03:41 +0700 Subject: [PATCH 083/341] JAMES-2586 Implement PostgresDomainList --- .../domainlist/jpa/PostgresDomainList.java | 61 +++++++++++++++++++ .../domainlist/jpa/PostgresDomainModule.java | 27 ++++++++ .../jpa/PostgresDomainListTest.java | 29 +++++++++ 3 files changed, 117 insertions(+) create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/PostgresDomainList.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/PostgresDomainModule.java create mode 100644 server/data/data-postgres/src/test/java/org/apache/james/domainlist/jpa/PostgresDomainListTest.java diff --git a/server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/PostgresDomainList.java b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/PostgresDomainList.java new file mode 100644 index 00000000000..2135c84ed59 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/PostgresDomainList.java @@ -0,0 +1,61 @@ +package org.apache.james.domainlist.jpa; + +import static org.apache.james.domainlist.jpa.PostgresDomainModule.PostgresDomainTable.DOMAIN; +import static org.apache.james.domainlist.jpa.PostgresDomainModule.PostgresDomainTable.TABLE_NAME; + +import java.util.List; + +import javax.inject.Inject; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.core.Domain; +import org.apache.james.dnsservice.api.DNSService; +import org.apache.james.domainlist.api.DomainListException; +import org.apache.james.domainlist.lib.AbstractDomainList; +import org.jooq.exception.DataAccessException; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresDomainList extends AbstractDomainList { + private final PostgresExecutor postgresExecutor; + + @Inject + public PostgresDomainList(DNSService dnsService, PostgresExecutor postgresExecutor) { + super(dnsService); + this.postgresExecutor = postgresExecutor; + } + + @Override + public void addDomain(Domain domain) throws DomainListException { + postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME, DOMAIN) + .values(domain.asString()))) + .onErrorMap(DataAccessException.class, e -> new DomainListException(domain.name() + " already exists.")) + .block(); + + } + + @Override + protected List getDomainListInternal() throws DomainListException { + return postgresExecutor.executeRows(dsl -> Flux.from(dsl.selectFrom(TABLE_NAME))) + .map(record -> Domain.of(record.get(DOMAIN))) + .collectList() + .block(); + } + + @Override + protected boolean containsDomainInternal(Domain domain) throws DomainListException { + return postgresExecutor.executeRow(dsl -> Mono.from(dsl.selectFrom(TABLE_NAME) + .where(DOMAIN.eq(domain.asString())))) + .blockOptional() + .isPresent(); + } + + @Override + protected void doRemoveDomain(Domain domain) throws DomainListException { + postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(TABLE_NAME) + .where(DOMAIN.eq(domain.asString())))) + .onErrorMap(DataAccessException.class, e -> new DomainListException(domain.name() + " was not found")) + .block(); + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/PostgresDomainModule.java b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/PostgresDomainModule.java new file mode 100644 index 00000000000..9a99e5e7854 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/PostgresDomainModule.java @@ -0,0 +1,27 @@ +package org.apache.james.domainlist.jpa; + +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresDomainModule { + interface PostgresDomainTable { + Table TABLE_NAME = DSL.table("domains"); + + Field DOMAIN = DSL.field("domain", SQLDataType.VARCHAR.notNull()); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(DOMAIN) + .constraint(DSL.primaryKey(DOMAIN)))) + .disableRowLevelSecurity(); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(PostgresDomainTable.TABLE) + .build(); +} diff --git a/server/data/data-postgres/src/test/java/org/apache/james/domainlist/jpa/PostgresDomainListTest.java b/server/data/data-postgres/src/test/java/org/apache/james/domainlist/jpa/PostgresDomainListTest.java new file mode 100644 index 00000000000..909a1e8286f --- /dev/null +++ b/server/data/data-postgres/src/test/java/org/apache/james/domainlist/jpa/PostgresDomainListTest.java @@ -0,0 +1,29 @@ +package org.apache.james.domainlist.jpa; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.domainlist.api.DomainList; +import org.apache.james.domainlist.lib.DomainListConfiguration; +import org.apache.james.domainlist.lib.DomainListContract; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresDomainListTest implements DomainListContract { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresDomainModule.MODULE); + + PostgresDomainList domainList; + + @BeforeEach + public void setup() throws Exception { + domainList = new PostgresDomainList(getDNSServer("localhost"), postgresExtension.getPostgresExecutor()); + domainList.configure(DomainListConfiguration.builder() + .autoDetect(false) + .autoDetectIp(false) + .build()); + } + + @Override + public DomainList domainList() { + return domainList; + } +} From e954c0ee02c7c0e685c2cf35d94eed491953d70d Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Tue, 21 Nov 2023 15:30:52 +0700 Subject: [PATCH 084/341] JAMES-2586 Guice bindings and package renaming for domain postgres implementation --- .../main/resources/META-INF/persistence.xml | 1 - .../modules/data/PostgresDataModule.java | 2 +- ...ule.java => PostgresDomainListModule.java} | 19 +- server/data/data-postgres/pom.xml | 2 - .../james/domainlist/jpa/JPADomainList.java | 178 ------------------ .../domainlist/jpa/PostgresDomainList.java | 61 ------ .../domainlist/jpa/PostgresDomainModule.java | 27 --- .../james/domainlist/jpa/model/JPADomain.java | 69 ------- .../postgres/PostgresDomainList.java | 79 ++++++++ .../postgres/PostgresDomainModule.java | 46 +++++ .../jpa/PostgresDomainListTest.java | 29 --- .../PostgresDomainListTest.java} | 56 ++---- .../src/test/resources/persistence.xml | 1 - 13 files changed, 157 insertions(+), 413 deletions(-) rename server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/{JPADomainListModule.java => PostgresDomainListModule.java} (71%) delete mode 100644 server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/JPADomainList.java delete mode 100644 server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/PostgresDomainList.java delete mode 100644 server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/PostgresDomainModule.java delete mode 100644 server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/model/JPADomain.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainList.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainModule.java delete mode 100644 server/data/data-postgres/src/test/java/org/apache/james/domainlist/jpa/PostgresDomainListTest.java rename server/data/data-postgres/src/test/java/org/apache/james/domainlist/{jpa/JPADomainListTest.java => postgres/PostgresDomainListTest.java} (55%) diff --git a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml index 9573f6e5f64..165c6456cd1 100644 --- a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml +++ b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml @@ -30,7 +30,6 @@ org.apache.james.mailbox.postgres.mail.model.openjpa.JPAMailboxMessage org.apache.james.mailbox.postgres.mail.model.JPAProperty - org.apache.james.domainlist.jpa.model.JPADomain org.apache.james.mailrepository.jpa.model.JPAUrl org.apache.james.mailrepository.jpa.model.JPAMail org.apache.james.rrt.jpa.model.JPARecipientRewrite diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataModule.java index 125746063b1..39cec088895 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataModule.java @@ -27,7 +27,7 @@ public class PostgresDataModule extends AbstractModule { @Override protected void configure() { install(new CoreDataModule()); - install(new JPADomainListModule()); + install(new PostgresDomainListModule()); install(new JPARecipientRewriteTableModule()); install(new JPAMailRepositoryModule()); } diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPADomainListModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDomainListModule.java similarity index 71% rename from server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPADomainListModule.java rename to server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDomainListModule.java index 116fd4b8ace..728c1ad0513 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPADomainListModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDomainListModule.java @@ -16,29 +16,34 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ + package org.apache.james.modules.data; +import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.domainlist.api.DomainList; -import org.apache.james.domainlist.jpa.JPADomainList; import org.apache.james.domainlist.lib.DomainListConfiguration; +import org.apache.james.domainlist.postgres.PostgresDomainList; +import org.apache.james.domainlist.postgres.PostgresDomainModule; import org.apache.james.utils.InitializationOperation; import org.apache.james.utils.InitilizationOperationBuilder; import com.google.inject.AbstractModule; import com.google.inject.Scopes; +import com.google.inject.multibindings.Multibinder; import com.google.inject.multibindings.ProvidesIntoSet; -public class JPADomainListModule extends AbstractModule { +public class PostgresDomainListModule extends AbstractModule { @Override public void configure() { - bind(JPADomainList.class).in(Scopes.SINGLETON); - bind(DomainList.class).to(JPADomainList.class); + bind(PostgresDomainList.class).in(Scopes.SINGLETON); + bind(DomainList.class).to(PostgresDomainList.class); + Multibinder.newSetBinder(binder(), PostgresModule.class).addBinding().toInstance(PostgresDomainModule.MODULE); } @ProvidesIntoSet - InitializationOperation configureDomainList(DomainListConfiguration configuration, JPADomainList jpaDomainList) { + InitializationOperation configureDomainList(DomainListConfiguration configuration, PostgresDomainList postgresDomainList) { return InitilizationOperationBuilder - .forClass(JPADomainList.class) - .init(() -> jpaDomainList.configure(configuration)); + .forClass(PostgresDomainList.class) + .init(() -> postgresDomainList.configure(configuration)); } } diff --git a/server/data/data-postgres/pom.xml b/server/data/data-postgres/pom.xml index 223cf0a8027..f5a2a5226e3 100644 --- a/server/data/data-postgres/pom.xml +++ b/server/data/data-postgres/pom.xml @@ -157,7 +157,6 @@ org/apache/james/sieve/postgres/model/JPASieveScript.class, org/apache/james/rrt/jpa/model/JPARecipientRewrite.class, - org/apache/james/domainlist/jpa/model/JPADomain.class, org/apache/james/mailrepository/jpa/model/JPAUrl.class, org/apache/james/mailrepository/jpa/model/JPAMail.class true @@ -171,7 +170,6 @@ metaDataFactory jpa(Types=org.apache.james.sieve.postgres.model.JPASieveScript; org.apache.james.rrt.jpa.model.JPARecipientRewrite; - org.apache.james.domainlist.jpa.model.JPADomain; org.apache.james.mailrepository.jpa.model.JPAUrl; org.apache.james.mailrepository.jpa.model.JPAMail) diff --git a/server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/JPADomainList.java b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/JPADomainList.java deleted file mode 100644 index 1432b211b8c..00000000000 --- a/server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/JPADomainList.java +++ /dev/null @@ -1,178 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.domainlist.jpa; - -import java.util.List; - -import javax.annotation.PostConstruct; -import javax.inject.Inject; -import javax.persistence.EntityManager; -import javax.persistence.EntityManagerFactory; -import javax.persistence.EntityTransaction; -import javax.persistence.NoResultException; -import javax.persistence.PersistenceException; -import javax.persistence.PersistenceUnit; - -import org.apache.james.backends.jpa.EntityManagerUtils; -import org.apache.james.core.Domain; -import org.apache.james.dnsservice.api.DNSService; -import org.apache.james.domainlist.api.DomainListException; -import org.apache.james.domainlist.jpa.model.JPADomain; -import org.apache.james.domainlist.lib.AbstractDomainList; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.google.common.collect.ImmutableList; - -/** - * JPA implementation of the DomainList.
    - * This implementation is compatible with the JDBCDomainList, meaning same - * database schema can be reused. - */ -public class JPADomainList extends AbstractDomainList { - private static final Logger LOGGER = LoggerFactory.getLogger(JPADomainList.class); - - /** - * The entity manager to access the database. - */ - private EntityManagerFactory entityManagerFactory; - - @Inject - public JPADomainList(DNSService dns, EntityManagerFactory entityManagerFactory) { - super(dns); - this.entityManagerFactory = entityManagerFactory; - } - - /** - * Set the entity manager to use. - */ - @Inject - @PersistenceUnit(unitName = "James") - public void setEntityManagerFactory(EntityManagerFactory entityManagerFactory) { - this.entityManagerFactory = entityManagerFactory; - } - - @PostConstruct - public void init() { - EntityManagerUtils.safelyClose(createEntityManager()); - } - - @SuppressWarnings("unchecked") - @Override - protected List getDomainListInternal() throws DomainListException { - EntityManager entityManager = entityManagerFactory.createEntityManager(); - try { - List resultList = entityManager - .createNamedQuery("listDomainNames") - .getResultList(); - return resultList - .stream() - .map(Domain::of) - .collect(ImmutableList.toImmutableList()); - } catch (PersistenceException e) { - LOGGER.error("Failed to list domains", e); - throw new DomainListException("Unable to retrieve domains", e); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - @Override - protected boolean containsDomainInternal(Domain domain) throws DomainListException { - EntityManager entityManager = entityManagerFactory.createEntityManager(); - try { - return containsDomainInternal(domain, entityManager); - } catch (PersistenceException e) { - LOGGER.error("Failed to find domain", e); - throw new DomainListException("Unable to retrieve domains", e); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - @Override - public void addDomain(Domain domain) throws DomainListException { - EntityManager entityManager = entityManagerFactory.createEntityManager(); - final EntityTransaction transaction = entityManager.getTransaction(); - try { - transaction.begin(); - if (containsDomainInternal(domain, entityManager)) { - transaction.commit(); - throw new DomainListException(domain.name() + " already exists."); - } - JPADomain jpaDomain = new JPADomain(domain); - entityManager.persist(jpaDomain); - transaction.commit(); - } catch (PersistenceException e) { - LOGGER.error("Failed to save domain", e); - rollback(transaction); - throw new DomainListException("Unable to add domain " + domain.name(), e); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - @Override - public void doRemoveDomain(Domain domain) throws DomainListException { - EntityManager entityManager = entityManagerFactory.createEntityManager(); - final EntityTransaction transaction = entityManager.getTransaction(); - try { - transaction.begin(); - if (!containsDomainInternal(domain, entityManager)) { - transaction.commit(); - throw new DomainListException(domain.name() + " was not found."); - } - entityManager.createNamedQuery("deleteDomainByName").setParameter("name", domain.asString()).executeUpdate(); - transaction.commit(); - } catch (PersistenceException e) { - LOGGER.error("Failed to remove domain", e); - rollback(transaction); - throw new DomainListException("Unable to remove domain " + domain.name(), e); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - private void rollback(EntityTransaction transaction) { - if (transaction.isActive()) { - transaction.rollback(); - } - } - - private boolean containsDomainInternal(Domain domain, EntityManager entityManager) { - try { - return entityManager.createNamedQuery("findDomainByName") - .setParameter("name", domain.asString()) - .getSingleResult() != null; - } catch (NoResultException e) { - LOGGER.debug("No domain found", e); - return false; - } - } - - /** - * Return a new {@link EntityManager} instance - * - * @return manager - */ - private EntityManager createEntityManager() { - return entityManagerFactory.createEntityManager(); - } - -} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/PostgresDomainList.java b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/PostgresDomainList.java deleted file mode 100644 index 2135c84ed59..00000000000 --- a/server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/PostgresDomainList.java +++ /dev/null @@ -1,61 +0,0 @@ -package org.apache.james.domainlist.jpa; - -import static org.apache.james.domainlist.jpa.PostgresDomainModule.PostgresDomainTable.DOMAIN; -import static org.apache.james.domainlist.jpa.PostgresDomainModule.PostgresDomainTable.TABLE_NAME; - -import java.util.List; - -import javax.inject.Inject; - -import org.apache.james.backends.postgres.utils.PostgresExecutor; -import org.apache.james.core.Domain; -import org.apache.james.dnsservice.api.DNSService; -import org.apache.james.domainlist.api.DomainListException; -import org.apache.james.domainlist.lib.AbstractDomainList; -import org.jooq.exception.DataAccessException; - -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - -public class PostgresDomainList extends AbstractDomainList { - private final PostgresExecutor postgresExecutor; - - @Inject - public PostgresDomainList(DNSService dnsService, PostgresExecutor postgresExecutor) { - super(dnsService); - this.postgresExecutor = postgresExecutor; - } - - @Override - public void addDomain(Domain domain) throws DomainListException { - postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME, DOMAIN) - .values(domain.asString()))) - .onErrorMap(DataAccessException.class, e -> new DomainListException(domain.name() + " already exists.")) - .block(); - - } - - @Override - protected List getDomainListInternal() throws DomainListException { - return postgresExecutor.executeRows(dsl -> Flux.from(dsl.selectFrom(TABLE_NAME))) - .map(record -> Domain.of(record.get(DOMAIN))) - .collectList() - .block(); - } - - @Override - protected boolean containsDomainInternal(Domain domain) throws DomainListException { - return postgresExecutor.executeRow(dsl -> Mono.from(dsl.selectFrom(TABLE_NAME) - .where(DOMAIN.eq(domain.asString())))) - .blockOptional() - .isPresent(); - } - - @Override - protected void doRemoveDomain(Domain domain) throws DomainListException { - postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(TABLE_NAME) - .where(DOMAIN.eq(domain.asString())))) - .onErrorMap(DataAccessException.class, e -> new DomainListException(domain.name() + " was not found")) - .block(); - } -} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/PostgresDomainModule.java b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/PostgresDomainModule.java deleted file mode 100644 index 9a99e5e7854..00000000000 --- a/server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/PostgresDomainModule.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.apache.james.domainlist.jpa; - -import org.apache.james.backends.postgres.PostgresModule; -import org.apache.james.backends.postgres.PostgresTable; -import org.jooq.Field; -import org.jooq.Record; -import org.jooq.Table; -import org.jooq.impl.DSL; -import org.jooq.impl.SQLDataType; - -public interface PostgresDomainModule { - interface PostgresDomainTable { - Table TABLE_NAME = DSL.table("domains"); - - Field DOMAIN = DSL.field("domain", SQLDataType.VARCHAR.notNull()); - - PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) - .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) - .column(DOMAIN) - .constraint(DSL.primaryKey(DOMAIN)))) - .disableRowLevelSecurity(); - } - - PostgresModule MODULE = PostgresModule.builder() - .addTable(PostgresDomainTable.TABLE) - .build(); -} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/model/JPADomain.java b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/model/JPADomain.java deleted file mode 100644 index 3b4367494cf..00000000000 --- a/server/data/data-postgres/src/main/java/org/apache/james/domainlist/jpa/model/JPADomain.java +++ /dev/null @@ -1,69 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.domainlist.jpa.model; - -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.Id; -import javax.persistence.NamedQueries; -import javax.persistence.NamedQuery; -import javax.persistence.Table; - -import org.apache.james.core.Domain; - -/** - * Domain class for the James Domain to be used for JPA persistence. - */ -@Entity(name = "JamesDomain") -@Table(name = "JAMES_DOMAIN") -@NamedQueries({ - @NamedQuery(name = "findDomainByName", query = "SELECT domain FROM JamesDomain domain WHERE domain.name=:name"), - @NamedQuery(name = "containsDomain", query = "SELECT COUNT(domain) FROM JamesDomain domain WHERE domain.name=:name"), - @NamedQuery(name = "listDomainNames", query = "SELECT domain.name FROM JamesDomain domain"), - @NamedQuery(name = "deleteDomainByName", query = "DELETE FROM JamesDomain domain WHERE domain.name=:name") }) -public class JPADomain { - - /** - * The name of the domain. column name is chosen to be compatible with the - * JDBCDomainList. - */ - @Id - @Column(name = "DOMAIN_NAME", nullable = false, length = 100) - private String name; - - /** - * Default no-args constructor for JPA class enhancement. - * The constructor need to be public or protected to be used by JPA. - * See: http://docs.oracle.com/javaee/6/tutorial/doc/bnbqa.html - * Do not us this constructor, it is for JPA only. - */ - protected JPADomain() { - } - - /** - * Use this simple constructor to create a new Domain. - * - * @param name - * the name of the Domain - */ - public JPADomain(Domain name) { - this.name = name.asString(); - } - -} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainList.java b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainList.java new file mode 100644 index 00000000000..6074b6babce --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainList.java @@ -0,0 +1,79 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.domainlist.postgres; + +import java.util.List; +import java.util.Optional; + +import javax.inject.Inject; + +import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.core.Domain; +import org.apache.james.dnsservice.api.DNSService; +import org.apache.james.domainlist.api.DomainListException; +import org.apache.james.domainlist.lib.AbstractDomainList; +import org.jooq.exception.DataAccessException; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresDomainList extends AbstractDomainList { + private final PostgresExecutor postgresExecutor; + + @Inject + public PostgresDomainList(DNSService dnsService, JamesPostgresConnectionFactory postgresConnectionFactory) { + super(dnsService); + this.postgresExecutor = new PostgresExecutor(postgresConnectionFactory.getConnection(Optional.empty()));; + } + + @Override + public void addDomain(Domain domain) throws DomainListException { + postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(PostgresDomainModule.PostgresDomainTable.TABLE_NAME, PostgresDomainModule.PostgresDomainTable.DOMAIN) + .values(domain.asString()))) + .onErrorMap(DataAccessException.class, e -> new DomainListException(domain.name() + " already exists.")) + .block(); + + } + + @Override + protected List getDomainListInternal() throws DomainListException { + return postgresExecutor.executeRows(dsl -> Flux.from(dsl.selectFrom(PostgresDomainModule.PostgresDomainTable.TABLE_NAME))) + .map(record -> Domain.of(record.get(PostgresDomainModule.PostgresDomainTable.DOMAIN))) + .collectList() + .block(); + } + + @Override + protected boolean containsDomainInternal(Domain domain) throws DomainListException { + return postgresExecutor.executeRow(dsl -> Mono.from(dsl.selectFrom(PostgresDomainModule.PostgresDomainTable.TABLE_NAME) + .where(PostgresDomainModule.PostgresDomainTable.DOMAIN.eq(domain.asString())))) + .blockOptional() + .isPresent(); + } + + @Override + protected void doRemoveDomain(Domain domain) throws DomainListException { + postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(PostgresDomainModule.PostgresDomainTable.TABLE_NAME) + .where(PostgresDomainModule.PostgresDomainTable.DOMAIN.eq(domain.asString())))) + .onErrorMap(DataAccessException.class, e -> new DomainListException(domain.name() + " was not found")) + .block(); + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainModule.java b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainModule.java new file mode 100644 index 00000000000..aa80839f9f7 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainModule.java @@ -0,0 +1,46 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.domainlist.postgres; + +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresDomainModule { + interface PostgresDomainTable { + Table TABLE_NAME = DSL.table("domains"); + + Field DOMAIN = DSL.field("domain", SQLDataType.VARCHAR.notNull()); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(DOMAIN) + .constraint(DSL.primaryKey(DOMAIN)))) + .disableRowLevelSecurity(); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(PostgresDomainTable.TABLE) + .build(); +} diff --git a/server/data/data-postgres/src/test/java/org/apache/james/domainlist/jpa/PostgresDomainListTest.java b/server/data/data-postgres/src/test/java/org/apache/james/domainlist/jpa/PostgresDomainListTest.java deleted file mode 100644 index 909a1e8286f..00000000000 --- a/server/data/data-postgres/src/test/java/org/apache/james/domainlist/jpa/PostgresDomainListTest.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.apache.james.domainlist.jpa; - -import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.domainlist.api.DomainList; -import org.apache.james.domainlist.lib.DomainListConfiguration; -import org.apache.james.domainlist.lib.DomainListContract; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.extension.RegisterExtension; - -public class PostgresDomainListTest implements DomainListContract { - @RegisterExtension - static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresDomainModule.MODULE); - - PostgresDomainList domainList; - - @BeforeEach - public void setup() throws Exception { - domainList = new PostgresDomainList(getDNSServer("localhost"), postgresExtension.getPostgresExecutor()); - domainList.configure(DomainListConfiguration.builder() - .autoDetect(false) - .autoDetectIp(false) - .build()); - } - - @Override - public DomainList domainList() { - return domainList; - } -} diff --git a/server/data/data-postgres/src/test/java/org/apache/james/domainlist/jpa/JPADomainListTest.java b/server/data/data-postgres/src/test/java/org/apache/james/domainlist/postgres/PostgresDomainListTest.java similarity index 55% rename from server/data/data-postgres/src/test/java/org/apache/james/domainlist/jpa/JPADomainListTest.java rename to server/data/data-postgres/src/test/java/org/apache/james/domainlist/postgres/PostgresDomainListTest.java index 2a9bb30fd36..a3b969b3388 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/domainlist/jpa/JPADomainListTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/domainlist/postgres/PostgresDomainListTest.java @@ -16,56 +16,38 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.domainlist.jpa; -import org.apache.james.backends.jpa.JpaTestCluster; -import org.apache.james.core.Domain; +package org.apache.james.domainlist.postgres; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.utils.SinglePostgresConnectionFactory; import org.apache.james.domainlist.api.DomainList; -import org.apache.james.domainlist.jpa.model.JPADomain; import org.apache.james.domainlist.lib.DomainListConfiguration; import org.apache.james.domainlist.lib.DomainListContract; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; -/** - * Test the JPA implementation of the DomainList. - */ -class JPADomainListTest implements DomainListContract { +import io.r2dbc.spi.Connection; +import reactor.core.publisher.Mono; - static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPADomain.class); +public class PostgresDomainListTest implements DomainListContract { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresDomainModule.MODULE); - JPADomainList jpaDomainList; + PostgresDomainList domainList; @BeforeEach - public void setUp() throws Exception { - jpaDomainList = createDomainList(); - } - - @AfterEach - public void tearDown() throws Exception { - DomainList domainList = createDomainList(); - for (Domain domain: domainList.getDomains()) { - try { - domainList.removeDomain(domain); - } catch (Exception e) { - // silent: exception arise where clearing auto detected domains - } - } - } - - @Override - public DomainList domainList() { - return jpaDomainList; - } - - private JPADomainList createDomainList() throws Exception { - JPADomainList jpaDomainList = new JPADomainList(getDNSServer("localhost"), - JPA_TEST_CLUSTER.getEntityManagerFactory()); - jpaDomainList.configure(DomainListConfiguration.builder() + public void setup() throws Exception { + Connection connection = Mono.from(postgresExtension.getConnectionFactory().create()).block(); + domainList = new PostgresDomainList(getDNSServer("localhost"), new SinglePostgresConnectionFactory(connection)); + domainList.configure(DomainListConfiguration.builder() .autoDetect(false) .autoDetectIp(false) .build()); + } - return jpaDomainList; + @Override + public DomainList domainList() { + return domainList; } } diff --git a/server/data/data-postgres/src/test/resources/persistence.xml b/server/data/data-postgres/src/test/resources/persistence.xml index 962146a5432..4a6b7c3c5b4 100644 --- a/server/data/data-postgres/src/test/resources/persistence.xml +++ b/server/data/data-postgres/src/test/resources/persistence.xml @@ -26,7 +26,6 @@ org.apache.openjpa.persistence.PersistenceProviderImpl osgi:service/javax.sql.DataSource/(osgi.jndi.service.name=jdbc/james) - org.apache.james.domainlist.jpa.model.JPADomain org.apache.james.rrt.jpa.model.JPARecipientRewrite org.apache.james.mailrepository.jpa.model.JPAUrl org.apache.james.mailrepository.jpa.model.JPAMail From 6865405efd6b27adedceb9eea085ee94abdcbf8b Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Tue, 21 Nov 2023 16:15:35 +0700 Subject: [PATCH 085/341] JAMES-2586 DomainList Should throw when insert duplicate or delete not found domain --- .../postgres/PostgresDomainList.java | 35 ++++++++++++------- .../postgres/PostgresDomainModule.java | 2 +- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainList.java b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainList.java index 6074b6babce..f4bfd90cee5 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainList.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainList.java @@ -19,6 +19,8 @@ package org.apache.james.domainlist.postgres; +import static org.apache.james.domainlist.postgres.PostgresDomainModule.PostgresDomainTable.DOMAIN; + import java.util.List; import java.util.Optional; @@ -46,34 +48,41 @@ public PostgresDomainList(DNSService dnsService, JamesPostgresConnectionFactory @Override public void addDomain(Domain domain) throws DomainListException { - postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(PostgresDomainModule.PostgresDomainTable.TABLE_NAME, PostgresDomainModule.PostgresDomainTable.DOMAIN) - .values(domain.asString()))) - .onErrorMap(DataAccessException.class, e -> new DomainListException(domain.name() + " already exists.")) - .block(); - + try { + postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(PostgresDomainModule.PostgresDomainTable.TABLE_NAME, DOMAIN) + .values(domain.asString()))) + .block(); + } catch (DataAccessException exception) { + throw new DomainListException(domain.name() + " already exists."); + } } @Override - protected List getDomainListInternal() throws DomainListException { + protected List getDomainListInternal() { return postgresExecutor.executeRows(dsl -> Flux.from(dsl.selectFrom(PostgresDomainModule.PostgresDomainTable.TABLE_NAME))) - .map(record -> Domain.of(record.get(PostgresDomainModule.PostgresDomainTable.DOMAIN))) + .map(record -> Domain.of(record.get(DOMAIN))) .collectList() .block(); } @Override - protected boolean containsDomainInternal(Domain domain) throws DomainListException { + protected boolean containsDomainInternal(Domain domain) { return postgresExecutor.executeRow(dsl -> Mono.from(dsl.selectFrom(PostgresDomainModule.PostgresDomainTable.TABLE_NAME) - .where(PostgresDomainModule.PostgresDomainTable.DOMAIN.eq(domain.asString())))) + .where(DOMAIN.eq(domain.asString())))) .blockOptional() .isPresent(); } @Override protected void doRemoveDomain(Domain domain) throws DomainListException { - postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(PostgresDomainModule.PostgresDomainTable.TABLE_NAME) - .where(PostgresDomainModule.PostgresDomainTable.DOMAIN.eq(domain.asString())))) - .onErrorMap(DataAccessException.class, e -> new DomainListException(domain.name() + " was not found")) - .block(); + boolean executed = postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.deleteFrom(PostgresDomainModule.PostgresDomainTable.TABLE_NAME) + .where(DOMAIN.eq(domain.asString())) + .returning(DOMAIN))) + .blockOptional() + .isPresent(); + + if (!executed) { + throw new DomainListException(domain.name() + " was not found"); + } } } diff --git a/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainModule.java b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainModule.java index aa80839f9f7..1d9fd110d06 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainModule.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainModule.java @@ -36,7 +36,7 @@ interface PostgresDomainTable { PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) .column(DOMAIN) - .constraint(DSL.primaryKey(DOMAIN)))) + .primaryKey(DOMAIN))) .disableRowLevelSecurity(); } From 13c0517867ca4e0b9d3f9ca97e7ae740b1e41762 Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Tue, 28 Nov 2023 16:12:22 +0700 Subject: [PATCH 086/341] JAMES-2586 Fix Guice bindings between PostgresDomainList and PostgresTableManager Need to initialize the postgresql db and tables before returning the default PostgresExecutor to not have the domain list configuration being played before the domains table exists. --- .../postgres/PostgresTableManager.java | 24 +++++++++++++------ .../modules/data/PostgresCommonModule.java | 23 +++++------------- .../postgres/PostgresDomainList.java | 8 +++---- .../postgres/PostgresDomainListTest.java | 7 +----- 4 files changed, 28 insertions(+), 34 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java index a7277dc414f..38e51da1b75 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java @@ -19,13 +19,10 @@ package org.apache.james.backends.postgres; -import static org.apache.james.backends.postgres.utils.PostgresExecutor.DEFAULT_INJECT; - import javax.inject.Inject; -import javax.inject.Named; +import javax.inject.Provider; import org.apache.james.backends.postgres.utils.PostgresExecutor; -import org.apache.james.lifecycle.api.Startable; import org.jooq.exception.DataAccessException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -36,19 +33,20 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -public class PostgresTableManager implements Startable { +public class PostgresTableManager implements Provider { private static final Logger LOGGER = LoggerFactory.getLogger(PostgresTableManager.class); private final PostgresExecutor postgresExecutor; private final PostgresModule module; private final boolean rowLevelSecurityEnabled; @Inject - public PostgresTableManager(@Named(DEFAULT_INJECT) PostgresExecutor postgresExecutor, + public PostgresTableManager(PostgresExecutor.Factory factory, PostgresModule module, PostgresConfiguration postgresConfiguration) { - this.postgresExecutor = postgresExecutor; + this.postgresExecutor = factory.create(); this.module = module; this.rowLevelSecurityEnabled = postgresConfiguration.rowLevelSecurityEnabled(); + initPostgres(); } @VisibleForTesting @@ -58,6 +56,13 @@ public PostgresTableManager(PostgresExecutor postgresExecutor, PostgresModule mo this.rowLevelSecurityEnabled = rowLevelSecurityEnabled; } + private void initPostgres() { + initializePostgresExtension() + .then(initializeTables()) + .then(initializeTableIndexes()) + .block(); + } + public Mono initializePostgresExtension() { return postgresExecutor.connection() .flatMapMany(connection -> connection.createStatement("CREATE EXTENSION IF NOT EXISTS hstore") @@ -131,4 +136,9 @@ private Mono handleIndexCreationException(PostgresIndex index LOGGER.error("Error while creating index {}", index.getName(), e); return Mono.error(e); } + + @Override + public PostgresExecutor get() { + return postgresExecutor; + } } diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index 30dcf74a093..82366095ae0 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -31,8 +31,6 @@ import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.backends.postgres.utils.SinglePostgresConnectionFactory; -import org.apache.james.utils.InitializationOperation; -import org.apache.james.utils.InitilizationOperationBuilder; import org.apache.james.utils.PropertiesProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -42,7 +40,6 @@ import com.google.inject.Scopes; import com.google.inject.Singleton; import com.google.inject.multibindings.Multibinder; -import com.google.inject.multibindings.ProvidesIntoSet; import com.google.inject.name.Named; import io.r2dbc.postgresql.PostgresqlConnectionConfiguration; @@ -57,6 +54,8 @@ public class PostgresCommonModule extends AbstractModule { public void configure() { Multibinder.newSetBinder(binder(), PostgresModule.class); bind(PostgresExecutor.Factory.class).in(Scopes.SINGLETON); + + bind(PostgresExecutor.class).toProvider(PostgresTableManager.class); } @Provides @@ -98,26 +97,16 @@ PostgresModule composePostgresDataDefinitions(Set modules) { @Provides @Singleton - PostgresTableManager postgresTableManager(@Named(DEFAULT_INJECT) PostgresExecutor defaultPostgresExecutor, + PostgresTableManager postgresTableManager(PostgresExecutor.Factory factory, PostgresModule postgresModule, PostgresConfiguration postgresConfiguration) { - return new PostgresTableManager(defaultPostgresExecutor, postgresModule, postgresConfiguration); + return new PostgresTableManager(factory, postgresModule, postgresConfiguration); } @Provides @Named(DEFAULT_INJECT) @Singleton - PostgresExecutor defaultPostgresExecutor(PostgresExecutor.Factory factory) { - return factory.create(); - } - - @ProvidesIntoSet - InitializationOperation provisionPostgresTablesAndIndexes(PostgresTableManager postgresTableManager) { - return InitilizationOperationBuilder - .forClass(PostgresTableManager.class) - .init(() -> postgresTableManager.initializePostgresExtension() - .then(postgresTableManager.initializeTables()) - .then(postgresTableManager.initializeTableIndexes()) - .block()); + PostgresExecutor defaultPostgresExecutor(PostgresTableManager postgresTableManager) { + return postgresTableManager.get(); } } diff --git a/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainList.java b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainList.java index f4bfd90cee5..9defb6ef2a5 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainList.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainList.java @@ -19,14 +19,14 @@ package org.apache.james.domainlist.postgres; +import static org.apache.james.backends.postgres.utils.PostgresExecutor.DEFAULT_INJECT; import static org.apache.james.domainlist.postgres.PostgresDomainModule.PostgresDomainTable.DOMAIN; import java.util.List; -import java.util.Optional; import javax.inject.Inject; +import javax.inject.Named; -import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.Domain; import org.apache.james.dnsservice.api.DNSService; @@ -41,9 +41,9 @@ public class PostgresDomainList extends AbstractDomainList { private final PostgresExecutor postgresExecutor; @Inject - public PostgresDomainList(DNSService dnsService, JamesPostgresConnectionFactory postgresConnectionFactory) { + public PostgresDomainList(DNSService dnsService, @Named(DEFAULT_INJECT) PostgresExecutor postgresExecutor) { super(dnsService); - this.postgresExecutor = new PostgresExecutor(postgresConnectionFactory.getConnection(Optional.empty()));; + this.postgresExecutor = postgresExecutor; } @Override diff --git a/server/data/data-postgres/src/test/java/org/apache/james/domainlist/postgres/PostgresDomainListTest.java b/server/data/data-postgres/src/test/java/org/apache/james/domainlist/postgres/PostgresDomainListTest.java index a3b969b3388..fc7ba810499 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/domainlist/postgres/PostgresDomainListTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/domainlist/postgres/PostgresDomainListTest.java @@ -20,16 +20,12 @@ package org.apache.james.domainlist.postgres; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.backends.postgres.utils.SinglePostgresConnectionFactory; import org.apache.james.domainlist.api.DomainList; import org.apache.james.domainlist.lib.DomainListConfiguration; import org.apache.james.domainlist.lib.DomainListContract; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.RegisterExtension; -import io.r2dbc.spi.Connection; -import reactor.core.publisher.Mono; - public class PostgresDomainListTest implements DomainListContract { @RegisterExtension static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresDomainModule.MODULE); @@ -38,8 +34,7 @@ public class PostgresDomainListTest implements DomainListContract { @BeforeEach public void setup() throws Exception { - Connection connection = Mono.from(postgresExtension.getConnectionFactory().create()).block(); - domainList = new PostgresDomainList(getDNSServer("localhost"), new SinglePostgresConnectionFactory(connection)); + domainList = new PostgresDomainList(getDNSServer("localhost"), postgresExtension.getPostgresExecutor()); domainList.configure(DomainListConfiguration.builder() .autoDetect(false) .autoDetectIp(false) From ec1a534d73eb2bb9ac3cab3b9dced18ec7c75530 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Mon, 4 Dec 2023 13:35:15 +0700 Subject: [PATCH 087/341] JAMES-2586 postgres-app should run tests against Postgresql container for both JPA and Postgres r2dbc JPA code was pointed to embedded Derby before. --- .../backends/postgres/PostgresExtension.java | 8 ++ .../james/JamesCapabilitiesServerTest.java | 6 +- .../apache/james/PostgresJamesServerTest.java | 6 +- ...uthenticatedDatabaseSqlValidationTest.java | 5 +- ...seAuthenticaticationSqlValidationTest.java | 38 ++++++++- .../PostgresWithLDAPJamesServerTest.java | 6 +- .../container/guice/postgres-common/pom.xml | 17 +++- .../james/TestJPAConfigurationModule.java | 17 ++-- ...AConfigurationModuleWithSqlValidation.java | 80 +++++++------------ 9 files changed, 115 insertions(+), 68 deletions(-) diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index 476b5819eec..126cc722b19 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -194,6 +194,14 @@ public PostgresExecutor.Factory getExecutorFactory() { return executorFactory; } + public PostgresConfiguration getPostgresConfiguration() { + return postgresConfiguration; + } + + public String getJdbcUrl() { + return String.format("jdbc:postgresql://%s:%d/%s", getHost(), getMappedPort(), postgresConfiguration.getDatabaseName()); + } + private void initTablesAndIndexes() { PostgresTableManager postgresTableManager = new PostgresTableManager(postgresExecutor, postgresModule, postgresConfiguration.rowLevelSecurityEnabled()); postgresTableManager.initializeTables().block(); diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java index 16568dc9004..652d35788b3 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java @@ -41,6 +41,8 @@ private static MailboxManager mailboxManager() { return mailboxManager; } + static PostgresExtension postgresExtension = PostgresExtension.empty(); + @RegisterExtension static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> PostgresJamesConfiguration.builder() @@ -49,9 +51,9 @@ private static MailboxManager mailboxManager() { .usersRepository(DEFAULT) .build()) .server(configuration -> PostgresJamesServerMain.createServer(configuration) - .overrideWith(new TestJPAConfigurationModule()) + .overrideWith(new TestJPAConfigurationModule(postgresExtension)) .overrideWith(binder -> binder.bind(MailboxManager.class).toInstance(mailboxManager()))) - .extension(PostgresExtension.empty()) + .extension(postgresExtension) .build(); @Test diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java index 52654ba7b60..2e03f181cde 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java @@ -41,6 +41,8 @@ import com.google.common.base.Strings; class PostgresJamesServerTest implements JamesServerConcreteContract { + static PostgresExtension postgresExtension = PostgresExtension.empty(); + @RegisterExtension static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> PostgresJamesConfiguration.builder() @@ -49,8 +51,8 @@ class PostgresJamesServerTest implements JamesServerConcreteContract { .usersRepository(DEFAULT) .build()) .server(configuration -> PostgresJamesServerMain.createServer(configuration) - .overrideWith(new TestJPAConfigurationModule())) - .extension(PostgresExtension.empty()) + .overrideWith(new TestJPAConfigurationModule(postgresExtension))) + .extension(postgresExtension) .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) .build(); diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest.java index 55fd090c497..2e0fc42cd54 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest.java @@ -25,6 +25,7 @@ import org.junit.jupiter.api.extension.RegisterExtension; class PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest extends PostgresJamesServerWithSqlValidationTest { + static PostgresExtension postgresExtension = PostgresExtension.empty(); @RegisterExtension static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> @@ -34,8 +35,8 @@ class PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest extends Post .usersRepository(DEFAULT) .build()) .server(configuration -> PostgresJamesServerMain.createServer(configuration) - .overrideWith(new TestJPAConfigurationModuleWithSqlValidation.WithDatabaseAuthentication())) - .extension(PostgresExtension.empty()) + .overrideWith(new TestJPAConfigurationModuleWithSqlValidation.WithDatabaseAuthentication(postgresExtension))) + .extension(postgresExtension) .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) .build(); } diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java index 44f9620748f..37d5491075b 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java @@ -22,9 +22,12 @@ import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; import org.apache.james.backends.postgres.PostgresExtension; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.extension.RegisterExtension; class PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest extends PostgresJamesServerWithSqlValidationTest { + static PostgresExtension postgresExtension = PostgresExtension.empty(); + @RegisterExtension static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> PostgresJamesConfiguration.builder() @@ -33,8 +36,39 @@ class PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest exten .usersRepository(DEFAULT) .build()) .server(configuration -> PostgresJamesServerMain.createServer(configuration) - .overrideWith(new TestJPAConfigurationModuleWithSqlValidation.NoDatabaseAuthentication())) - .extension(PostgresExtension.empty()) + .overrideWith(new TestJPAConfigurationModuleWithSqlValidation.NoDatabaseAuthentication(postgresExtension))) + .extension(postgresExtension) .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) .build(); + + @Override + @Disabled("PostgresExtension does not support non-authentication mode. SQL validation for JPA code with authentication should be enough.") + public void jpaGuiceServerShouldUpdateQuota(GuiceJamesServer jamesServer) { + + } + + @Override + @Disabled("PostgresExtension does not support non-authentication mode. SQL validation for JPA code with authentication should be enough.") + public void connectIMAPServerShouldSendShabangOnConnect(GuiceJamesServer jamesServer) { + } + + @Override + @Disabled("PostgresExtension does not support non-authentication mode. SQL validation for JPA code with authentication should be enough.") + public void connectOnSecondaryIMAPServerIMAPServerShouldSendShabangOnConnect(GuiceJamesServer jamesServer) { + } + + @Override + @Disabled("PostgresExtension does not support non-authentication mode. SQL validation for JPA code with authentication should be enough.") + public void connectPOP3ServerShouldSendShabangOnConnect(GuiceJamesServer jamesServer) { + } + + @Override + @Disabled("PostgresExtension does not support non-authentication mode. SQL validation for JPA code with authentication should be enough.") + public void connectSMTPServerShouldSendShabangOnConnect(GuiceJamesServer jamesServer) { + } + + @Override + @Disabled("PostgresExtension does not support non-authentication mode. SQL validation for JPA code with authentication should be enough.") + public void connectLMTPServerShouldSendShabangOnConnect(GuiceJamesServer jamesServer) { + } } diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java index 6bc0e02a95d..8f02723bf57 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java @@ -34,6 +34,8 @@ import org.junit.jupiter.api.extension.RegisterExtension; class PostgresWithLDAPJamesServerTest { + static PostgresExtension postgresExtension = PostgresExtension.empty(); + @RegisterExtension static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> PostgresJamesConfiguration.builder() @@ -42,10 +44,10 @@ class PostgresWithLDAPJamesServerTest { .usersRepository(LDAP) .build()) .server(configuration -> PostgresJamesServerMain.createServer(configuration) - .overrideWith(new TestJPAConfigurationModule())) + .overrideWith(new TestJPAConfigurationModule(postgresExtension))) .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) .extension(new LdapTestExtension()) - .extension(PostgresExtension.empty()) + .extension(postgresExtension) .build(); diff --git a/server/container/guice/postgres-common/pom.xml b/server/container/guice/postgres-common/pom.xml index dc1f2e8ad84..503c7864332 100644 --- a/server/container/guice/postgres-common/pom.xml +++ b/server/container/guice/postgres-common/pom.xml @@ -38,6 +38,12 @@ + + ${james.groupId} + apache-james-backends-postgres + test-jar + test + ${james.groupId} james-server-data-file @@ -50,6 +56,12 @@ ${james.groupId} james-server-guice-common + + ${james.groupId} + james-server-guice-common + test-jar + test + ${james.groupId} james-server-mailbox-adapter @@ -60,8 +72,9 @@ test - org.apache.derby - derby + org.postgresql + postgresql + 42.7.0 test diff --git a/server/container/guice/postgres-common/src/test/java/org/apache/james/TestJPAConfigurationModule.java b/server/container/guice/postgres-common/src/test/java/org/apache/james/TestJPAConfigurationModule.java index 957cddc27db..19ca6b61889 100644 --- a/server/container/guice/postgres-common/src/test/java/org/apache/james/TestJPAConfigurationModule.java +++ b/server/container/guice/postgres-common/src/test/java/org/apache/james/TestJPAConfigurationModule.java @@ -22,14 +22,19 @@ import javax.inject.Singleton; import org.apache.james.backends.jpa.JPAConfiguration; +import org.apache.james.backends.postgres.PostgresExtension; import com.google.inject.AbstractModule; import com.google.inject.Provides; public class TestJPAConfigurationModule extends AbstractModule { + public static final String JDBC_EMBEDDED_DRIVER = org.postgresql.Driver.class.getName(); - private static final String JDBC_EMBEDDED_URL = "jdbc:derby:memory:mailboxintegration;create=true"; - private static final String JDBC_EMBEDDED_DRIVER = org.apache.derby.jdbc.EmbeddedDriver.class.getName(); + private final PostgresExtension postgresExtension; + + public TestJPAConfigurationModule(PostgresExtension postgresExtension) { + this.postgresExtension = postgresExtension; + } @Override protected void configure() { @@ -39,8 +44,10 @@ protected void configure() { @Singleton JPAConfiguration provideConfiguration() { return JPAConfiguration.builder() - .driverName(JDBC_EMBEDDED_DRIVER) - .driverURL(JDBC_EMBEDDED_URL) - .build(); + .driverName(JDBC_EMBEDDED_DRIVER) + .driverURL(postgresExtension.getJdbcUrl()) + .username(postgresExtension.getPostgresConfiguration().getCredential().getUsername()) + .password(postgresExtension.getPostgresConfiguration().getCredential().getPassword()) + .build(); } } diff --git a/server/container/guice/postgres-common/src/test/java/org/apache/james/TestJPAConfigurationModuleWithSqlValidation.java b/server/container/guice/postgres-common/src/test/java/org/apache/james/TestJPAConfigurationModuleWithSqlValidation.java index 1cf89b519b4..dce784827bc 100644 --- a/server/container/guice/postgres-common/src/test/java/org/apache/james/TestJPAConfigurationModuleWithSqlValidation.java +++ b/server/container/guice/postgres-common/src/test/java/org/apache/james/TestJPAConfigurationModuleWithSqlValidation.java @@ -19,14 +19,12 @@ package org.apache.james; -import java.sql.CallableStatement; -import java.sql.Connection; -import java.sql.DriverManager; -import java.sql.SQLException; +import static org.apache.james.TestJPAConfigurationModule.JDBC_EMBEDDED_DRIVER; import javax.inject.Singleton; import org.apache.james.backends.jpa.JPAConfiguration; +import org.apache.james.backends.postgres.PostgresExtension; import com.google.inject.AbstractModule; import com.google.inject.Provides; @@ -34,6 +32,12 @@ public interface TestJPAConfigurationModuleWithSqlValidation { class NoDatabaseAuthentication extends AbstractModule { + private final PostgresExtension postgresExtension; + + public NoDatabaseAuthentication(PostgresExtension postgresExtension) { + this.postgresExtension = postgresExtension; + } + @Override protected void configure() { } @@ -41,68 +45,42 @@ protected void configure() { @Provides @Singleton JPAConfiguration provideConfiguration() { - return jpaConfigurationBuilder().build(); + return JPAConfiguration.builder() + .driverName(JDBC_EMBEDDED_DRIVER) + .driverURL(postgresExtension.getJdbcUrl()) + .testOnBorrow(true) + .validationQueryTimeoutSec(2) + .validationQuery(VALIDATION_SQL_QUERY) + .build(); } } class WithDatabaseAuthentication extends AbstractModule { + private final PostgresExtension postgresExtension; + + public WithDatabaseAuthentication(PostgresExtension postgresExtension) { + this.postgresExtension = postgresExtension; + } @Override protected void configure() { - setupAuthenticationOnDerby(); + } @Provides @Singleton JPAConfiguration provideConfiguration() { - return jpaConfigurationBuilder() - .username(DATABASE_USERNAME) - .password(DATABASE_PASSWORD) + return JPAConfiguration.builder() + .driverName(JDBC_EMBEDDED_DRIVER) + .driverURL(postgresExtension.getJdbcUrl()) + .testOnBorrow(true) + .validationQueryTimeoutSec(2) + .validationQuery(VALIDATION_SQL_QUERY) + .username(postgresExtension.getPostgresConfiguration().getCredential().getUsername()) + .password(postgresExtension.getPostgresConfiguration().getCredential().getPassword()) .build(); } - - private void setupAuthenticationOnDerby() { - try (Connection conn = DriverManager.getConnection(JDBC_EMBEDDED_URL, DATABASE_USERNAME, DATABASE_PASSWORD)) { - // Setting and Confirming requireAuthentication - setDerbyProperty(conn, "derby.connection.requireAuthentication", "true"); - - // Setting authentication scheme and username password to Derby - setDerbyProperty(conn, "derby.authentication.provider", "BUILTIN"); - setDerbyProperty(conn, "derby.user." + DATABASE_USERNAME + "", DATABASE_PASSWORD); - setDerbyProperty(conn, "derby.database.propertiesOnly", "true"); - - // Setting default connection mode to no access to restrict accesses without authentication information - setDerbyProperty(conn, "derby.database.defaultConnectionMode", "noAccess"); - setDerbyProperty(conn, "derby.database.fullAccessUsers", DATABASE_USERNAME); - setDerbyProperty(conn, "derby.database.propertiesOnly", "false"); - } catch (SQLException e) { - throw new RuntimeException(e); - } - } - - private void setDerbyProperty(Connection conn, String key, String value) { - try (CallableStatement call = conn.prepareCall("CALL SYSCS_UTIL.SYSCS_SET_DATABASE_PROPERTY(?, ?)")) { - call.setString(1, key); - call.setString(2, value); - call.execute(); - } catch (SQLException e) { - throw new RuntimeException(e); - } - } } - String DATABASE_USERNAME = "james"; - String DATABASE_PASSWORD = "james-secret"; - String JDBC_EMBEDDED_URL = "jdbc:derby:memory:mailboxintegration;create=true"; - String JDBC_EMBEDDED_DRIVER = org.apache.derby.jdbc.EmbeddedDriver.class.getName(); String VALIDATION_SQL_QUERY = "VALUES 1"; - - static JPAConfiguration.ReadyToBuild jpaConfigurationBuilder() { - return JPAConfiguration.builder() - .driverName(JDBC_EMBEDDED_DRIVER) - .driverURL(JDBC_EMBEDDED_URL) - .testOnBorrow(true) - .validationQueryTimeoutSec(2) - .validationQuery(VALIDATION_SQL_QUERY); - } } From 00859a0ef521da62d120e3b2b8e6d897a98c7b6b Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Thu, 30 Nov 2023 11:33:04 +0700 Subject: [PATCH 088/341] JAMES-2586 - MailboxMessage table - Remove FK key to mailbox table - Prevent the exception from the database when trying to delete the mailbox. --- .../james/mailbox/postgres/mail/PostgresMessageModule.java | 1 - 1 file changed, 1 deletion(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java index dd3cde87275..0321c34e321 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java @@ -127,7 +127,6 @@ interface MessageToMailboxTable { .column(USER_FLAGS) .column(SAVE_DATE) .constraints(DSL.primaryKey(MAILBOX_ID, MESSAGE_UID), - foreignKey(MAILBOX_ID).references(PostgresMailboxTable.TABLE_NAME, PostgresMailboxTable.MAILBOX_ID), foreignKey(MESSAGE_ID).references(MessageTable.TABLE_NAME, MessageTable.MESSAGE_ID)) .comment("Holds mailbox and flags for each message"))) .supportsRowLevelSecurity(); From fe8d45255cf25840b24bfdf61f8421f8cd09fefe Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Thu, 30 Nov 2023 11:38:01 +0700 Subject: [PATCH 089/341] JAMES-2586 - Fixup PostgresMessageMapper findMailbox method - ensuring the message was sorted --- .../james/mailbox/postgres/mail/PostgresMessageMapper.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java index 620d48df7cf..badeadbf1b4 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java @@ -26,6 +26,7 @@ import java.io.IOException; import java.io.InputStream; import java.time.Clock; +import java.util.Comparator; import java.util.Date; import java.util.Iterator; import java.util.List; @@ -157,7 +158,9 @@ public Flux findInMailboxReactive(Mailbox mailbox, MessageRange default: return Flux.error(new RuntimeException("Unknown FetchType " + fetchType)); } - }); + }) + .sort(Comparator.comparing(MailboxMessage::getUid)) + .map(message -> message); } private Mono retrieveFullContent(String blobIdString) { From 67c5095de2baf1da2c70616765471181c0c6da7a Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Thu, 30 Nov 2023 11:41:49 +0700 Subject: [PATCH 090/341] JAMES-2586 - Fixup PostgresMessageMapper updateFlags method - apply single new modSeq for all messages --- .../mailbox/postgres/mail/PostgresMessageMapper.java | 12 ++++++++++-- .../postgres/mail/dao/PostgresMailboxMessageDAO.java | 1 + 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java index badeadbf1b4..1a6787d5c4b 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java @@ -307,8 +307,16 @@ public Mono> updateFlagsReactive(Mailbox mailbox, FlagsUpdate private Flux updateFlagsPublisher(Mailbox mailbox, FlagsUpdateCalculator flagsUpdateCalculator, MessageRange range) { return mailboxMessageDAO.findMessagesMetadata((PostgresMailboxId) mailbox.getMailboxId(), range) - .flatMap(currentMetaData -> modSeqProvider.nextModSeqReactive(mailbox.getMailboxId()) - .flatMap(newModSeq -> updateFlags(currentMetaData, flagsUpdateCalculator, newModSeq))); + .collectList() + .flatMapMany(listMessagesMetadata -> updatedFlags(listMessagesMetadata, mailbox, flagsUpdateCalculator)); + } + + private Flux updatedFlags(List listMessagesMetaData, + Mailbox mailbox, + FlagsUpdateCalculator flagsUpdateCalculator) { + return modSeqProvider.nextModSeqReactive(mailbox.getMailboxId()) + .flatMapMany(newModSeq -> Flux.fromIterable(listMessagesMetaData) + .flatMap(messageMetaData -> updateFlags(messageMetaData, flagsUpdateCalculator, newModSeq))); } private Mono updateFlags(ComposedMessageIdWithMetaData currentMetaData, diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java index c55132b59f9..cbcba2943e4 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java @@ -335,6 +335,7 @@ private UpdateConditionStep buildUpdateFlagStatement(DSLContext dslConte return updateStatement.get() .set(USER_FLAGS, updatedFlags.getNewFlags().getUserFlags()) + .set(MOD_SEQ, updatedFlags.getModSeq().asLong()) .where(MAILBOX_ID.eq(mailboxId.asUuid())) .and(MESSAGE_UID.eq(uid.asLong())); } From d000b1e544f382b92dc2780aed424f4813c70d52 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Thu, 30 Nov 2023 11:42:32 +0700 Subject: [PATCH 091/341] JAMES-2586 - Fixup PostgresMailboxMessageDAO --- .../james/mailbox/postgres/mail/PostgresMessageModule.java | 1 - .../postgres/mail/dao/PostgresMailboxMessageDAOUtils.java | 7 ++++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java index 0321c34e321..6b92d9f2043 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java @@ -28,7 +28,6 @@ import org.apache.james.backends.postgres.PostgresIndex; import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.backends.postgres.PostgresTable; -import org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable; import org.jooq.Field; import org.jooq.Record; import org.jooq.Table; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAOUtils.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAOUtils.java index 1d832e20c52..f69021d3bb0 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAOUtils.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAOUtils.java @@ -21,7 +21,9 @@ import static org.apache.james.backends.postgres.PostgresCommons.LOCAL_DATE_TIME_DATE_FUNCTION; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.BODY_START_OCTET; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_DESCRIPTION; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_DISPOSITION_PARAMETERS; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_DISPOSITION_TYPE; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_ID; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_LANGUAGE; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_LOCATION; @@ -101,7 +103,7 @@ interface PostgresMailboxMessageDAOUtils { .orElse(ThreadId.fromBaseMessageId(PostgresMessageId.Factory.of(record.get(MESSAGE_ID)))); - Field[] MESSAGE_METADATA_FIELDS_REQUIRE = new Field[] { + Field[] MESSAGE_METADATA_FIELDS_REQUIRE = new Field[]{ MESSAGE_UID, MOD_SEQ, SIZE, @@ -147,6 +149,8 @@ interface PostgresMailboxMessageDAOUtils { .map(Long::valueOf) .orElse(null)); + property.setContentDescription(record.get(CONTENT_DESCRIPTION)); + property.setContentDispositionType(record.get(CONTENT_DISPOSITION_TYPE)); property.setContentID(record.get(CONTENT_ID)); property.setContentMD5(record.get(CONTENT_MD5)); property.setContentTransferEncoding(record.get(CONTENT_TRANSFER_ENCODING)); @@ -173,6 +177,7 @@ public long size() { .messageId(PostgresMessageId.Factory.of(record.get(MESSAGE_ID))) .mailboxId(PostgresMailboxId.of(record.get(MAILBOX_ID))) .uid(MessageUid.of(record.get(MESSAGE_UID))) + .modseq(ModSeq.of(record.get(MOD_SEQ))) .threadId(RECORD_TO_THREAD_ID_FUNCTION.apply(record)) .internalDate(LOCAL_DATE_TIME_DATE_FUNCTION.apply(record.get(PostgresMessageModule.MessageTable.INTERNAL_DATE, LocalDateTime.class))) .saveDate(LOCAL_DATE_TIME_DATE_FUNCTION.apply(record.get(SAVE_DATE, LocalDateTime.class))) From 8809241c8ab7bf58de79ea51809ed5d20896bcf3 Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Fri, 1 Dec 2023 11:49:35 +0100 Subject: [PATCH 092/341] JAMES-2586 - Postgres Mailbox DAO - Fix rename deadlock --- .../james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java index 22ccdf9e04e..88ac6baee40 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java @@ -103,7 +103,7 @@ public PostgresMailboxDAO(PostgresExecutor postgresExecutor) { } public Mono create(MailboxPath mailboxPath, UidValidity uidValidity) { - final PostgresMailboxId mailboxId = PostgresMailboxId.generate(); + PostgresMailboxId mailboxId = PostgresMailboxId.generate(); return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME, MAILBOX_ID, MAILBOX_NAME, USER_NAME, MAILBOX_NAMESPACE, MAILBOX_UID_VALIDITY) @@ -178,7 +178,9 @@ public Flux findMailboxWithPathLike(MailboxQuery.UserBound query) { .and(USER_NAME.eq(query.getFixedUser().asString())) .and(MAILBOX_NAMESPACE.eq(query.getFixedNamespace()))))) .map(this::asMailbox) - .filter(query::matches); + .filter(query::matches) + .collectList() + .flatMapIterable(Function.identity()); } public Mono hasChildren(Mailbox mailbox, char delimiter) { From 44de0946d50faed09048ae708de9f42a288a9bd8 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 4 Dec 2023 08:53:00 +0700 Subject: [PATCH 093/341] JAMES-2586 - Postgres MailboxAnnotation DAO - Fix null pointer --- .../mailbox/postgres/mail/dao/PostgresMailboxAnnotationDAO.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxAnnotationDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxAnnotationDAO.java index 845e9cf5e51..60d29c6d1aa 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxAnnotationDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxAnnotationDAO.java @@ -128,7 +128,7 @@ public Mono countAnnotations(PostgresMailboxId mailboxId) { .from(TABLE_NAME) .where(MAILBOX_ID.eq(mailboxId.asUuid())))) .singleOrEmpty() - .map(record -> record.get(0, Integer.class)) + .flatMap(record -> Mono.justOrEmpty(record.get(0, Integer.class))) .defaultIfEmpty(0); } From 08d0567e8eb968d8408555281331c9acfa008a52 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Thu, 30 Nov 2023 11:48:17 +0700 Subject: [PATCH 094/341] JAMES-2586 - Introduce PostgresMailboxSessionMapperFactoryTODO and using it to mpt imap test - The PostgresMailboxSessionMapperFactoryTODO was created independent of PostgresMailboxSessionMapperFactory for development. We need to remove MapperFactory, and rename MapperFactoryTODO -> MapperFactory when all dependencies already. --- mailbox/postgres/pom.xml | 10 ++ ...stgresMailboxSessionMapperFactoryTODO.java | 118 ++++++++++++++++++ .../openjpa/OpenJPAMailboxManager.java | 4 +- .../openjpa/OpenJPAMessageManager.java | 3 +- mpt/impl/imap-mailbox/postgres/pom.xml | 10 ++ .../postgres/PostgresFetchTest.java | 6 - .../PostgresMailboxAnnotationTest.java | 2 + .../postgres/host/PostgresHostSystem.java | 35 +++--- .../host/PostgresHostSystemExtension.java | 6 +- 9 files changed, 169 insertions(+), 25 deletions(-) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactoryTODO.java diff --git a/mailbox/postgres/pom.xml b/mailbox/postgres/pom.xml index 1c638412735..04887957b34 100644 --- a/mailbox/postgres/pom.xml +++ b/mailbox/postgres/pom.xml @@ -92,6 +92,16 @@ blob-memory test
    + + ${james.groupId} + blob-memory + test + + + ${james.groupId} + blob-storage-strategy + test + ${james.groupId} blob-storage-strategy diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactoryTODO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactoryTODO.java new file mode 100644 index 00000000000..e5ba0f38d1e --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactoryTODO.java @@ -0,0 +1,118 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.mailbox.postgres; + +import java.time.Clock; + +import javax.inject.Inject; + +import org.apache.commons.lang3.NotImplementedException; +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BlobStore; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.postgres.mail.PostgresAnnotationMapper; +import org.apache.james.mailbox.postgres.mail.PostgresMailboxMapper; +import org.apache.james.mailbox.postgres.mail.PostgresMessageMapper; +import org.apache.james.mailbox.postgres.mail.PostgresModSeqProvider; +import org.apache.james.mailbox.postgres.mail.PostgresUidProvider; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxAnnotationDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; +import org.apache.james.mailbox.postgres.user.PostgresSubscriptionDAO; +import org.apache.james.mailbox.postgres.user.PostgresSubscriptionMapper; +import org.apache.james.mailbox.store.MailboxSessionMapperFactory; +import org.apache.james.mailbox.store.mail.AnnotationMapper; +import org.apache.james.mailbox.store.mail.AttachmentMapper; +import org.apache.james.mailbox.store.mail.AttachmentMapperFactory; +import org.apache.james.mailbox.store.mail.MailboxMapper; +import org.apache.james.mailbox.store.mail.MessageIdMapper; +import org.apache.james.mailbox.store.mail.MessageMapper; +import org.apache.james.mailbox.store.user.SubscriptionMapper; + + +public class PostgresMailboxSessionMapperFactoryTODO extends MailboxSessionMapperFactory implements AttachmentMapperFactory { + + private final PostgresExecutor.Factory executorFactory; + private final BlobStore blobStore; + private final BlobId.Factory blobIdFactory; + private final Clock clock; + + @Inject + public PostgresMailboxSessionMapperFactoryTODO(PostgresExecutor.Factory executorFactory, + Clock clock, + BlobStore blobStore, + BlobId.Factory blobIdFactory) { + this.executorFactory = executorFactory; + this.blobStore = blobStore; + this.blobIdFactory = blobIdFactory; + this.clock = clock; + } + + @Override + public MailboxMapper createMailboxMapper(MailboxSession session) { + PostgresMailboxDAO mailboxDAO = new PostgresMailboxDAO(executorFactory.create(session.getUser().getDomainPart())); + return new PostgresMailboxMapper(mailboxDAO); + } + + @Override + public MessageMapper createMessageMapper(MailboxSession session) { + return new PostgresMessageMapper(executorFactory.create(session.getUser().getDomainPart()), + getModSeqProvider(session), + getUidProvider(session), + blobStore, + clock, + blobIdFactory); + } + + @Override + public MessageIdMapper createMessageIdMapper(MailboxSession session) { + throw new NotImplementedException("not implemented"); + } + + @Override + public SubscriptionMapper createSubscriptionMapper(MailboxSession session) { + return new PostgresSubscriptionMapper(new PostgresSubscriptionDAO(executorFactory.create(session.getUser().getDomainPart()))); + } + + @Override + public AnnotationMapper createAnnotationMapper(MailboxSession session) { + return new PostgresAnnotationMapper(new PostgresMailboxAnnotationDAO(executorFactory.create(session.getUser().getDomainPart()))); + } + + @Override + public PostgresUidProvider getUidProvider(MailboxSession session) { + return new PostgresUidProvider.Factory(executorFactory).create(session); + } + + @Override + public PostgresModSeqProvider getModSeqProvider(MailboxSession session) { + return new PostgresModSeqProvider.Factory(executorFactory).create(session); + } + + @Override + public AttachmentMapper createAttachmentMapper(MailboxSession session) { + throw new NotImplementedException("not implemented"); + } + + @Override + public AttachmentMapper getAttachmentMapper(MailboxSession session) { + throw new NotImplementedException("not implemented"); + } + +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMailboxManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMailboxManager.java index ef172d4fdff..1bcd6c14fd9 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMailboxManager.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMailboxManager.java @@ -29,9 +29,9 @@ import org.apache.james.mailbox.SessionProvider; import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MessageId; -import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; import org.apache.james.mailbox.store.JVMMailboxPathLocker; import org.apache.james.mailbox.store.MailboxManagerConfiguration; +import org.apache.james.mailbox.store.MailboxSessionMapperFactory; import org.apache.james.mailbox.store.PreDeletionHooks; import org.apache.james.mailbox.store.StoreMailboxAnnotationManager; import org.apache.james.mailbox.store.StoreMailboxManager; @@ -52,7 +52,7 @@ public class OpenJPAMailboxManager extends StoreMailboxManager { MailboxCapabilities.Annotation); @Inject - public OpenJPAMailboxManager(PostgresMailboxSessionMapperFactory mapperFactory, + public OpenJPAMailboxManager(MailboxSessionMapperFactory mapperFactory, SessionProvider sessionProvider, MessageParser messageParser, MessageId.Factory messageIdFactory, diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMessageManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMessageManager.java index a664432b653..81c2d955558 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMessageManager.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMessageManager.java @@ -36,6 +36,7 @@ import org.apache.james.mailbox.quota.QuotaRootResolver; import org.apache.james.mailbox.store.BatchSizes; import org.apache.james.mailbox.store.MailboxSessionMapperFactory; +import org.apache.james.mailbox.store.MessageFactory; import org.apache.james.mailbox.store.MessageStorer; import org.apache.james.mailbox.store.PreDeletionHooks; import org.apache.james.mailbox.store.StoreMailboxManager; @@ -66,7 +67,7 @@ public OpenJPAMessageManager(MailboxSessionMapperFactory mapperFactory, Clock clock) { super(StoreMailboxManager.DEFAULT_NO_MESSAGE_CAPABILITIES, mapperFactory, index, eventBus, locker, mailbox, quotaManager, quotaRootResolver, batchSizes, storeRightManager, PreDeletionHooks.NO_PRE_DELETION_HOOK, - new MessageStorer.WithoutAttachment(mapperFactory, messageIdFactory, new OpenJPAMessageFactory(OpenJPAMessageFactory.AdvancedFeature.None), threadIdGuessingAlgorithm, clock)); + new MessageStorer.WithoutAttachment(mapperFactory, messageIdFactory, new MessageFactory.StoreMessageFactory(), threadIdGuessingAlgorithm, clock)); this.storeRightManager = storeRightManager; this.mapperFactory = mapperFactory; this.mailbox = mailbox; diff --git a/mpt/impl/imap-mailbox/postgres/pom.xml b/mpt/impl/imap-mailbox/postgres/pom.xml index 4201111f07b..fe69267b82b 100644 --- a/mpt/impl/imap-mailbox/postgres/pom.xml +++ b/mpt/impl/imap-mailbox/postgres/pom.xml @@ -67,6 +67,16 @@ ${james.groupId} apache-james-mpt-imapmailbox-core + + ${james.groupId} + blob-memory + test + + + ${james.groupId} + blob-storage-strategy + test + ${james.groupId} event-bus-api diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchTest.java index f24b19527dd..715bfa4a4b7 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchTest.java @@ -34,10 +34,4 @@ protected ImapHostSystem createImapHostSystem() { return hostSystemExtension.getHostSystem(); } - @Override - @Test - public void testFetchSaveDate() throws Exception { - simpleScriptedTestProtocol - .run("FetchNILSaveDate"); - } } diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxAnnotationTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxAnnotationTest.java index e4c7535eb98..dce51c7c0d7 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxAnnotationTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxAnnotationTest.java @@ -22,8 +22,10 @@ import org.apache.james.mpt.api.ImapHostSystem; import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; import org.apache.james.mpt.imapmailbox.suite.MailboxAnnotation; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.extension.RegisterExtension; +@Disabled("TODO https://github.com/apache/james-project/pull/1822") public class PostgresMailboxAnnotationTest extends MailboxAnnotation { @RegisterExtension public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java index 9fc4823f9a3..eff72a7c4c7 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java @@ -19,13 +19,18 @@ package org.apache.james.mpt.imapmailbox.postgres.host; +import java.time.Clock; import java.time.Instant; import javax.persistence.EntityManagerFactory; -import org.apache.james.backends.jpa.JPAConfiguration; import org.apache.james.backends.jpa.JpaTestCluster; import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.quota.PostgresQuotaCurrentValueDAO; +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BucketName; +import org.apache.james.blob.api.HashBlobId; +import org.apache.james.blob.memory.MemoryBlobStoreDAO; import org.apache.james.core.quota.QuotaCountLimit; import org.apache.james.core.quota.QuotaSizeLimit; import org.apache.james.events.EventBusTestFixture; @@ -42,13 +47,13 @@ import org.apache.james.mailbox.acl.MailboxACLResolver; import org.apache.james.mailbox.acl.UnionMailboxACLResolver; import org.apache.james.mailbox.postgres.JPAMailboxFixture; -import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; -import org.apache.james.mailbox.postgres.mail.JPAModSeqProvider; -import org.apache.james.mailbox.postgres.mail.JPAUidProvider; +import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactoryTODO; +import org.apache.james.mailbox.postgres.PostgresMessageId; import org.apache.james.mailbox.postgres.openjpa.OpenJPAMailboxManager; import org.apache.james.mailbox.postgres.quota.JPAPerUserMaxQuotaDAO; import org.apache.james.mailbox.postgres.quota.JPAPerUserMaxQuotaManager; -import org.apache.james.mailbox.postgres.quota.JpaCurrentQuotaManager; +import org.apache.james.mailbox.postgres.quota.PostgresCurrentQuotaManager; +import org.apache.james.mailbox.quota.CurrentQuotaManager; import org.apache.james.mailbox.store.SessionProviderImpl; import org.apache.james.mailbox.store.StoreMailboxAnnotationManager; import org.apache.james.mailbox.store.StoreRightManager; @@ -56,7 +61,6 @@ import org.apache.james.mailbox.store.event.MailboxAnnotationListener; import org.apache.james.mailbox.store.extractor.DefaultTextExtractor; import org.apache.james.mailbox.store.mail.NaiveThreadIdGuessingAlgorithm; -import org.apache.james.mailbox.store.mail.model.DefaultMessageId; import org.apache.james.mailbox.store.mail.model.impl.MessageParser; import org.apache.james.mailbox.store.quota.DefaultUserQuotaRootResolver; import org.apache.james.mailbox.store.quota.ListeningCurrentQuotaUpdater; @@ -69,6 +73,7 @@ import org.apache.james.mpt.api.ImapFeatures; import org.apache.james.mpt.api.ImapFeatures.Feature; import org.apache.james.mpt.host.JamesImapHostSystem; +import org.apache.james.server.blob.deduplication.DeDuplicationBlobStore; import org.apache.james.utils.UpdatableTickingClock; import com.google.common.base.Preconditions; @@ -97,6 +102,7 @@ static PostgresHostSystem build(PostgresExtension postgresExtension) { private JPAPerUserMaxQuotaManager maxQuotaManager; private OpenJPAMailboxManager mailboxManager; private final PostgresExtension postgresExtension; + public PostgresHostSystem(PostgresExtension postgresExtension) { this.postgresExtension = postgresExtension; } @@ -109,13 +115,11 @@ public void beforeAll() { public void beforeTest() throws Exception { super.beforeTest(); EntityManagerFactory entityManagerFactory = JPA_TEST_CLUSTER.getEntityManagerFactory(); - JPAUidProvider uidProvider = new JPAUidProvider(entityManagerFactory); - JPAModSeqProvider modSeqProvider = new JPAModSeqProvider(entityManagerFactory); - JPAConfiguration jpaConfiguration = JPAConfiguration.builder() - .driverName("driverName") - .driverURL("driverUrl") - .build(); - PostgresMailboxSessionMapperFactory mapperFactory = new PostgresMailboxSessionMapperFactory(entityManagerFactory, uidProvider, modSeqProvider, jpaConfiguration, postgresExtension.getExecutorFactory()); + + BlobId.Factory blobIdFactory = new HashBlobId.Factory(); + DeDuplicationBlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory); + + PostgresMailboxSessionMapperFactoryTODO mapperFactory = new PostgresMailboxSessionMapperFactoryTODO(postgresExtension.getExecutorFactory(), Clock.systemUTC(), blobStore, blobIdFactory); MailboxACLResolver aclResolver = new UnionMailboxACLResolver(); MessageParser messageParser = new MessageParser(); @@ -126,7 +130,7 @@ public void beforeTest() throws Exception { StoreMailboxAnnotationManager annotationManager = new StoreMailboxAnnotationManager(mapperFactory, storeRightManager); SessionProviderImpl sessionProvider = new SessionProviderImpl(authenticator, authorizator); DefaultUserQuotaRootResolver quotaRootResolver = new DefaultUserQuotaRootResolver(sessionProvider, mapperFactory); - JpaCurrentQuotaManager currentQuotaManager = new JpaCurrentQuotaManager(entityManagerFactory); + CurrentQuotaManager currentQuotaManager = new PostgresCurrentQuotaManager(new PostgresQuotaCurrentValueDAO(postgresExtension.getPostgresExecutor())); maxQuotaManager = new JPAPerUserMaxQuotaManager(entityManagerFactory, new JPAPerUserMaxQuotaDAO(entityManagerFactory)); StoreQuotaManager storeQuotaManager = new StoreQuotaManager(currentQuotaManager, maxQuotaManager); ListeningCurrentQuotaUpdater quotaUpdater = new ListeningCurrentQuotaUpdater(currentQuotaManager, quotaRootResolver, eventBus, storeQuotaManager); @@ -134,7 +138,8 @@ public void beforeTest() throws Exception { AttachmentContentLoader attachmentContentLoader = null; MessageSearchIndex index = new SimpleMessageSearchIndex(mapperFactory, mapperFactory, new DefaultTextExtractor(), attachmentContentLoader); - mailboxManager = new OpenJPAMailboxManager(mapperFactory, sessionProvider, messageParser, new DefaultMessageId.Factory(), + mailboxManager = new OpenJPAMailboxManager(mapperFactory, sessionProvider, messageParser, + new PostgresMessageId.Factory(), eventBus, annotationManager, storeRightManager, quotaComponents, index, new NaiveThreadIdGuessingAlgorithm(), new UpdatableTickingClock(Instant.now())); eventBus.register(quotaUpdater); diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystemExtension.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystemExtension.java index 8ec2e1df875..c3f3f163608 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystemExtension.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystemExtension.java @@ -20,6 +20,8 @@ package org.apache.james.mpt.imapmailbox.postgres.host; import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.quota.PostgresQuotaModule; import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; import org.apache.james.mpt.host.JamesImapHostSystem; import org.junit.jupiter.api.extension.AfterAllCallback; @@ -36,7 +38,9 @@ public class PostgresHostSystemExtension implements BeforeEachCallback, AfterEac private final PostgresExtension postgresExtension; public PostgresHostSystemExtension() { - this.postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); + this.postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresModule.aggregateModules( + PostgresMailboxAggregateModule.MODULE, + PostgresQuotaModule.MODULE)); try { hostSystem = PostgresHostSystem.build(postgresExtension); } catch (Exception e) { From a965036f663b5f8fe7ca37c5bcacf971a245c2c3 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Thu, 30 Nov 2023 15:55:10 +0700 Subject: [PATCH 095/341] JAMES-2586 Guide binding Postgres Message/Mailbox mapper --- mailbox/postgres/pom.xml | 5 + .../apache/james/mailbox/postgres/JPAId.java | 84 --- .../postgres/JPATransactionalMapper.java | 96 --- .../postgres/PostgresMailboxIdFaker.java | 43 -- .../PostgresMailboxSessionMapperFactory.java | 86 ++- ...stgresMailboxSessionMapperFactoryTODO.java | 118 ---- .../postgres/mail/JPAAnnotationMapper.java | 168 ----- .../postgres/mail/JPAAttachmentMapper.java | 118 ---- .../postgres/mail/JPAMailboxMapper.java | 240 -------- .../postgres/mail/JPAMessageMapper.java | 510 --------------- .../postgres/mail/JPAModSeqProvider.java | 105 ---- .../mailbox/postgres/mail/JPAUidProvider.java | 99 --- .../PostgresMailboxManager.java} | 41 +- .../PostgresMessageManager.java} | 28 +- .../postgres/mail/dao/PostgresMailboxDAO.java | 16 +- .../postgres/mail/model/JPAAttachment.java | 193 ------ .../postgres/mail/model/JPAMailbox.java | 205 ------- .../mail/model/JPAMailboxAnnotation.java | 99 --- .../mail/model/JPAMailboxAnnotationId.java | 62 -- .../postgres/mail/model/JPAProperty.java | 129 ---- .../postgres/mail/model/JPAUserFlag.java | 120 ---- .../openjpa/AbstractJPAMailboxMessage.java | 579 ------------------ .../mail/model/openjpa/JPAMailboxMessage.java | 126 ---- .../openjpa/OpenJPAMessageFactory.java | 56 -- .../quota/JpaCurrentQuotaManager.java | 131 ---- .../quota/PostgresCurrentQuotaManager.java | 4 + .../postgres/quota/model/JpaCurrentQuota.java | 69 --- .../mailbox/postgres/JPAMailboxFixture.java | 33 +- ...va => PostgresMailboxManagerProvider.java} | 46 +- ... => PostgresMailboxManagerStressTest.java} | 21 +- ...t.java => PostgresMailboxManagerTest.java} | 44 +- .../PostgresSubscriptionManagerTest.java | 30 +- .../mail/JPAAttachmentMapperTest.java | 102 --- .../postgres/mail/JPAMapperProvider.java | 122 ---- .../mail/JpaAnnotationMapperTest.java | 52 -- .../postgres/mail/JpaMailboxMapperTest.java | 90 --- .../mail/TransactionalAnnotationMapper.java | 87 --- .../mail/TransactionalAttachmentMapper.java | 79 --- .../mail/TransactionalMailboxMapper.java | 99 --- .../mail/TransactionalMessageMapper.java | 147 ----- .../model/openjpa/JPAMailboxMessageTest.java | 57 -- ...resRecomputeCurrentQuotasServiceTest.java} | 72 +-- .../src/test/resources/persistence.xml | 10 - .../postgres/host/PostgresHostSystem.java | 12 +- .../apache/james/PostgresJamesServerMain.java | 2 - .../main/resources/META-INF/persistence.xml | 10 - .../container/guice/mailbox-postgres/pom.xml | 4 + .../modules/mailbox/JPAMailboxModule.java | 152 ----- .../mailbox/PostgresMailboxModule.java | 115 ++++ ...taModule.java => PostgresQuotaModule.java} | 8 +- 50 files changed, 289 insertions(+), 4635 deletions(-) delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/JPAId.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/JPATransactionalMapper.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxIdFaker.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactoryTODO.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAAnnotationMapper.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAAttachmentMapper.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAMailboxMapper.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAMessageMapper.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAModSeqProvider.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAUidProvider.java rename mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/{openjpa/OpenJPAMailboxManager.java => mail/PostgresMailboxManager.java} (72%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/{openjpa/OpenJPAMessageManager.java => mail/PostgresMessageManager.java} (84%) delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAAttachment.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAMailbox.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAMailboxAnnotation.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAMailboxAnnotationId.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAProperty.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAUserFlag.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/AbstractJPAMailboxMessage.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAMailboxMessage.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMessageFactory.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/JpaCurrentQuotaManager.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/JpaCurrentQuota.java rename mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/{JpaMailboxManagerProvider.java => PostgresMailboxManagerProvider.java} (73%) rename mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/{JpaMailboxManagerStressTest.java => PostgresMailboxManagerStressTest.java} (68%) rename mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/{JPAMailboxManagerTest.java => PostgresMailboxManagerTest.java} (65%) delete mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JPAAttachmentMapperTest.java delete mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JPAMapperProvider.java delete mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaAnnotationMapperTest.java delete mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaMailboxMapperTest.java delete mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalAnnotationMapper.java delete mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalAttachmentMapper.java delete mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalMailboxMapper.java delete mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalMessageMapper.java delete mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAMailboxMessageTest.java rename mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/{JPARecomputeCurrentQuotasServiceTest.java => PostgresRecomputeCurrentQuotasServiceTest.java} (62%) delete mode 100644 server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JPAMailboxModule.java rename server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/{JpaQuotaModule.java => PostgresQuotaModule.java} (91%) diff --git a/mailbox/postgres/pom.xml b/mailbox/postgres/pom.xml index 04887957b34..edc6bfac4b2 100644 --- a/mailbox/postgres/pom.xml +++ b/mailbox/postgres/pom.xml @@ -123,6 +123,11 @@ james-server-data-jpa test + + ${james.groupId} + james-server-data-postgres + test + ${james.groupId} james-server-guice-common diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/JPAId.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/JPAId.java deleted file mode 100644 index 16e20f0cff4..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/JPAId.java +++ /dev/null @@ -1,84 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.mailbox.postgres; - -import java.io.Serializable; - -import org.apache.james.mailbox.model.MailboxId; - -public class JPAId implements MailboxId, Serializable { - - public static class Factory implements MailboxId.Factory { - @Override - public JPAId fromString(String serialized) { - return of(Long.parseLong(serialized)); - } - } - - public static JPAId of(long value) { - return new JPAId(value); - } - - private final long value; - - public JPAId(long value) { - this.value = value; - } - - @Override - public String serialize() { - return String.valueOf(value); - } - - @Override - public String toString() { - return String.valueOf(value); - } - - public long getRawId() { - return value; - } - - @Override - public int hashCode() { - final int prime = 31; - int result = 1; - result = prime * result + (int) (value ^ (value >>> 32)); - return result; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null) { - return false; - } - if (getClass() != obj.getClass()) { - return false; - } - JPAId other = (JPAId) obj; - if (value != other.value) { - return false; - } - return true; - } - -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/JPATransactionalMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/JPATransactionalMapper.java deleted file mode 100644 index d39b31b742f..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/JPATransactionalMapper.java +++ /dev/null @@ -1,96 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.mailbox.postgres; - -import javax.persistence.EntityManager; -import javax.persistence.EntityManagerFactory; -import javax.persistence.EntityTransaction; -import javax.persistence.PersistenceException; - -import org.apache.james.backends.jpa.EntityManagerUtils; -import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.store.transaction.TransactionalMapper; - -/** - * JPA implementation of TransactionMapper. This class is not thread-safe! - * - */ -public abstract class JPATransactionalMapper extends TransactionalMapper { - - protected EntityManagerFactory entityManagerFactory; - protected EntityManager entityManager; - - public JPATransactionalMapper(EntityManagerFactory entityManagerFactory) { - this.entityManagerFactory = entityManagerFactory; - } - - /** - * Return the currently used {@link EntityManager} or a new one if none exists. - * - * @return entitymanger - */ - public EntityManager getEntityManager() { - if (entityManager != null) { - return entityManager; - } - entityManager = entityManagerFactory.createEntityManager(); - return entityManager; - } - - @Override - protected void begin() throws MailboxException { - try { - getEntityManager().getTransaction().begin(); - } catch (PersistenceException e) { - throw new MailboxException("Begin of transaction failed", e); - } - } - - /** - * Commit the Transaction and close the EntityManager - */ - @Override - protected void commit() throws MailboxException { - try { - getEntityManager().getTransaction().commit(); - } catch (PersistenceException e) { - throw new MailboxException("Commit of transaction failed",e); - } - } - - @Override - protected void rollback() throws MailboxException { - EntityTransaction transaction = entityManager.getTransaction(); - // check if we have a transaction to rollback - if (transaction.isActive()) { - getEntityManager().getTransaction().rollback(); - } - } - - /** - * Close open {@link EntityManager} - */ - @Override - public void endRequest() { - EntityManagerUtils.safelyClose(entityManager); - entityManager = null; - } - - -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxIdFaker.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxIdFaker.java deleted file mode 100644 index 23751b5001a..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxIdFaker.java +++ /dev/null @@ -1,43 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres; - -import java.util.UUID; - -import org.apache.james.mailbox.model.MailboxId; - -// TODO remove: this is trick convert JPAId to PostgresMailboxId when implementing PostgresUidProvider. -// it should be removed when all JPA dependencies are removed -@Deprecated -public class PostgresMailboxIdFaker { - public static PostgresMailboxId getMailboxId(MailboxId mailboxId) { - if (mailboxId instanceof JPAId) { - long longValue = ((JPAId) mailboxId).getRawId(); - return PostgresMailboxId.of(longToUUID(longValue)); - } - return (PostgresMailboxId) mailboxId; - } - - public static UUID longToUUID(Long longValue) { - long mostSigBits = longValue << 32; - long leastSigBits = 0; - return new UUID(mostSigBits, leastSigBits); - } -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java index 34f5aa17b61..0fbd9e657be 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java @@ -18,21 +18,22 @@ ****************************************************************/ package org.apache.james.mailbox.postgres; +import java.time.Clock; + import javax.inject.Inject; -import javax.persistence.EntityManager; -import javax.persistence.EntityManagerFactory; import org.apache.commons.lang3.NotImplementedException; -import org.apache.james.backends.jpa.EntityManagerUtils; -import org.apache.james.backends.jpa.JPAConfiguration; import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BlobStore; import org.apache.james.mailbox.MailboxSession; -import org.apache.james.mailbox.postgres.mail.JPAAnnotationMapper; -import org.apache.james.mailbox.postgres.mail.JPAAttachmentMapper; -import org.apache.james.mailbox.postgres.mail.JPAMailboxMapper; -import org.apache.james.mailbox.postgres.mail.JPAMessageMapper; -import org.apache.james.mailbox.postgres.mail.JPAModSeqProvider; -import org.apache.james.mailbox.postgres.mail.JPAUidProvider; +import org.apache.james.mailbox.postgres.mail.PostgresAnnotationMapper; +import org.apache.james.mailbox.postgres.mail.PostgresMailboxMapper; +import org.apache.james.mailbox.postgres.mail.PostgresMessageMapper; +import org.apache.james.mailbox.postgres.mail.PostgresModSeqProvider; +import org.apache.james.mailbox.postgres.mail.PostgresUidProvider; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxAnnotationDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; import org.apache.james.mailbox.postgres.user.PostgresSubscriptionDAO; import org.apache.james.mailbox.postgres.user.PostgresSubscriptionMapper; import org.apache.james.mailbox.store.MailboxSessionMapperFactory; @@ -42,45 +43,41 @@ import org.apache.james.mailbox.store.mail.MailboxMapper; import org.apache.james.mailbox.store.mail.MessageIdMapper; import org.apache.james.mailbox.store.mail.MessageMapper; -import org.apache.james.mailbox.store.mail.ModSeqProvider; -import org.apache.james.mailbox.store.mail.UidProvider; import org.apache.james.mailbox.store.user.SubscriptionMapper; -/** - * JPA implementation of {@link MailboxSessionMapperFactory} - * - */ -public class PostgresMailboxSessionMapperFactory extends MailboxSessionMapperFactory implements AttachmentMapperFactory { - private final EntityManagerFactory entityManagerFactory; - private final JPAUidProvider uidProvider; - private final JPAModSeqProvider modSeqProvider; - private final AttachmentMapper attachmentMapper; - private final JPAConfiguration jpaConfiguration; +public class PostgresMailboxSessionMapperFactory extends MailboxSessionMapperFactory implements AttachmentMapperFactory { private final PostgresExecutor.Factory executorFactory; + private final BlobStore blobStore; + private final BlobId.Factory blobIdFactory; + private final Clock clock; @Inject - public PostgresMailboxSessionMapperFactory(EntityManagerFactory entityManagerFactory, JPAUidProvider uidProvider, - JPAModSeqProvider modSeqProvider, JPAConfiguration jpaConfiguration, - PostgresExecutor.Factory executorFactory) { - this.entityManagerFactory = entityManagerFactory; - this.uidProvider = uidProvider; - this.modSeqProvider = modSeqProvider; - EntityManagerUtils.safelyClose(createEntityManager()); - this.attachmentMapper = new JPAAttachmentMapper(entityManagerFactory); - this.jpaConfiguration = jpaConfiguration; + public PostgresMailboxSessionMapperFactory(PostgresExecutor.Factory executorFactory, + Clock clock, + BlobStore blobStore, + BlobId.Factory blobIdFactory) { this.executorFactory = executorFactory; + this.blobStore = blobStore; + this.blobIdFactory = blobIdFactory; + this.clock = clock; } @Override public MailboxMapper createMailboxMapper(MailboxSession session) { - return new JPAMailboxMapper(entityManagerFactory); + PostgresMailboxDAO mailboxDAO = new PostgresMailboxDAO(executorFactory.create(session.getUser().getDomainPart())); + return new PostgresMailboxMapper(mailboxDAO); } @Override public MessageMapper createMessageMapper(MailboxSession session) { - return new JPAMessageMapper(uidProvider, modSeqProvider, entityManagerFactory, jpaConfiguration); + return new PostgresMessageMapper(executorFactory.create(session.getUser().getDomainPart()), + getModSeqProvider(session), + getUidProvider(session), + blobStore, + clock, + blobIdFactory); } @Override @@ -93,38 +90,29 @@ public SubscriptionMapper createSubscriptionMapper(MailboxSession session) { return new PostgresSubscriptionMapper(new PostgresSubscriptionDAO(executorFactory.create(session.getUser().getDomainPart()))); } - /** - * Return a new {@link EntityManager} instance - * - * @return manager - */ - private EntityManager createEntityManager() { - return entityManagerFactory.createEntityManager(); - } - @Override public AnnotationMapper createAnnotationMapper(MailboxSession session) { - return new JPAAnnotationMapper(entityManagerFactory); + return new PostgresAnnotationMapper(new PostgresMailboxAnnotationDAO(executorFactory.create(session.getUser().getDomainPart()))); } @Override - public UidProvider getUidProvider(MailboxSession session) { - return uidProvider; + public PostgresUidProvider getUidProvider(MailboxSession session) { + return new PostgresUidProvider.Factory(executorFactory).create(session); } @Override - public ModSeqProvider getModSeqProvider(MailboxSession session) { - return modSeqProvider; + public PostgresModSeqProvider getModSeqProvider(MailboxSession session) { + return new PostgresModSeqProvider.Factory(executorFactory).create(session); } @Override public AttachmentMapper createAttachmentMapper(MailboxSession session) { - return new JPAAttachmentMapper(entityManagerFactory); + throw new NotImplementedException("not implemented"); } @Override public AttachmentMapper getAttachmentMapper(MailboxSession session) { - return attachmentMapper; + throw new NotImplementedException("not implemented"); } } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactoryTODO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactoryTODO.java deleted file mode 100644 index e5ba0f38d1e..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactoryTODO.java +++ /dev/null @@ -1,118 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.mailbox.postgres; - -import java.time.Clock; - -import javax.inject.Inject; - -import org.apache.commons.lang3.NotImplementedException; -import org.apache.james.backends.postgres.utils.PostgresExecutor; -import org.apache.james.blob.api.BlobId; -import org.apache.james.blob.api.BlobStore; -import org.apache.james.mailbox.MailboxSession; -import org.apache.james.mailbox.postgres.mail.PostgresAnnotationMapper; -import org.apache.james.mailbox.postgres.mail.PostgresMailboxMapper; -import org.apache.james.mailbox.postgres.mail.PostgresMessageMapper; -import org.apache.james.mailbox.postgres.mail.PostgresModSeqProvider; -import org.apache.james.mailbox.postgres.mail.PostgresUidProvider; -import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxAnnotationDAO; -import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; -import org.apache.james.mailbox.postgres.user.PostgresSubscriptionDAO; -import org.apache.james.mailbox.postgres.user.PostgresSubscriptionMapper; -import org.apache.james.mailbox.store.MailboxSessionMapperFactory; -import org.apache.james.mailbox.store.mail.AnnotationMapper; -import org.apache.james.mailbox.store.mail.AttachmentMapper; -import org.apache.james.mailbox.store.mail.AttachmentMapperFactory; -import org.apache.james.mailbox.store.mail.MailboxMapper; -import org.apache.james.mailbox.store.mail.MessageIdMapper; -import org.apache.james.mailbox.store.mail.MessageMapper; -import org.apache.james.mailbox.store.user.SubscriptionMapper; - - -public class PostgresMailboxSessionMapperFactoryTODO extends MailboxSessionMapperFactory implements AttachmentMapperFactory { - - private final PostgresExecutor.Factory executorFactory; - private final BlobStore blobStore; - private final BlobId.Factory blobIdFactory; - private final Clock clock; - - @Inject - public PostgresMailboxSessionMapperFactoryTODO(PostgresExecutor.Factory executorFactory, - Clock clock, - BlobStore blobStore, - BlobId.Factory blobIdFactory) { - this.executorFactory = executorFactory; - this.blobStore = blobStore; - this.blobIdFactory = blobIdFactory; - this.clock = clock; - } - - @Override - public MailboxMapper createMailboxMapper(MailboxSession session) { - PostgresMailboxDAO mailboxDAO = new PostgresMailboxDAO(executorFactory.create(session.getUser().getDomainPart())); - return new PostgresMailboxMapper(mailboxDAO); - } - - @Override - public MessageMapper createMessageMapper(MailboxSession session) { - return new PostgresMessageMapper(executorFactory.create(session.getUser().getDomainPart()), - getModSeqProvider(session), - getUidProvider(session), - blobStore, - clock, - blobIdFactory); - } - - @Override - public MessageIdMapper createMessageIdMapper(MailboxSession session) { - throw new NotImplementedException("not implemented"); - } - - @Override - public SubscriptionMapper createSubscriptionMapper(MailboxSession session) { - return new PostgresSubscriptionMapper(new PostgresSubscriptionDAO(executorFactory.create(session.getUser().getDomainPart()))); - } - - @Override - public AnnotationMapper createAnnotationMapper(MailboxSession session) { - return new PostgresAnnotationMapper(new PostgresMailboxAnnotationDAO(executorFactory.create(session.getUser().getDomainPart()))); - } - - @Override - public PostgresUidProvider getUidProvider(MailboxSession session) { - return new PostgresUidProvider.Factory(executorFactory).create(session); - } - - @Override - public PostgresModSeqProvider getModSeqProvider(MailboxSession session) { - return new PostgresModSeqProvider.Factory(executorFactory).create(session); - } - - @Override - public AttachmentMapper createAttachmentMapper(MailboxSession session) { - throw new NotImplementedException("not implemented"); - } - - @Override - public AttachmentMapper getAttachmentMapper(MailboxSession session) { - throw new NotImplementedException("not implemented"); - } - -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAAnnotationMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAAnnotationMapper.java deleted file mode 100644 index 7009fb95cc3..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAAnnotationMapper.java +++ /dev/null @@ -1,168 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.mail; - -import java.util.List; -import java.util.Optional; -import java.util.Set; -import java.util.function.Function; -import java.util.function.Predicate; - -import javax.persistence.EntityManagerFactory; -import javax.persistence.NoResultException; -import javax.persistence.PersistenceException; - -import org.apache.james.mailbox.model.MailboxAnnotation; -import org.apache.james.mailbox.model.MailboxAnnotationKey; -import org.apache.james.mailbox.model.MailboxId; -import org.apache.james.mailbox.postgres.JPAId; -import org.apache.james.mailbox.postgres.JPATransactionalMapper; -import org.apache.james.mailbox.postgres.mail.model.JPAMailboxAnnotation; -import org.apache.james.mailbox.postgres.mail.model.JPAMailboxAnnotationId; -import org.apache.james.mailbox.store.mail.AnnotationMapper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableSet; - -public class JPAAnnotationMapper extends JPATransactionalMapper implements AnnotationMapper { - - private static final Logger LOGGER = LoggerFactory.getLogger(JPAAnnotationMapper.class); - - public static final Function READ_ROW = - input -> MailboxAnnotation.newInstance(new MailboxAnnotationKey(input.getKey()), input.getValue()); - - public JPAAnnotationMapper(EntityManagerFactory entityManagerFactory) { - super(entityManagerFactory); - } - - @Override - public List getAllAnnotations(MailboxId mailboxId) { - JPAId jpaId = (JPAId) mailboxId; - return getEntityManager().createNamedQuery("retrieveAllAnnotations", JPAMailboxAnnotation.class) - .setParameter("idParam", jpaId.getRawId()) - .getResultList() - .stream() - .map(READ_ROW) - .collect(ImmutableList.toImmutableList()); - } - - @Override - public List getAnnotationsByKeys(MailboxId mailboxId, Set keys) { - try { - final JPAId jpaId = (JPAId) mailboxId; - return keys.stream() - .map(input -> READ_ROW.apply( - getEntityManager() - .createNamedQuery("retrieveByKey", JPAMailboxAnnotation.class) - .setParameter("idParam", jpaId.getRawId()) - .setParameter("keyParam", input.asString()) - .getSingleResult())) - .collect(ImmutableList.toImmutableList()); - } catch (NoResultException e) { - return ImmutableList.of(); - } - } - - @Override - public List getAnnotationsByKeysWithOneDepth(MailboxId mailboxId, Set keys) { - return getFilteredLikes((JPAId) mailboxId, - keys, - key -> - annotation -> - key.isParentOrIsEqual(annotation.getKey())); - } - - @Override - public List getAnnotationsByKeysWithAllDepth(MailboxId mailboxId, Set keys) { - return getFilteredLikes((JPAId) mailboxId, - keys, - key -> - annotation -> key.isAncestorOrIsEqual(annotation.getKey())); - } - - private List getFilteredLikes(final JPAId jpaId, Set keys, final Function> predicateFunction) { - try { - return keys.stream() - .flatMap(key -> getEntityManager() - .createNamedQuery("retrieveByKeyLike", JPAMailboxAnnotation.class) - .setParameter("idParam", jpaId.getRawId()) - .setParameter("keyParam", key.asString() + '%') - .getResultList() - .stream() - .map(READ_ROW) - .filter(predicateFunction.apply(key))) - .collect(ImmutableList.toImmutableList()); - } catch (NoResultException e) { - return ImmutableList.of(); - } - } - - @Override - public void deleteAnnotation(MailboxId mailboxId, MailboxAnnotationKey key) { - try { - JPAId jpaId = (JPAId) mailboxId; - JPAMailboxAnnotation jpaMailboxAnnotation = getEntityManager() - .find(JPAMailboxAnnotation.class, new JPAMailboxAnnotationId(jpaId.getRawId(), key.asString())); - getEntityManager().remove(jpaMailboxAnnotation); - } catch (NoResultException e) { - LOGGER.debug("Mailbox annotation not found for ID {} and key {}", mailboxId.serialize(), key.asString()); - } catch (PersistenceException pe) { - throw new RuntimeException(pe); - } - } - - @Override - public void insertAnnotation(MailboxId mailboxId, MailboxAnnotation mailboxAnnotation) { - Preconditions.checkArgument(!mailboxAnnotation.isNil()); - JPAId jpaId = (JPAId) mailboxId; - if (getAnnotationsByKeys(mailboxId, ImmutableSet.of(mailboxAnnotation.getKey())).isEmpty()) { - getEntityManager().persist( - new JPAMailboxAnnotation(jpaId.getRawId(), - mailboxAnnotation.getKey().asString(), - mailboxAnnotation.getValue().orElse(null))); - } else { - getEntityManager().find(JPAMailboxAnnotation.class, - new JPAMailboxAnnotationId(jpaId.getRawId(), mailboxAnnotation.getKey().asString())) - .setValue(mailboxAnnotation.getValue().orElse(null)); - } - } - - @Override - public boolean exist(MailboxId mailboxId, MailboxAnnotation mailboxAnnotation) { - JPAId jpaId = (JPAId) mailboxId; - Optional row = Optional.ofNullable(getEntityManager().find(JPAMailboxAnnotation.class, - new JPAMailboxAnnotationId(jpaId.getRawId(), mailboxAnnotation.getKey().asString()))); - return row.isPresent(); - } - - @Override - public int countAnnotations(MailboxId mailboxId) { - try { - JPAId jpaId = (JPAId) mailboxId; - return ((Long)getEntityManager().createNamedQuery("countAnnotationsInMailbox") - .setParameter("idParam", jpaId.getRawId()).getSingleResult()).intValue(); - } catch (PersistenceException pe) { - throw new RuntimeException(pe); - } - } -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAAttachmentMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAAttachmentMapper.java deleted file mode 100644 index dc91260fc35..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAAttachmentMapper.java +++ /dev/null @@ -1,118 +0,0 @@ -/*************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.mail; - -import java.io.IOException; -import java.io.InputStream; -import java.util.Collection; -import java.util.List; - -import javax.persistence.EntityManagerFactory; -import javax.persistence.NoResultException; - -import org.apache.commons.io.IOUtils; -import org.apache.james.mailbox.exception.AttachmentNotFoundException; -import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.model.AttachmentId; -import org.apache.james.mailbox.model.AttachmentMetadata; -import org.apache.james.mailbox.model.MessageAttachmentMetadata; -import org.apache.james.mailbox.model.MessageId; -import org.apache.james.mailbox.model.ParsedAttachment; -import org.apache.james.mailbox.postgres.JPATransactionalMapper; -import org.apache.james.mailbox.postgres.mail.model.JPAAttachment; -import org.apache.james.mailbox.store.mail.AttachmentMapper; - -import com.github.fge.lambdas.Throwing; -import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; - -public class JPAAttachmentMapper extends JPATransactionalMapper implements AttachmentMapper { - - private static final String ID_PARAM = "idParam"; - - public JPAAttachmentMapper(EntityManagerFactory entityManagerFactory) { - super(entityManagerFactory); - } - - @Override - public InputStream loadAttachmentContent(AttachmentId attachmentId) { - Preconditions.checkArgument(attachmentId != null); - return getEntityManager().createNamedQuery("findAttachmentById", JPAAttachment.class) - .setParameter(ID_PARAM, attachmentId.getId()) - .getSingleResult().getContent(); - } - - @Override - public AttachmentMetadata getAttachment(AttachmentId attachmentId) throws AttachmentNotFoundException { - Preconditions.checkArgument(attachmentId != null); - AttachmentMetadata attachmentMetadata = getAttachmentMetadata(attachmentId); - if (attachmentMetadata == null) { - throw new AttachmentNotFoundException(attachmentId.getId()); - } - return attachmentMetadata; - } - - @Override - public List getAttachments(Collection attachmentIds) { - Preconditions.checkArgument(attachmentIds != null); - ImmutableList.Builder builder = ImmutableList.builder(); - for (AttachmentId attachmentId : attachmentIds) { - AttachmentMetadata attachmentMetadata = getAttachmentMetadata(attachmentId); - if (attachmentMetadata != null) { - builder.add(attachmentMetadata); - } - } - return builder.build(); - } - - @Override - public List storeAttachments(Collection parsedAttachments, MessageId ownerMessageId) { - Preconditions.checkArgument(parsedAttachments != null); - Preconditions.checkArgument(ownerMessageId != null); - return parsedAttachments.stream() - .map(Throwing.function( - typedContent -> storeAttachmentForMessage(ownerMessageId, typedContent)) - .sneakyThrow()) - .collect(ImmutableList.toImmutableList()); - } - - private AttachmentMetadata getAttachmentMetadata(AttachmentId attachmentId) { - try { - return getEntityManager().createNamedQuery("findAttachmentById", JPAAttachment.class) - .setParameter(ID_PARAM, attachmentId.getId()) - .getSingleResult() - .toAttachmentMetadata(); - } catch (NoResultException e) { - return null; - } - } - - private MessageAttachmentMetadata storeAttachmentForMessage(MessageId ownerMessageId, ParsedAttachment parsedAttachment) throws MailboxException { - try { - byte[] bytes = IOUtils.toByteArray(parsedAttachment.getContent().openStream()); - JPAAttachment persistedAttachment = new JPAAttachment(parsedAttachment.asMessageAttachment(AttachmentId.random(), ownerMessageId), bytes); - getEntityManager().persist(persistedAttachment); - AttachmentId attachmentId = AttachmentId.from(persistedAttachment.getAttachmentId()); - return parsedAttachment.asMessageAttachment(attachmentId, bytes.length, ownerMessageId); - } catch (IOException e) { - throw new MailboxException("Failed to store attachment for message " + ownerMessageId, e); - } - } -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAMailboxMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAMailboxMapper.java deleted file mode 100644 index 810f2388b89..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAMailboxMapper.java +++ /dev/null @@ -1,240 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.mail; - -import java.util.NoSuchElementException; - -import javax.persistence.EntityExistsException; -import javax.persistence.EntityManagerFactory; -import javax.persistence.NoResultException; -import javax.persistence.PersistenceException; -import javax.persistence.RollbackException; -import javax.persistence.TypedQuery; - -import org.apache.james.core.Username; -import org.apache.james.mailbox.acl.ACLDiff; -import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.exception.MailboxExistsException; -import org.apache.james.mailbox.exception.MailboxNotFoundException; -import org.apache.james.mailbox.model.Mailbox; -import org.apache.james.mailbox.model.MailboxACL; -import org.apache.james.mailbox.model.MailboxACL.Right; -import org.apache.james.mailbox.model.MailboxId; -import org.apache.james.mailbox.model.MailboxPath; -import org.apache.james.mailbox.model.UidValidity; -import org.apache.james.mailbox.model.search.MailboxQuery; -import org.apache.james.mailbox.postgres.JPAId; -import org.apache.james.mailbox.postgres.JPATransactionalMapper; -import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; -import org.apache.james.mailbox.store.MailboxExpressionBackwardCompatibility; -import org.apache.james.mailbox.store.mail.MailboxMapper; - -import com.google.common.base.Preconditions; - -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; - -/** - * Data access management for mailbox. - */ -public class JPAMailboxMapper extends JPATransactionalMapper implements MailboxMapper { - - private static final char SQL_WILDCARD_CHAR = '%'; - private String lastMailboxName; - - public JPAMailboxMapper(EntityManagerFactory entityManagerFactory) { - super(entityManagerFactory); - } - - /** - * Commit the transaction. If the commit fails due a conflict in a unique key constraint a {@link MailboxExistsException} - * will get thrown - */ - @Override - protected void commit() throws MailboxException { - try { - getEntityManager().getTransaction().commit(); - } catch (PersistenceException e) { - if (e instanceof EntityExistsException) { - throw new MailboxExistsException(lastMailboxName); - } - if (e instanceof RollbackException) { - Throwable t = e.getCause(); - if (t instanceof EntityExistsException) { - throw new MailboxExistsException(lastMailboxName); - } - } - throw new MailboxException("Commit of transaction failed", e); - } - } - - @Override - public Mono create(MailboxPath mailboxPath, UidValidity uidValidity) { - return assertPathIsNotAlreadyUsedByAnotherMailbox(mailboxPath) - .then(Mono.fromCallable(() -> { - this.lastMailboxName = mailboxPath.getName(); - JPAMailbox persistedMailbox = new JPAMailbox(mailboxPath, uidValidity); - getEntityManager().persist(persistedMailbox); - - return new Mailbox(mailboxPath, uidValidity, persistedMailbox.getMailboxId()); - }).subscribeOn(Schedulers.boundedElastic())) - .onErrorMap(PersistenceException.class, e -> new MailboxException("Save of mailbox " + mailboxPath.getName() + " failed", e)); - } - - @Override - public Mono rename(Mailbox mailbox) { - Preconditions.checkNotNull(mailbox.getMailboxId(), "A mailbox we want to rename should have a defined mailboxId"); - - return assertPathIsNotAlreadyUsedByAnotherMailbox(mailbox.generateAssociatedPath()) - .then(Mono.fromCallable(() -> { - this.lastMailboxName = mailbox.getName(); - JPAMailbox persistedMailbox = jpaMailbox(mailbox); - - getEntityManager().persist(persistedMailbox); - return (MailboxId) persistedMailbox.getMailboxId(); - }).subscribeOn(Schedulers.boundedElastic())) - .onErrorMap(PersistenceException.class, e -> new MailboxException("Save of mailbox " + mailbox.getName() + " failed", e)); - } - - private JPAMailbox jpaMailbox(Mailbox mailbox) throws MailboxException { - JPAMailbox result = loadJpaMailbox(mailbox.getMailboxId()); - result.setNamespace(mailbox.getNamespace()); - result.setUser(mailbox.getUser().asString()); - result.setName(mailbox.getName()); - return result; - } - - private Mono assertPathIsNotAlreadyUsedByAnotherMailbox(MailboxPath mailboxPath) { - return findMailboxByPath(mailboxPath) - .flatMap(ignored -> Mono.error(new MailboxExistsException(mailboxPath.getName()))); - } - - @Override - public Mono findMailboxByPath(MailboxPath mailboxPath) { - return Mono.fromCallable(() -> getEntityManager().createNamedQuery("findMailboxByNameWithUser", JPAMailbox.class) - .setParameter("nameParam", mailboxPath.getName()) - .setParameter("namespaceParam", mailboxPath.getNamespace()) - .setParameter("userParam", mailboxPath.getUser().asString()) - .getSingleResult() - .toMailbox()) - .onErrorResume(NoResultException.class, e -> Mono.empty()) - .onErrorResume(NoSuchElementException.class, e -> Mono.empty()) - .onErrorResume(PersistenceException.class, e -> Mono.error(new MailboxException("Exception upon JPA execution", e))) - .subscribeOn(Schedulers.boundedElastic()); - } - - @Override - public Mono findMailboxById(MailboxId id) { - return Mono.fromCallable(() -> loadJpaMailbox(id).toMailbox()) - .subscribeOn(Schedulers.boundedElastic()) - .onErrorMap(PersistenceException.class, e -> new MailboxException("Search of mailbox " + id.serialize() + " failed", e)); - } - - private JPAMailbox loadJpaMailbox(MailboxId id) throws MailboxNotFoundException { - JPAId mailboxId = (JPAId)id; - try { - return getEntityManager().createNamedQuery("findMailboxById", JPAMailbox.class) - .setParameter("idParam", mailboxId.getRawId()) - .getSingleResult(); - } catch (NoResultException e) { - throw new MailboxNotFoundException(mailboxId); - } - } - - @Override - public Mono delete(Mailbox mailbox) { - return Mono.fromRunnable(() -> { - JPAId mailboxId = (JPAId) mailbox.getMailboxId(); - getEntityManager().createNamedQuery("deleteMessages").setParameter("idParam", mailboxId.getRawId()).executeUpdate(); - JPAMailbox jpaMailbox = getEntityManager().find(JPAMailbox.class, mailboxId.getRawId()); - getEntityManager().remove(jpaMailbox); - }) - .subscribeOn(Schedulers.boundedElastic()) - .onErrorMap(PersistenceException.class, e -> new MailboxException("Delete of mailbox " + mailbox + " failed", e)) - .then(); - } - - @Override - public Flux findMailboxWithPathLike(MailboxQuery.UserBound query) { - String pathLike = MailboxExpressionBackwardCompatibility.getPathLike(query); - return Mono.fromCallable(() -> findMailboxWithPathLikeTypedQuery(query.getFixedNamespace(), query.getFixedUser(), pathLike)) - .subscribeOn(Schedulers.boundedElastic()) - .flatMapIterable(TypedQuery::getResultList) - .map(JPAMailbox::toMailbox) - .filter(query::matches) - .onErrorMap(PersistenceException.class, e -> new MailboxException("Search of mailbox " + query + " failed", e)); - } - - private TypedQuery findMailboxWithPathLikeTypedQuery(String namespace, Username username, String pathLike) { - return getEntityManager().createNamedQuery("findMailboxWithNameLikeWithUser", JPAMailbox.class) - .setParameter("nameParam", pathLike) - .setParameter("namespaceParam", namespace) - .setParameter("userParam", username.asString()); - } - - @Override - public Mono hasChildren(Mailbox mailbox, char delimiter) { - final String name = mailbox.getName() + delimiter + SQL_WILDCARD_CHAR; - - return Mono.defer(() -> Mono.justOrEmpty((Long) getEntityManager() - .createNamedQuery("countMailboxesWithNameLikeWithUser") - .setParameter("nameParam", name) - .setParameter("namespaceParam", mailbox.getNamespace()) - .setParameter("userParam", mailbox.getUser().asString()) - .getSingleResult())) - .subscribeOn(Schedulers.boundedElastic()) - .filter(numberOfChildMailboxes -> numberOfChildMailboxes > 0) - .hasElement(); - } - - @Override - public Flux list() { - return Mono.fromCallable(() -> getEntityManager().createNamedQuery("listMailboxes", JPAMailbox.class)) - .subscribeOn(Schedulers.boundedElastic()) - .flatMapIterable(TypedQuery::getResultList) - .onErrorMap(PersistenceException.class, e -> new MailboxException("Delete of mailboxes failed", e)) - .map(JPAMailbox::toMailbox); - } - - @Override - public Mono updateACL(Mailbox mailbox, MailboxACL.ACLCommand mailboxACLCommand) { - return Mono.fromCallable(() -> { - MailboxACL oldACL = mailbox.getACL(); - MailboxACL newACL = mailbox.getACL().apply(mailboxACLCommand); - mailbox.setACL(newACL); - return ACLDiff.computeDiff(oldACL, newACL); - }).subscribeOn(Schedulers.boundedElastic()); - } - - @Override - public Mono setACL(Mailbox mailbox, MailboxACL mailboxACL) { - return Mono.fromCallable(() -> { - MailboxACL oldMailboxAcl = mailbox.getACL(); - mailbox.setACL(mailboxACL); - return ACLDiff.computeDiff(oldMailboxAcl, mailboxACL); - }).subscribeOn(Schedulers.boundedElastic()); - } - - @Override - public Flux findNonPersonalMailboxes(Username userName, Right right) { - return Flux.empty(); - } -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAMessageMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAMessageMapper.java deleted file mode 100644 index 89c2d3d1d68..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAMessageMapper.java +++ /dev/null @@ -1,510 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.mailbox.postgres.mail; - -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.stream.Collectors; - -import javax.mail.Flags; -import javax.persistence.EntityManagerFactory; -import javax.persistence.PersistenceException; -import javax.persistence.Query; - -import org.apache.james.backends.jpa.JPAConfiguration; -import org.apache.james.mailbox.ApplicableFlagBuilder; -import org.apache.james.mailbox.MessageUid; -import org.apache.james.mailbox.ModSeq; -import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.model.Mailbox; -import org.apache.james.mailbox.model.MailboxCounters; -import org.apache.james.mailbox.model.MailboxId; -import org.apache.james.mailbox.model.MessageAttachmentMetadata; -import org.apache.james.mailbox.model.MessageMetaData; -import org.apache.james.mailbox.model.MessageRange; -import org.apache.james.mailbox.model.MessageRange.Type; -import org.apache.james.mailbox.model.UpdatedFlags; -import org.apache.james.mailbox.postgres.JPAId; -import org.apache.james.mailbox.postgres.JPATransactionalMapper; -import org.apache.james.mailbox.postgres.mail.MessageUtils.MessageChangedFlags; -import org.apache.james.mailbox.postgres.mail.model.JPAAttachment; -import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; -import org.apache.james.mailbox.postgres.mail.model.openjpa.AbstractJPAMailboxMessage; -import org.apache.james.mailbox.postgres.mail.model.openjpa.JPAMailboxMessage; -import org.apache.james.mailbox.store.FlagsUpdateCalculator; -import org.apache.james.mailbox.store.mail.MessageMapper; -import org.apache.james.mailbox.store.mail.model.MailboxMessage; -import org.apache.openjpa.persistence.ArgumentException; - -import com.github.fge.lambdas.Throwing; -import com.google.common.collect.ImmutableList; - -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; - -/** - * JPA implementation of a {@link MessageMapper}. This class is not thread-safe! - */ -public class JPAMessageMapper extends JPATransactionalMapper implements MessageMapper { - private static final int UNLIMIT_MAX_SIZE = -1; - private static final int UNLIMITED = -1; - - private final MessageUtils messageMetadataMapper; - private final JPAUidProvider uidProvider; - private final JPAModSeqProvider modSeqProvider; - private final JPAConfiguration jpaConfiguration; - - public JPAMessageMapper(JPAUidProvider uidProvider, JPAModSeqProvider modSeqProvider, EntityManagerFactory entityManagerFactory, - JPAConfiguration jpaConfiguration) { - super(entityManagerFactory); - this.messageMetadataMapper = new MessageUtils(uidProvider, modSeqProvider); - this.uidProvider = uidProvider; - this.modSeqProvider = modSeqProvider; - this.jpaConfiguration = jpaConfiguration; - } - - @Override - public MailboxCounters getMailboxCounters(Mailbox mailbox) throws MailboxException { - return MailboxCounters.builder() - .mailboxId(mailbox.getMailboxId()) - .count(countMessagesInMailbox(mailbox)) - .unseen(countUnseenMessagesInMailbox(mailbox)) - .build(); - } - - @Override - public Flux listAllMessageUids(Mailbox mailbox) { - return Mono.fromCallable(() -> { - try { - JPAId mailboxId = (JPAId) mailbox.getMailboxId(); - Query query = getEntityManager().createNamedQuery("listUidsInMailbox") - .setParameter("idParam", mailboxId.getRawId()); - return query.getResultStream().map(result -> MessageUid.of((Long) result)); - } catch (PersistenceException e) { - throw new MailboxException("Search of recent messages failed in mailbox " + mailbox, e); - } - }).flatMapMany(Flux::fromStream) - .subscribeOn(Schedulers.boundedElastic()); - } - - @Override - public Flux findInMailboxReactive(Mailbox mailbox, MessageRange messageRange, FetchType ftype, int limitAsInt) { - return Flux.defer(Throwing.supplier(() -> Flux.fromIterable(findAsList(mailbox.getMailboxId(), messageRange, limitAsInt))).sneakyThrow()) - .subscribeOn(Schedulers.boundedElastic()); - } - - @Override - public Iterator findInMailbox(Mailbox mailbox, MessageRange set, FetchType fType, int max) - throws MailboxException { - - return findAsList(mailbox.getMailboxId(), set, max).iterator(); - } - - private List findAsList(MailboxId mailboxId, MessageRange set, int max) throws MailboxException { - try { - MessageUid from = set.getUidFrom(); - MessageUid to = set.getUidTo(); - Type type = set.getType(); - JPAId jpaId = (JPAId) mailboxId; - - switch (type) { - default: - case ALL: - return findMessagesInMailbox(jpaId, max); - case FROM: - return findMessagesInMailboxAfterUID(jpaId, from, max); - case ONE: - return findMessagesInMailboxWithUID(jpaId, from); - case RANGE: - return findMessagesInMailboxBetweenUIDs(jpaId, from, to, max); - } - } catch (PersistenceException e) { - throw new MailboxException("Search of MessageRange " + set + " failed in mailbox " + mailboxId.serialize(), e); - } - } - - @Override - public long countMessagesInMailbox(Mailbox mailbox) throws MailboxException { - JPAId mailboxId = (JPAId) mailbox.getMailboxId(); - return countMessagesInMailbox(mailboxId); - } - - private long countMessagesInMailbox(JPAId mailboxId) throws MailboxException { - try { - return (Long) getEntityManager().createNamedQuery("countMessagesInMailbox") - .setParameter("idParam", mailboxId.getRawId()).getSingleResult(); - } catch (PersistenceException e) { - throw new MailboxException("Count of messages failed in mailbox " + mailboxId, e); - } - } - - public long countUnseenMessagesInMailbox(Mailbox mailbox) throws MailboxException { - JPAId mailboxId = (JPAId) mailbox.getMailboxId(); - return countUnseenMessagesInMailbox(mailboxId); - } - - private long countUnseenMessagesInMailbox(JPAId mailboxId) throws MailboxException { - try { - return (Long) getEntityManager().createNamedQuery("countUnseenMessagesInMailbox") - .setParameter("idParam", mailboxId.getRawId()).getSingleResult(); - } catch (PersistenceException e) { - throw new MailboxException("Count of useen messages failed in mailbox " + mailboxId, e); - } - } - - @Override - public void delete(Mailbox mailbox, MailboxMessage message) throws MailboxException { - try { - AbstractJPAMailboxMessage jpaMessage = getEntityManager().find(AbstractJPAMailboxMessage.class, buildKey(mailbox, message)); - getEntityManager().remove(jpaMessage); - - } catch (PersistenceException e) { - throw new MailboxException("Delete of message " + message + " failed in mailbox " + mailbox, e); - } - } - - private AbstractJPAMailboxMessage.MailboxIdUidKey buildKey(Mailbox mailbox, MailboxMessage message) { - JPAId mailboxId = (JPAId) mailbox.getMailboxId(); - AbstractJPAMailboxMessage.MailboxIdUidKey key = new AbstractJPAMailboxMessage.MailboxIdUidKey(); - key.mailbox = mailboxId.getRawId(); - key.uid = message.getUid().asLong(); - return key; - } - - @Override - @SuppressWarnings("unchecked") - public MessageUid findFirstUnseenMessageUid(Mailbox mailbox) throws MailboxException { - try { - JPAId mailboxId = (JPAId) mailbox.getMailboxId(); - Query query = getEntityManager().createNamedQuery("findUnseenMessagesInMailboxOrderByUid").setParameter( - "idParam", mailboxId.getRawId()); - query.setMaxResults(1); - List result = query.getResultList(); - if (result.isEmpty()) { - return null; - } else { - return result.get(0).getUid(); - } - } catch (PersistenceException e) { - throw new MailboxException("Search of first unseen message failed in mailbox " + mailbox, e); - } - } - - @Override - @SuppressWarnings("unchecked") - public List findRecentMessageUidsInMailbox(Mailbox mailbox) throws MailboxException { - try { - JPAId mailboxId = (JPAId) mailbox.getMailboxId(); - Query query = getEntityManager().createNamedQuery("findRecentMessageUidsInMailbox").setParameter("idParam", - mailboxId.getRawId()); - List resultList = query.getResultList(); - ImmutableList.Builder results = ImmutableList.builder(); - for (long result: resultList) { - results.add(MessageUid.of(result)); - } - return results.build(); - } catch (PersistenceException e) { - throw new MailboxException("Search of recent messages failed in mailbox " + mailbox, e); - } - } - - - - @Override - public List retrieveMessagesMarkedForDeletion(Mailbox mailbox, MessageRange messageRange) throws MailboxException { - try { - JPAId mailboxId = (JPAId) mailbox.getMailboxId(); - List messages = findDeletedMessages(messageRange, mailboxId); - return getUidList(messages); - } catch (PersistenceException e) { - throw new MailboxException("Search of MessageRange " + messageRange + " failed in mailbox " + mailbox, e); - } - } - - private List findDeletedMessages(MessageRange messageRange, JPAId mailboxId) { - MessageUid from = messageRange.getUidFrom(); - MessageUid to = messageRange.getUidTo(); - - switch (messageRange.getType()) { - case ONE: - return findDeletedMessagesInMailboxWithUID(mailboxId, from); - case RANGE: - return findDeletedMessagesInMailboxBetweenUIDs(mailboxId, from, to); - case FROM: - return findDeletedMessagesInMailboxAfterUID(mailboxId, from); - case ALL: - return findDeletedMessagesInMailbox(mailboxId); - default: - throw new RuntimeException("Cannot find deleted messages, range type " + messageRange.getType() + " doesn't exist"); - } - } - - @Override - public Map deleteMessages(Mailbox mailbox, List uids) throws MailboxException { - JPAId mailboxId = (JPAId) mailbox.getMailboxId(); - Map data = new HashMap<>(); - List ranges = MessageRange.toRanges(uids); - - ranges.forEach(Throwing.consumer(range -> { - List messages = findAsList(mailboxId, range, JPAMessageMapper.UNLIMITED); - data.putAll(createMetaData(messages)); - deleteMessages(range, mailboxId); - }).sneakyThrow()); - - return data; - } - - private void deleteMessages(MessageRange messageRange, JPAId mailboxId) { - MessageUid from = messageRange.getUidFrom(); - MessageUid to = messageRange.getUidTo(); - - switch (messageRange.getType()) { - case ONE: - deleteMessagesInMailboxWithUID(mailboxId, from); - break; - case RANGE: - deleteMessagesInMailboxBetweenUIDs(mailboxId, from, to); - break; - case FROM: - deleteMessagesInMailboxAfterUID(mailboxId, from); - break; - case ALL: - deleteMessagesInMailbox(mailboxId); - break; - default: - throw new RuntimeException("Cannot delete messages, range type " + messageRange.getType() + " doesn't exist"); - } - } - - @Override - public MessageMetaData move(Mailbox mailbox, MailboxMessage original) throws MailboxException { - JPAId originalMailboxId = (JPAId) original.getMailboxId(); - JPAMailbox originalMailbox = getEntityManager().find(JPAMailbox.class, originalMailboxId.getRawId()); - - MessageMetaData messageMetaData = copy(mailbox, original); - delete(originalMailbox.toMailbox(), original); - - return messageMetaData; - } - - @Override - public MessageMetaData add(Mailbox mailbox, MailboxMessage message) throws MailboxException { - messageMetadataMapper.enrichMessage(mailbox, message); - - return save(mailbox, message); - } - - @Override - public Iterator updateFlags(Mailbox mailbox, FlagsUpdateCalculator flagsUpdateCalculator, - MessageRange set) throws MailboxException { - Iterator messages = findInMailbox(mailbox, set, FetchType.METADATA, UNLIMIT_MAX_SIZE); - - MessageChangedFlags messageChangedFlags = messageMetadataMapper.updateFlags(mailbox, flagsUpdateCalculator, messages); - - for (MailboxMessage mailboxMessage : messageChangedFlags.getChangedFlags()) { - save(mailbox, mailboxMessage); - } - - return messageChangedFlags.getUpdatedFlags(); - } - - @Override - public MessageMetaData copy(Mailbox mailbox, MailboxMessage original) throws MailboxException { - return copy(mailbox, uidProvider.nextUid(mailbox), modSeqProvider.nextModSeq(mailbox), original); - } - - @Override - public Optional getLastUid(Mailbox mailbox) throws MailboxException { - return uidProvider.lastUid(mailbox, getEntityManager()); - } - - @Override - public ModSeq getHighestModSeq(Mailbox mailbox) throws MailboxException { - return modSeqProvider.highestModSeq(mailbox.getMailboxId(), getEntityManager()); - } - - @Override - public Flags getApplicableFlag(Mailbox mailbox) throws MailboxException { - JPAId jpaId = (JPAId) mailbox.getMailboxId(); - ApplicableFlagBuilder builder = ApplicableFlagBuilder.builder(); - List flags = getEntityManager().createNativeQuery("SELECT DISTINCT USERFLAG_NAME FROM JAMES_MAIL_USERFLAG WHERE MAILBOX_ID=?") - .setParameter(1, jpaId.getRawId()) - .getResultList(); - flags.forEach(builder::add); - return builder.build(); - } - - private MessageMetaData copy(Mailbox mailbox, MessageUid uid, ModSeq modSeq, MailboxMessage original) - throws MailboxException { - MailboxMessage copy; - JPAMailbox currentMailbox = JPAMailbox.from(mailbox); - - copy = new JPAMailboxMessage(currentMailbox, uid, modSeq, original); - return save(mailbox, copy); - } - - protected MessageMetaData save(Mailbox mailbox, MailboxMessage message) throws MailboxException { - try { - // We need to reload a "JPA attached" mailbox, because the provide - // mailbox is already "JPA detached" - // If we don't this, we will get an - // org.apache.openjpa.persistence.ArgumentException. - JPAId mailboxId = (JPAId) mailbox.getMailboxId(); - JPAMailbox currentMailbox = getEntityManager().find(JPAMailbox.class, mailboxId.getRawId()); - - boolean isAttachmentStorage = false; - if (Objects.nonNull(jpaConfiguration)) { - isAttachmentStorage = jpaConfiguration.isAttachmentStorageEnabled().orElse(false); - } - - if (message instanceof AbstractJPAMailboxMessage) { - ((AbstractJPAMailboxMessage) message).setMailbox(currentMailbox); - - getEntityManager().persist(message); - return message.metaData(); - } else { - JPAMailboxMessage persistData = new JPAMailboxMessage(currentMailbox, message.getUid(), message.getModSeq(), message); - persistData.setFlags(message.createFlags()); - getEntityManager().persist(persistData); - return persistData.metaData(); - } - - } catch (PersistenceException | ArgumentException e) { - throw new MailboxException("Save of message " + message + " failed in mailbox " + mailbox, e); - } - } - - private List getAttachments(MailboxMessage message) { - return message.getAttachments().stream() - .map(MessageAttachmentMetadata::getAttachmentId) - .map(attachmentId -> getEntityManager().createNamedQuery("findAttachmentById", JPAAttachment.class) - .setParameter("idParam", attachmentId.getId()) - .getSingleResult()) - .collect(Collectors.toList()); - } - - @SuppressWarnings("unchecked") - private List findMessagesInMailboxAfterUID(JPAId mailboxId, MessageUid from, int batchSize) { - Query query = getEntityManager().createNamedQuery("findMessagesInMailboxAfterUID") - .setParameter("idParam", mailboxId.getRawId()).setParameter("uidParam", from.asLong()); - - if (batchSize > 0) { - query.setMaxResults(batchSize); - } - - return query.getResultList(); - } - - @SuppressWarnings("unchecked") - private List findMessagesInMailboxWithUID(JPAId mailboxId, MessageUid from) { - return getEntityManager().createNamedQuery("findMessagesInMailboxWithUID") - .setParameter("idParam", mailboxId.getRawId()).setParameter("uidParam", from.asLong()).setMaxResults(1) - .getResultList(); - } - - @SuppressWarnings("unchecked") - private List findMessagesInMailboxBetweenUIDs(JPAId mailboxId, MessageUid from, MessageUid to, - int batchSize) { - Query query = getEntityManager().createNamedQuery("findMessagesInMailboxBetweenUIDs") - .setParameter("idParam", mailboxId.getRawId()).setParameter("fromParam", from.asLong()) - .setParameter("toParam", to.asLong()); - - if (batchSize > 0) { - query.setMaxResults(batchSize); - } - - return query.getResultList(); - } - - @SuppressWarnings("unchecked") - private List findMessagesInMailbox(JPAId mailboxId, int batchSize) { - Query query = getEntityManager().createNamedQuery("findMessagesInMailbox").setParameter("idParam", - mailboxId.getRawId()); - if (batchSize > 0) { - query.setMaxResults(batchSize); - } - return query.getResultList(); - } - - private Map createMetaData(List uids) { - final Map data = new HashMap<>(); - for (MailboxMessage m : uids) { - data.put(m.getUid(), m.metaData()); - } - return data; - } - - private List getUidList(List messages) { - return messages.stream() - .map(MailboxMessage::getUid) - .collect(ImmutableList.toImmutableList()); - } - - private int deleteMessagesInMailbox(JPAId mailboxId) { - return getEntityManager().createNamedQuery("deleteMessagesInMailbox") - .setParameter("idParam", mailboxId.getRawId()).executeUpdate(); - } - - private int deleteMessagesInMailboxAfterUID(JPAId mailboxId, MessageUid from) { - return getEntityManager().createNamedQuery("deleteMessagesInMailboxAfterUID") - .setParameter("idParam", mailboxId.getRawId()).setParameter("uidParam", from.asLong()).executeUpdate(); - } - - private int deleteMessagesInMailboxWithUID(JPAId mailboxId, MessageUid from) { - return getEntityManager().createNamedQuery("deleteMessagesInMailboxWithUID") - .setParameter("idParam", mailboxId.getRawId()).setParameter("uidParam", from.asLong()).executeUpdate(); - } - - private int deleteMessagesInMailboxBetweenUIDs(JPAId mailboxId, MessageUid from, MessageUid to) { - return getEntityManager().createNamedQuery("deleteMessagesInMailboxBetweenUIDs") - .setParameter("idParam", mailboxId.getRawId()).setParameter("fromParam", from.asLong()) - .setParameter("toParam", to.asLong()).executeUpdate(); - } - - @SuppressWarnings("unchecked") - private List findDeletedMessagesInMailbox(JPAId mailboxId) { - return getEntityManager().createNamedQuery("findDeletedMessagesInMailbox") - .setParameter("idParam", mailboxId.getRawId()).getResultList(); - } - - @SuppressWarnings("unchecked") - private List findDeletedMessagesInMailboxAfterUID(JPAId mailboxId, MessageUid from) { - return getEntityManager().createNamedQuery("findDeletedMessagesInMailboxAfterUID") - .setParameter("idParam", mailboxId.getRawId()).setParameter("uidParam", from.asLong()).getResultList(); - } - - @SuppressWarnings("unchecked") - private List findDeletedMessagesInMailboxWithUID(JPAId mailboxId, MessageUid from) { - return getEntityManager().createNamedQuery("findDeletedMessagesInMailboxWithUID") - .setParameter("idParam", mailboxId.getRawId()).setParameter("uidParam", from.asLong()).setMaxResults(1) - .getResultList(); - } - - @SuppressWarnings("unchecked") - private List findDeletedMessagesInMailboxBetweenUIDs(JPAId mailboxId, MessageUid from, MessageUid to) { - return getEntityManager().createNamedQuery("findDeletedMessagesInMailboxBetweenUIDs") - .setParameter("idParam", mailboxId.getRawId()).setParameter("fromParam", from.asLong()) - .setParameter("toParam", to.asLong()).getResultList(); - } -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAModSeqProvider.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAModSeqProvider.java deleted file mode 100644 index bfa16f9ad1f..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAModSeqProvider.java +++ /dev/null @@ -1,105 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.mailbox.postgres.mail; - -import javax.inject.Inject; -import javax.persistence.EntityManager; -import javax.persistence.EntityManagerFactory; -import javax.persistence.PersistenceException; - -import org.apache.james.backends.jpa.EntityManagerUtils; -import org.apache.james.mailbox.ModSeq; -import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.model.Mailbox; -import org.apache.james.mailbox.model.MailboxId; -import org.apache.james.mailbox.postgres.JPAId; -import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; -import org.apache.james.mailbox.store.mail.ModSeqProvider; - -public class JPAModSeqProvider implements ModSeqProvider { - - private final EntityManagerFactory factory; - - @Inject - public JPAModSeqProvider(EntityManagerFactory factory) { - this.factory = factory; - } - - @Override - public ModSeq highestModSeq(Mailbox mailbox) throws MailboxException { - JPAId mailboxId = (JPAId) mailbox.getMailboxId(); - return highestModSeq(mailboxId); - } - - @Override - public ModSeq nextModSeq(Mailbox mailbox) throws MailboxException { - return nextModSeq((JPAId) mailbox.getMailboxId()); - } - - @Override - public ModSeq nextModSeq(MailboxId mailboxId) throws MailboxException { - return nextModSeq((JPAId) mailboxId); - } - - @Override - public ModSeq highestModSeq(MailboxId mailboxId) throws MailboxException { - return highestModSeq((JPAId) mailboxId); - } - - private ModSeq nextModSeq(JPAId mailboxId) throws MailboxException { - EntityManager manager = null; - try { - manager = factory.createEntityManager(); - manager.getTransaction().begin(); - JPAMailbox m = manager.find(JPAMailbox.class, mailboxId.getRawId()); - long modSeq = m.consumeModSeq(); - manager.persist(m); - manager.getTransaction().commit(); - return ModSeq.of(modSeq); - } catch (PersistenceException e) { - if (manager != null && manager.getTransaction().isActive()) { - manager.getTransaction().rollback(); - } - throw new MailboxException("Unable to save highest mod-sequence for mailbox " + mailboxId.serialize(), e); - } finally { - EntityManagerUtils.safelyClose(manager); - } - } - - private ModSeq highestModSeq(JPAId mailboxId) throws MailboxException { - EntityManager manager = factory.createEntityManager(); - try { - return highestModSeq(mailboxId, manager); - } finally { - EntityManagerUtils.safelyClose(manager); - } - } - - public ModSeq highestModSeq(MailboxId mailboxId, EntityManager manager) throws MailboxException { - JPAId jpaId = (JPAId) mailboxId; - try { - long highest = (Long) manager.createNamedQuery("findHighestModSeq") - .setParameter("idParam", jpaId.getRawId()) - .getSingleResult(); - return ModSeq.of(highest); - } catch (PersistenceException e) { - throw new MailboxException("Unable to get highest mod-sequence for mailbox " + mailboxId.serialize(), e); - } - } -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAUidProvider.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAUidProvider.java deleted file mode 100644 index 2b778d0e41e..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/JPAUidProvider.java +++ /dev/null @@ -1,99 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.mailbox.postgres.mail; - -import java.util.Optional; - -import javax.inject.Inject; -import javax.persistence.EntityManager; -import javax.persistence.EntityManagerFactory; -import javax.persistence.PersistenceException; - -import org.apache.james.backends.jpa.EntityManagerUtils; -import org.apache.james.mailbox.MessageUid; -import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.model.Mailbox; -import org.apache.james.mailbox.model.MailboxId; -import org.apache.james.mailbox.postgres.JPAId; -import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; -import org.apache.james.mailbox.store.mail.UidProvider; - -public class JPAUidProvider implements UidProvider { - - private final EntityManagerFactory factory; - - @Inject - public JPAUidProvider(EntityManagerFactory factory) { - this.factory = factory; - } - - @Override - public Optional lastUid(Mailbox mailbox) throws MailboxException { - EntityManager manager = factory.createEntityManager(); - try { - return lastUid(mailbox, manager); - } finally { - EntityManagerUtils.safelyClose(manager); - } - } - - public Optional lastUid(Mailbox mailbox, EntityManager manager) throws MailboxException { - try { - JPAId mailboxId = (JPAId) mailbox.getMailboxId(); - long uid = (Long) manager.createNamedQuery("findLastUid").setParameter("idParam", mailboxId.getRawId()).getSingleResult(); - if (uid == 0) { - return Optional.empty(); - } - return Optional.of(MessageUid.of(uid)); - } catch (PersistenceException e) { - throw new MailboxException("Unable to get last uid for mailbox " + mailbox, e); - } - } - - @Override - public MessageUid nextUid(Mailbox mailbox) throws MailboxException { - return nextUid((JPAId) mailbox.getMailboxId()); - } - - @Override - public MessageUid nextUid(MailboxId mailboxId) throws MailboxException { - return nextUid((JPAId) mailboxId); - } - - private MessageUid nextUid(JPAId mailboxId) throws MailboxException { - EntityManager manager = null; - try { - manager = factory.createEntityManager(); - manager.getTransaction().begin(); - JPAMailbox m = manager.find(JPAMailbox.class, mailboxId.getRawId()); - long uid = m.consumeUid(); - manager.persist(m); - manager.getTransaction().commit(); - return MessageUid.of(uid); - } catch (PersistenceException e) { - if (manager != null && manager.getTransaction().isActive()) { - manager.getTransaction().rollback(); - } - throw new MailboxException("Unable to save next uid for mailbox " + mailboxId.serialize(), e); - } finally { - EntityManagerUtils.safelyClose(manager); - } - } - -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMailboxManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxManager.java similarity index 72% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMailboxManager.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxManager.java index 1bcd6c14fd9..e0197d67774 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMailboxManager.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxManager.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.postgres.openjpa; +package org.apache.james.mailbox.postgres.mail; import java.time.Clock; import java.util.EnumSet; @@ -29,9 +29,9 @@ import org.apache.james.mailbox.SessionProvider; import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MessageId; -import org.apache.james.mailbox.store.JVMMailboxPathLocker; +import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; import org.apache.james.mailbox.store.MailboxManagerConfiguration; -import org.apache.james.mailbox.store.MailboxSessionMapperFactory; +import org.apache.james.mailbox.store.NoMailboxPathLocker; import org.apache.james.mailbox.store.PreDeletionHooks; import org.apache.james.mailbox.store.StoreMailboxAnnotationManager; import org.apache.james.mailbox.store.StoreMailboxManager; @@ -42,28 +42,27 @@ import org.apache.james.mailbox.store.quota.QuotaComponents; import org.apache.james.mailbox.store.search.MessageSearchIndex; -/** - * OpenJPA implementation of MailboxManager - */ -public class OpenJPAMailboxManager extends StoreMailboxManager { - public static final EnumSet MAILBOX_CAPABILITIES = EnumSet.of(MailboxCapabilities.UserFlag, +public class PostgresMailboxManager extends StoreMailboxManager { + + public static final EnumSet MAILBOX_CAPABILITIES = EnumSet.of( + MailboxCapabilities.UserFlag, MailboxCapabilities.Namespace, MailboxCapabilities.Move, MailboxCapabilities.Annotation); @Inject - public OpenJPAMailboxManager(MailboxSessionMapperFactory mapperFactory, - SessionProvider sessionProvider, - MessageParser messageParser, - MessageId.Factory messageIdFactory, - EventBus eventBus, - StoreMailboxAnnotationManager annotationManager, - StoreRightManager storeRightManager, - QuotaComponents quotaComponents, - MessageSearchIndex index, - ThreadIdGuessingAlgorithm threadIdGuessingAlgorithm, - Clock clock) { - super(mapperFactory, sessionProvider, new JVMMailboxPathLocker(), + public PostgresMailboxManager(PostgresMailboxSessionMapperFactory mapperFactory, + SessionProvider sessionProvider, + MessageParser messageParser, + MessageId.Factory messageIdFactory, + EventBus eventBus, + StoreMailboxAnnotationManager annotationManager, + StoreRightManager storeRightManager, + QuotaComponents quotaComponents, + MessageSearchIndex index, + ThreadIdGuessingAlgorithm threadIdGuessingAlgorithm, + Clock clock) { + super(mapperFactory, sessionProvider, new NoMailboxPathLocker(), messageParser, messageIdFactory, annotationManager, eventBus, storeRightManager, quotaComponents, index, MailboxManagerConfiguration.DEFAULT, PreDeletionHooks.NO_PRE_DELETION_HOOK, threadIdGuessingAlgorithm, clock); @@ -71,7 +70,7 @@ public OpenJPAMailboxManager(MailboxSessionMapperFactory mapperFactory, @Override protected StoreMessageManager createMessageManager(Mailbox mailboxRow, MailboxSession session) { - return new OpenJPAMessageManager(getMapperFactory(), + return new PostgresMessageManager(getMapperFactory(), getMessageSearchIndex(), getEventBus(), getLocker(), diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMessageManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageManager.java similarity index 84% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMessageManager.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageManager.java index 81c2d955558..39b584529d0 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMessageManager.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageManager.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.postgres.openjpa; +package org.apache.james.mailbox.postgres.mail; import java.time.Clock; import java.util.EnumSet; @@ -50,21 +50,19 @@ import reactor.core.publisher.Mono; -/** - * OpenJPA implementation of Mailbox - */ -public class OpenJPAMessageManager extends StoreMessageManager { +public class PostgresMessageManager extends StoreMessageManager { + private final MailboxSessionMapperFactory mapperFactory; private final StoreRightManager storeRightManager; private final Mailbox mailbox; - public OpenJPAMessageManager(MailboxSessionMapperFactory mapperFactory, - MessageSearchIndex index, EventBus eventBus, - MailboxPathLocker locker, Mailbox mailbox, - QuotaManager quotaManager, QuotaRootResolver quotaRootResolver, - MessageId.Factory messageIdFactory, BatchSizes batchSizes, - StoreRightManager storeRightManager, ThreadIdGuessingAlgorithm threadIdGuessingAlgorithm, - Clock clock) { + public PostgresMessageManager(MailboxSessionMapperFactory mapperFactory, + MessageSearchIndex index, EventBus eventBus, + MailboxPathLocker locker, Mailbox mailbox, + QuotaManager quotaManager, QuotaRootResolver quotaRootResolver, + MessageId.Factory messageIdFactory, BatchSizes batchSizes, + StoreRightManager storeRightManager, ThreadIdGuessingAlgorithm threadIdGuessingAlgorithm, + Clock clock) { super(StoreMailboxManager.DEFAULT_NO_MESSAGE_CAPABILITIES, mapperFactory, index, eventBus, locker, mailbox, quotaManager, quotaRootResolver, batchSizes, storeRightManager, PreDeletionHooks.NO_PRE_DELETION_HOOK, new MessageStorer.WithoutAttachment(mapperFactory, messageIdFactory, new MessageFactory.StoreMessageFactory(), threadIdGuessingAlgorithm, clock)); @@ -73,12 +71,10 @@ public OpenJPAMessageManager(MailboxSessionMapperFactory mapperFactory, this.mailbox = mailbox; } - /** - * Support user flags - */ + @Override public Flags getPermanentFlags(MailboxSession session) { - Flags flags = super.getPermanentFlags(session); + Flags flags = super.getPermanentFlags(session); flags.add(Flags.Flag.USER); return flags; } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java index 88ac6baee40..ac5279062ec 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java @@ -20,7 +20,6 @@ package org.apache.james.mailbox.postgres.mail.dao; import static org.apache.james.backends.postgres.utils.PostgresUtils.UNIQUE_CONSTRAINT_VIOLATION_PREDICATE; -import static org.apache.james.mailbox.postgres.PostgresMailboxIdFaker.getMailboxId; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_ACL; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_HIGHEST_MODSEQ; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_ID; @@ -36,6 +35,7 @@ import java.util.LinkedHashMap; import java.util.Map; import java.util.Optional; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; @@ -200,6 +200,10 @@ public Flux getAll() { .map(this::asMailbox); } + private UUID asUUID(MailboxId mailboxId) { + return ((PostgresMailboxId)mailboxId).asUuid(); + } + private Mailbox asMailbox(Record record) { Mailbox mailbox = new Mailbox(new MailboxPath(record.get(MAILBOX_NAMESPACE), Username.of(record.get(USER_NAME)), record.get(MAILBOX_NAME)), UidValidity.of(record.get(MAILBOX_UID_VALIDITY)), PostgresMailboxId.of(record.get(MAILBOX_ID))); @@ -210,7 +214,7 @@ private Mailbox asMailbox(Record record) { public Mono findLastUidByMailboxId(MailboxId mailboxId) { return postgresExecutor.executeRow(dsl -> Mono.from(dsl.select(MAILBOX_LAST_UID) .from(TABLE_NAME) - .where(MAILBOX_ID.eq(getMailboxId(mailboxId).asUuid())))) + .where(MAILBOX_ID.eq(asUUID(mailboxId))))) .flatMap(record -> Mono.justOrEmpty(record.get(MAILBOX_LAST_UID))) .map(MessageUid::of); } @@ -218,7 +222,7 @@ public Mono findLastUidByMailboxId(MailboxId mailboxId) { public Mono incrementAndGetLastUid(MailboxId mailboxId, int count) { return postgresExecutor.executeRow(dsl -> Mono.from(dsl.update(TABLE_NAME) .set(MAILBOX_LAST_UID, coalesce(MAILBOX_LAST_UID, 0L).add(count)) - .where(MAILBOX_ID.eq(getMailboxId(mailboxId).asUuid())) + .where(MAILBOX_ID.eq(asUUID(mailboxId))) .returning(MAILBOX_LAST_UID))) .map(record -> record.get(MAILBOX_LAST_UID)) .map(MessageUid::of); @@ -228,7 +232,7 @@ public Mono incrementAndGetLastUid(MailboxId mailboxId, int count) { public Mono findHighestModSeqByMailboxId(MailboxId mailboxId) { return postgresExecutor.executeRow(dsl -> Mono.from(dsl.select(MAILBOX_HIGHEST_MODSEQ) .from(TABLE_NAME) - .where(MAILBOX_ID.eq(getMailboxId(mailboxId).asUuid())))) + .where(MAILBOX_ID.eq(asUUID(mailboxId))))) .flatMap(record -> Mono.justOrEmpty(record.get(MAILBOX_HIGHEST_MODSEQ))) .map(ModSeq::of); } @@ -236,7 +240,7 @@ public Mono findHighestModSeqByMailboxId(MailboxId mailboxId) { public Mono incrementAndGetModSeq(MailboxId mailboxId) { return postgresExecutor.executeRow(dsl -> Mono.from(dsl.update(TABLE_NAME) .set(MAILBOX_HIGHEST_MODSEQ, coalesce(MAILBOX_HIGHEST_MODSEQ, 0L).add(1)) - .where(MAILBOX_ID.eq(getMailboxId(mailboxId).asUuid())) + .where(MAILBOX_ID.eq(asUUID(mailboxId))) .returning(MAILBOX_HIGHEST_MODSEQ))) .map(record -> record.get(MAILBOX_HIGHEST_MODSEQ)) .map(ModSeq::of); @@ -247,7 +251,7 @@ public Mono> incrementAndGetLastUidAndModSeq(MailboxId return postgresExecutor.executeRow(dsl -> Mono.from(dsl.update(TABLE_NAME) .set(MAILBOX_LAST_UID, coalesce(MAILBOX_LAST_UID, 0L).add(increment)) .set(MAILBOX_HIGHEST_MODSEQ, coalesce(MAILBOX_HIGHEST_MODSEQ, 0L).add(increment)) - .where(MAILBOX_ID.eq(getMailboxId(mailboxId).asUuid())) + .where(MAILBOX_ID.eq(asUUID(mailboxId))) .returning(MAILBOX_LAST_UID, MAILBOX_HIGHEST_MODSEQ))) .map(record -> Pair.of(MessageUid.of(record.get(MAILBOX_LAST_UID)), ModSeq.of(record.get(MAILBOX_HIGHEST_MODSEQ)))); } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAAttachment.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAAttachment.java deleted file mode 100644 index d45005fce56..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAAttachment.java +++ /dev/null @@ -1,193 +0,0 @@ -/*************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.mail.model; - -import java.io.ByteArrayInputStream; -import java.io.InputStream; -import java.util.Arrays; -import java.util.Objects; -import java.util.Optional; - -import javax.persistence.Basic; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.FetchType; -import javax.persistence.GeneratedValue; -import javax.persistence.Id; -import javax.persistence.Lob; -import javax.persistence.NamedQuery; -import javax.persistence.Table; - -import org.apache.james.mailbox.model.AttachmentId; -import org.apache.james.mailbox.model.AttachmentMetadata; -import org.apache.james.mailbox.model.Cid; -import org.apache.james.mailbox.model.MessageAttachmentMetadata; -import org.apache.james.mailbox.store.mail.model.DefaultMessageId; - -@Entity(name = "Attachment") -@Table(name = "JAMES_ATTACHMENT") -@NamedQuery(name = "findAttachmentById", query = "SELECT attachment FROM Attachment attachment WHERE attachment.attachmentId = :idParam") -public class JPAAttachment { - - private static final String TOSTRING_SEPARATOR = " "; - private static final byte[] EMPTY_ARRAY = new byte[]{}; - - @Id - @GeneratedValue - @Column(name = "ATTACHMENT_ID", nullable = false) - private String attachmentId; - - @Basic(optional = false) - @Column(name = "TYPE", nullable = false) - private String type; - - @Basic(optional = false) - @Column(name = "SIZE", nullable = false) - private long size; - - @Basic(optional = false, fetch = FetchType.LAZY) - @Column(name = "CONTENT", length = 1048576000, nullable = false) - @Lob - private byte[] content; - - @Basic(optional = true) - @Column(name = "NAME") - private String name; - - @Basic(optional = true) - @Column(name = "CID") - private String cid; - - @Basic(optional = false) - @Column(name = "INLINE", nullable = false) - private boolean isInline; - - public JPAAttachment() { - } - - public JPAAttachment(MessageAttachmentMetadata messageAttachmentMetadata, byte[] bytes) { - setMetadata(messageAttachmentMetadata, bytes); - } - - public JPAAttachment(MessageAttachmentMetadata messageAttachmentMetadata) { - setMetadata(messageAttachmentMetadata, new byte[0]); - } - - private void setMetadata(MessageAttachmentMetadata messageAttachmentMetadata, byte[] bytes) { - this.name = messageAttachmentMetadata.getName().orElse(null); - messageAttachmentMetadata.getCid() - .ifPresentOrElse(c -> this.cid = c.getValue(), () -> this.cid = ""); - this.type = messageAttachmentMetadata.getAttachment().getType().asString(); - this.size = messageAttachmentMetadata.getAttachment().getSize(); - this.isInline = messageAttachmentMetadata.isInline(); - this.content = bytes; - } - - public AttachmentMetadata toAttachmentMetadata() { - return AttachmentMetadata.builder() - .attachmentId(AttachmentId.from(attachmentId)) - .messageId(new DefaultMessageId()) - .type(type) - .size(size) - .build(); - } - - public MessageAttachmentMetadata toMessageAttachmentMetadata() { - return MessageAttachmentMetadata.builder() - .attachment(toAttachmentMetadata()) - .name(Optional.ofNullable(name)) - .cid(Optional.of(Cid.from(cid))) - .isInline(isInline) - .build(); - } - - public String getAttachmentId() { - return attachmentId; - } - - public String getType() { - return type; - } - - public long getSize() { - return size; - } - - public String getName() { - return name; - } - - public boolean isInline() { - return isInline; - } - - public String getCid() { - return cid; - } - - public InputStream getContent() { - return new ByteArrayInputStream(Objects.requireNonNullElse(content, EMPTY_ARRAY)); - } - - public void setType(String type) { - this.type = type; - } - - public void setSize(long size) { - this.size = size; - } - - public void setContent(byte[] bytes) { - this.content = bytes; - } - - @Override - public String toString() { - return "Attachment ( " - + "attachmentId = " + this.attachmentId + TOSTRING_SEPARATOR - + "name = " + this.type + TOSTRING_SEPARATOR - + "type = " + this.type + TOSTRING_SEPARATOR - + "size = " + this.size + TOSTRING_SEPARATOR - + "cid = " + this.cid + TOSTRING_SEPARATOR - + "isInline = " + this.isInline + TOSTRING_SEPARATOR - + " )"; - } - - @Override - public final boolean equals(Object o) { - if (o instanceof JPAAttachment) { - JPAAttachment that = (JPAAttachment) o; - - return Objects.equals(this.size, that.size) - && Objects.equals(this.attachmentId, that.attachmentId) - && Objects.equals(this.cid, that.cid) - && Arrays.equals(this.content, that.content) - && Objects.equals(this.isInline, that.isInline) - && Objects.equals(this.name, that.name) - && Objects.equals(this.type, that.type); - } - return false; - } - - @Override - public final int hashCode() { - return Objects.hash(attachmentId, type, size, name, cid, isInline); - } -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAMailbox.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAMailbox.java deleted file mode 100644 index 9f0050f6223..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAMailbox.java +++ /dev/null @@ -1,205 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.mailbox.postgres.mail.model; - -import java.util.Objects; - -import javax.persistence.Basic; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.Id; -import javax.persistence.NamedQueries; -import javax.persistence.NamedQuery; -import javax.persistence.Table; - -import org.apache.james.core.Username; -import org.apache.james.mailbox.model.Mailbox; -import org.apache.james.mailbox.model.MailboxPath; -import org.apache.james.mailbox.model.UidValidity; -import org.apache.james.mailbox.postgres.JPAId; - -import com.google.common.annotations.VisibleForTesting; - -@Entity(name = "Mailbox") -@Table(name = "JAMES_MAILBOX") -@NamedQueries({ - @NamedQuery(name = "findMailboxById", - query = "SELECT mailbox FROM Mailbox mailbox WHERE mailbox.mailboxId = :idParam"), - @NamedQuery(name = "findMailboxByName", - query = "SELECT mailbox FROM Mailbox mailbox WHERE mailbox.name = :nameParam and mailbox.user is NULL and mailbox.namespace= :namespaceParam"), - @NamedQuery(name = "findMailboxByNameWithUser", - query = "SELECT mailbox FROM Mailbox mailbox WHERE mailbox.name = :nameParam and mailbox.user= :userParam and mailbox.namespace= :namespaceParam"), - @NamedQuery(name = "findMailboxWithNameLikeWithUser", - query = "SELECT mailbox FROM Mailbox mailbox WHERE mailbox.name LIKE :nameParam and mailbox.user= :userParam and mailbox.namespace= :namespaceParam"), - @NamedQuery(name = "findMailboxWithNameLike", - query = "SELECT mailbox FROM Mailbox mailbox WHERE mailbox.name LIKE :nameParam and mailbox.user is NULL and mailbox.namespace= :namespaceParam"), - @NamedQuery(name = "countMailboxesWithNameLikeWithUser", - query = "SELECT COUNT(mailbox) FROM Mailbox mailbox WHERE mailbox.name LIKE :nameParam and mailbox.user= :userParam and mailbox.namespace= :namespaceParam"), - @NamedQuery(name = "countMailboxesWithNameLike", - query = "SELECT COUNT(mailbox) FROM Mailbox mailbox WHERE mailbox.name LIKE :nameParam and mailbox.user is NULL and mailbox.namespace= :namespaceParam"), - @NamedQuery(name = "listMailboxes", - query = "SELECT mailbox FROM Mailbox mailbox"), - @NamedQuery(name = "findHighestModSeq", - query = "SELECT mailbox.highestModSeq FROM Mailbox mailbox WHERE mailbox.mailboxId = :idParam"), - @NamedQuery(name = "findLastUid", - query = "SELECT mailbox.lastUid FROM Mailbox mailbox WHERE mailbox.mailboxId = :idParam") -}) -public class JPAMailbox { - - private static final String TAB = " "; - - public static JPAMailbox from(Mailbox mailbox) { - return new JPAMailbox(mailbox); - } - - /** The value for the mailboxId field */ - @Id - @GeneratedValue - @Column(name = "MAILBOX_ID") - private long mailboxId; - - /** The value for the name field */ - @Basic(optional = false) - @Column(name = "MAILBOX_NAME", nullable = false, length = 200) - private String name; - - /** The value for the uidValidity field */ - @Basic(optional = false) - @Column(name = "MAILBOX_UID_VALIDITY", nullable = false) - private long uidValidity; - - @Basic(optional = true) - @Column(name = "USER_NAME", nullable = true, length = 200) - private String user; - - @Basic(optional = false) - @Column(name = "MAILBOX_NAMESPACE", nullable = false, length = 200) - private String namespace; - - @Basic(optional = false) - @Column(name = "MAILBOX_LAST_UID", nullable = true) - private long lastUid; - - @Basic(optional = false) - @Column(name = "MAILBOX_HIGHEST_MODSEQ", nullable = true) - private long highestModSeq; - - /** - * JPA only - */ - @Deprecated - public JPAMailbox() { - } - - public JPAMailbox(MailboxPath path, UidValidity uidValidity) { - this(path, uidValidity.asLong()); - } - - @VisibleForTesting - public JPAMailbox(MailboxPath path, long uidValidity) { - this.name = path.getName(); - this.user = path.getUser().asString(); - this.namespace = path.getNamespace(); - this.uidValidity = uidValidity; - } - - public JPAMailbox(Mailbox mailbox) { - this(mailbox.generateAssociatedPath(), mailbox.getUidValidity()); - } - - public JPAId getMailboxId() { - return JPAId.of(mailboxId); - } - - public long consumeUid() { - return ++lastUid; - } - - public long consumeModSeq() { - return ++highestModSeq; - } - - public Mailbox toMailbox() { - MailboxPath path = new MailboxPath(namespace, Username.of(user), name); - return new Mailbox(path, sanitizeUidValidity(), new JPAId(mailboxId)); - } - - private UidValidity sanitizeUidValidity() { - if (UidValidity.isValid(uidValidity)) { - return UidValidity.of(uidValidity); - } - UidValidity sanitizedUidValidity = UidValidity.generate(); - // Update storage layer thanks to JPA magics! - setUidValidity(sanitizedUidValidity.asLong()); - return sanitizedUidValidity; - } - - public void setMailboxId(long mailboxId) { - this.mailboxId = mailboxId; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getUser() { - return user; - } - - public void setUser(String user) { - this.user = user; - } - - public void setNamespace(String namespace) { - this.namespace = namespace; - } - - public void setUidValidity(long uidValidity) { - this.uidValidity = uidValidity; - } - - @Override - public String toString() { - return "Mailbox ( " - + "mailboxId = " + this.mailboxId + TAB - + "name = " + this.name + TAB - + "uidValidity = " + this.uidValidity + TAB - + " )"; - } - - @Override - public final boolean equals(Object o) { - if (o instanceof JPAMailbox) { - JPAMailbox that = (JPAMailbox) o; - - return Objects.equals(this.mailboxId, that.mailboxId); - } - return false; - } - - @Override - public final int hashCode() { - return Objects.hash(mailboxId); - } -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAMailboxAnnotation.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAMailboxAnnotation.java deleted file mode 100644 index d28080212cd..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAMailboxAnnotation.java +++ /dev/null @@ -1,99 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.mail.model; - -import javax.persistence.Basic; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.Id; -import javax.persistence.IdClass; -import javax.persistence.NamedQueries; -import javax.persistence.NamedQuery; -import javax.persistence.Table; - -import com.google.common.base.Objects; - -@Entity(name = "MailboxAnnotation") -@Table(name = "JAMES_MAILBOX_ANNOTATION") -@NamedQueries({ - @NamedQuery(name = "retrieveAllAnnotations", query = "SELECT annotation FROM MailboxAnnotation annotation WHERE annotation.mailboxId = :idParam"), - @NamedQuery(name = "retrieveByKey", query = "SELECT annotation FROM MailboxAnnotation annotation WHERE annotation.mailboxId = :idParam AND annotation.key = :keyParam"), - @NamedQuery(name = "countAnnotationsInMailbox", query = "SELECT COUNT(annotation) FROM MailboxAnnotation annotation WHERE annotation.mailboxId = :idParam"), - @NamedQuery(name = "retrieveByKeyLike", query = "SELECT annotation FROM MailboxAnnotation annotation WHERE annotation.mailboxId = :idParam AND annotation.key LIKE :keyParam")}) -@IdClass(JPAMailboxAnnotationId.class) -public class JPAMailboxAnnotation { - - public static final String MAILBOX_ID = "MAILBOX_ID"; - public static final String ANNOTATION_KEY = "ANNOTATION_KEY"; - public static final String VALUE = "VALUE"; - - @Id - @Column(name = MAILBOX_ID) - private long mailboxId; - - @Id - @Column(name = ANNOTATION_KEY, length = 200) - private String key; - - @Basic() - @Column(name = VALUE) - private String value; - - public JPAMailboxAnnotation() { - } - - public JPAMailboxAnnotation(long mailboxId, String key, String value) { - this.mailboxId = mailboxId; - this.key = key; - this.value = value; - } - - public long getMailboxId() { - return mailboxId; - } - - public String getKey() { - return key; - } - - public String getValue() { - return value; - } - - public void setValue(String value) { - this.value = value; - } - - @Override - public boolean equals(Object o) { - if (o instanceof JPAMailboxAnnotation) { - JPAMailboxAnnotation that = (JPAMailboxAnnotation) o; - return Objects.equal(this.mailboxId, that.mailboxId) - && Objects.equal(this.key, that.key) - && Objects.equal(this.value, that.value); - } - return false; - } - - @Override - public int hashCode() { - return Objects.hashCode(mailboxId, key, value); - } -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAMailboxAnnotationId.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAMailboxAnnotationId.java deleted file mode 100644 index 36e5afbb68f..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAMailboxAnnotationId.java +++ /dev/null @@ -1,62 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.mail.model; - -import java.io.Serializable; - -import javax.persistence.Embeddable; - -import com.google.common.base.Objects; - -@Embeddable -public final class JPAMailboxAnnotationId implements Serializable { - private long mailboxId; - private String key; - - public JPAMailboxAnnotationId(long mailboxId, String key) { - this.mailboxId = mailboxId; - this.key = key; - } - - public JPAMailboxAnnotationId() { - } - - public long getMailboxId() { - return mailboxId; - } - - public String getKey() { - return key; - } - - @Override - public boolean equals(Object o) { - if (o instanceof JPAMailboxAnnotationId) { - JPAMailboxAnnotationId that = (JPAMailboxAnnotationId) o; - return Objects.equal(this.mailboxId, that.mailboxId) && Objects.equal(this.key, that.key); - } - return false; - } - - @Override - public int hashCode() { - return Objects.hashCode(mailboxId, key); - } -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAProperty.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAProperty.java deleted file mode 100644 index 4724aea04d7..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAProperty.java +++ /dev/null @@ -1,129 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.mailbox.postgres.mail.model; - -import java.util.Objects; - -import javax.persistence.Basic; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.Id; -import javax.persistence.Table; - -import org.apache.james.mailbox.store.mail.model.Property; -import org.apache.openjpa.persistence.jdbc.Index; - -@Entity(name = "Property") -@Table(name = "JAMES_MAIL_PROPERTY") -public class JPAProperty { - - /** The system unique key */ - @Id - @GeneratedValue - @Column(name = "PROPERTY_ID", nullable = true) - private long id; - - /** Order within the list of properties */ - @Basic(optional = false) - @Column(name = "PROPERTY_LINE_NUMBER", nullable = false) - @Index(name = "INDEX_PROPERTY_LINE_NUMBER") - private int line; - - /** Local part of the name of this property */ - @Basic(optional = false) - @Column(name = "PROPERTY_LOCAL_NAME", nullable = false, length = 500) - private String localName; - - /** Namespace part of the name of this property */ - @Basic(optional = false) - @Column(name = "PROPERTY_NAME_SPACE", nullable = false, length = 500) - private String namespace; - - /** Value of this property */ - @Basic(optional = false) - @Column(name = "PROPERTY_VALUE", nullable = false, length = 1024) - private String value; - - /** - * @deprecated enhancement only - */ - @Deprecated - public JPAProperty() { - } - - /** - * Constructs a property. - * - * @param localName - * not null - * @param namespace - * not null - * @param value - * not null - */ - public JPAProperty(String namespace, String localName, String value, int order) { - super(); - this.localName = localName; - this.namespace = namespace; - this.value = value; - this.line = order; - } - - /** - * Constructs a property cloned from the given. - * - * @param property - * not null - */ - public JPAProperty(Property property, int order) { - this(property.getNamespace(), property.getLocalName(), property.getValue(), order); - } - - public Property toProperty() { - return new Property(namespace, localName, value); - } - - @Override - public final boolean equals(Object o) { - if (o instanceof JPAProperty) { - JPAProperty that = (JPAProperty) o; - - return Objects.equals(this.id, that.id); - } - return false; - } - - @Override - public final int hashCode() { - return Objects.hash(id); - } - - /** - * Constructs a String with all attributes in name = value - * format. - * - * @return a String representation of this object. - */ - public String toString() { - return "JPAProperty ( " + "id = " + this.id + " " + "localName = " + this.localName + " " - + "namespace = " + this.namespace + " " + "value = " + this.value + " )"; - } - -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAUserFlag.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAUserFlag.java deleted file mode 100644 index 3e1736d79d1..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/JPAUserFlag.java +++ /dev/null @@ -1,120 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.mailbox.postgres.mail.model; - -import javax.persistence.Basic; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.GeneratedValue; -import javax.persistence.Id; -import javax.persistence.Table; - -@Entity(name = "UserFlag") -@Table(name = "JAMES_MAIL_USERFLAG") -public class JPAUserFlag { - - - /** The system unique key */ - @Id - @GeneratedValue - @Column(name = "USERFLAG_ID", nullable = true) - private long id; - - /** Local part of the name of this property */ - @Basic(optional = false) - @Column(name = "USERFLAG_NAME", nullable = false, length = 500) - private String name; - - - /** - * @deprecated enhancement only - */ - @Deprecated - public JPAUserFlag() { - - } - - /** - * Constructs a User Flag. - * @param name not null - */ - public JPAUserFlag(String name) { - super(); - this.name = name; - } - - /** - * Constructs a User Flag, cloned from the given. - * @param flag not null - */ - public JPAUserFlag(JPAUserFlag flag) { - this(flag.getName()); - } - - - - /** - * Gets the name. - * @return not null - */ - public String getName() { - return name; - } - - @Override - public int hashCode() { - final int PRIME = 31; - int result = 1; - result = PRIME * result + (int) (id ^ (id >>> 32)); - return result; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null) { - return false; - } - if (getClass() != obj.getClass()) { - return false; - } - final JPAUserFlag other = (JPAUserFlag) obj; - if (id != other.id) { - return false; - } - return true; - } - - /** - * Constructs a String with all attributes - * in name = value format. - * - * @return a String representation - * of this object. - */ - public String toString() { - return "JPAUserFlag ( " - + "id = " + this.id + " " - + "name = " + this.name - + " )"; - } - -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/AbstractJPAMailboxMessage.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/AbstractJPAMailboxMessage.java deleted file mode 100644 index 040bf064765..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/AbstractJPAMailboxMessage.java +++ /dev/null @@ -1,579 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.mailbox.postgres.mail.model.openjpa; - -import java.io.IOException; -import java.io.InputStream; -import java.io.SequenceInputStream; -import java.io.Serializable; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.atomic.AtomicInteger; - -import javax.mail.Flags; -import javax.persistence.Basic; -import javax.persistence.CascadeType; -import javax.persistence.Column; -import javax.persistence.Embeddable; -import javax.persistence.FetchType; -import javax.persistence.Id; -import javax.persistence.IdClass; -import javax.persistence.ManyToOne; -import javax.persistence.MappedSuperclass; -import javax.persistence.NamedQuery; -import javax.persistence.OneToMany; -import javax.persistence.OrderBy; - -import org.apache.james.mailbox.MessageUid; -import org.apache.james.mailbox.ModSeq; -import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.model.AttachmentId; -import org.apache.james.mailbox.model.ComposedMessageId; -import org.apache.james.mailbox.model.ComposedMessageIdWithMetaData; -import org.apache.james.mailbox.model.MessageAttachmentMetadata; -import org.apache.james.mailbox.model.MessageId; -import org.apache.james.mailbox.model.ParsedAttachment; -import org.apache.james.mailbox.model.ThreadId; -import org.apache.james.mailbox.postgres.JPAId; -import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; -import org.apache.james.mailbox.postgres.mail.model.JPAProperty; -import org.apache.james.mailbox.postgres.mail.model.JPAUserFlag; -import org.apache.james.mailbox.store.mail.model.DefaultMessageId; -import org.apache.james.mailbox.store.mail.model.DelegatingMailboxMessage; -import org.apache.james.mailbox.store.mail.model.FlagsFactory; -import org.apache.james.mailbox.store.mail.model.MailboxMessage; -import org.apache.james.mailbox.store.mail.model.Property; -import org.apache.james.mailbox.store.mail.model.impl.MessageParser; -import org.apache.james.mailbox.store.mail.model.impl.Properties; -import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; -import org.apache.openjpa.persistence.jdbc.ElementJoinColumn; -import org.apache.openjpa.persistence.jdbc.ElementJoinColumns; -import org.apache.openjpa.persistence.jdbc.Index; - -import com.github.fge.lambdas.Throwing; -import com.google.common.base.Objects; -import com.google.common.collect.ImmutableList; - -/** - * Abstract base class for JPA based implementations of - * {@link DelegatingMailboxMessage} - */ -@IdClass(AbstractJPAMailboxMessage.MailboxIdUidKey.class) -@NamedQuery(name = "findRecentMessageUidsInMailbox", query = "SELECT message.uid FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.recent = TRUE ORDER BY message.uid ASC") -@NamedQuery(name = "listUidsInMailbox", query = "SELECT message.uid FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam ORDER BY message.uid ASC") -@NamedQuery(name = "findUnseenMessagesInMailboxOrderByUid", query = "SELECT message FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.seen = FALSE ORDER BY message.uid ASC") -@NamedQuery(name = "findMessagesInMailbox", query = "SELECT message FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam ORDER BY message.uid ASC") -@NamedQuery(name = "findMessagesInMailboxBetweenUIDs", query = "SELECT message FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.uid BETWEEN :fromParam AND :toParam ORDER BY message.uid ASC") -@NamedQuery(name = "findMessagesInMailboxWithUID", query = "SELECT message FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.uid=:uidParam ORDER BY message.uid ASC") -@NamedQuery(name = "findMessagesInMailboxAfterUID", query = "SELECT message FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.uid>=:uidParam ORDER BY message.uid ASC") -@NamedQuery(name = "findDeletedMessagesInMailbox", query = "SELECT message FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.deleted=TRUE ORDER BY message.uid ASC") -@NamedQuery(name = "findDeletedMessagesInMailboxBetweenUIDs", query = "SELECT message FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.uid BETWEEN :fromParam AND :toParam AND message.deleted=TRUE ORDER BY message.uid ASC") -@NamedQuery(name = "findDeletedMessagesInMailboxWithUID", query = "SELECT message FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.uid=:uidParam AND message.deleted=TRUE ORDER BY message.uid ASC") -@NamedQuery(name = "findDeletedMessagesInMailboxAfterUID", query = "SELECT message FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.uid>=:uidParam AND message.deleted=TRUE ORDER BY message.uid ASC") - -@NamedQuery(name = "deleteMessagesInMailbox", query = "DELETE FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam") -@NamedQuery(name = "deleteMessagesInMailboxBetweenUIDs", query = "DELETE FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.uid BETWEEN :fromParam AND :toParam") -@NamedQuery(name = "deleteMessagesInMailboxWithUID", query = "DELETE FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.uid=:uidParam") -@NamedQuery(name = "deleteMessagesInMailboxAfterUID", query = "DELETE FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.uid>=:uidParam") - -@NamedQuery(name = "countUnseenMessagesInMailbox", query = "SELECT COUNT(message) FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam AND message.seen=FALSE") -@NamedQuery(name = "countMessagesInMailbox", query = "SELECT COUNT(message) FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam") -@NamedQuery(name = "deleteMessages", query = "DELETE FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam") -@NamedQuery(name = "findLastUidInMailbox", query = "SELECT message.uid FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam ORDER BY message.uid DESC") -@NamedQuery(name = "findHighestModSeqInMailbox", query = "SELECT message.modSeq FROM MailboxMessage message WHERE message.mailbox.mailboxId = :idParam ORDER BY message.modSeq DESC") -@MappedSuperclass -public abstract class AbstractJPAMailboxMessage implements MailboxMessage { - private static final String TOSTRING_SEPARATOR = " "; - - /** - * Identifies composite key - */ - @Embeddable - public static class MailboxIdUidKey implements Serializable { - - private static final long serialVersionUID = 7847632032426660997L; - - public MailboxIdUidKey() { - } - - /** - * The value for the mailbox field - */ - public long mailbox; - - /** - * The value for the uid field - */ - public long uid; - - @Override - public int hashCode() { - final int PRIME = 31; - int result = 1; - result = PRIME * result + (int) (mailbox ^ (mailbox >>> 32)); - result = PRIME * result + (int) (uid ^ (uid >>> 32)); - return result; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null) { - return false; - } - if (getClass() != obj.getClass()) { - return false; - } - final MailboxIdUidKey other = (MailboxIdUidKey) obj; - if (mailbox != other.mailbox) { - return false; - } - return uid == other.uid; - } - - } - - /** - * The value for the mailboxId field - */ - @Id - @ManyToOne(cascade = {CascadeType.PERSIST, CascadeType.REFRESH, CascadeType.MERGE}, fetch = FetchType.EAGER) - @Column(name = "MAILBOX_ID", nullable = true) - private JPAMailbox mailbox; - - /** - * The value for the uid field - */ - @Id - @Column(name = "MAIL_UID") - private long uid; - - /** - * The value for the modSeq field - */ - @Index - @Column(name = "MAIL_MODSEQ") - private long modSeq; - - /** - * The value for the internalDate field - */ - @Basic(optional = false) - @Column(name = "MAIL_DATE") - private Date internalDate; - - /** - * The value for the answered field - */ - @Basic(optional = false) - @Column(name = "MAIL_IS_ANSWERED", nullable = false) - private boolean answered = false; - - /** - * The value for the deleted field - */ - @Basic(optional = false) - @Column(name = "MAIL_IS_DELETED", nullable = false) - @Index - private boolean deleted = false; - - /** - * The value for the draft field - */ - @Basic(optional = false) - @Column(name = "MAIL_IS_DRAFT", nullable = false) - private boolean draft = false; - - /** - * The value for the flagged field - */ - @Basic(optional = false) - @Column(name = "MAIL_IS_FLAGGED", nullable = false) - private boolean flagged = false; - - /** - * The value for the recent field - */ - @Basic(optional = false) - @Column(name = "MAIL_IS_RECENT", nullable = false) - @Index - private boolean recent = false; - - /** - * The value for the seen field - */ - @Basic(optional = false) - @Column(name = "MAIL_IS_SEEN", nullable = false) - @Index - private boolean seen = false; - - /** - * The first body octet - */ - @Basic(optional = false) - @Column(name = "MAIL_BODY_START_OCTET", nullable = false) - private int bodyStartOctet; - - /** - * Number of octets in the full document content - */ - @Basic(optional = false) - @Column(name = "MAIL_CONTENT_OCTETS_COUNT", nullable = false) - private long contentOctets; - - /** - * MIME media type - */ - @Basic(optional = true) - @Column(name = "MAIL_MIME_TYPE", nullable = true, length = 200) - private String mediaType; - - /** - * MIME subtype - */ - @Basic(optional = true) - @Column(name = "MAIL_MIME_SUBTYPE", nullable = true, length = 200) - private String subType; - - /** - * THE CRFL count when this document is textual, null otherwise - */ - @Basic(optional = true) - @Column(name = "MAIL_TEXTUAL_LINE_COUNT", nullable = true) - private Long textualLineCount; - - /** - * Metadata for this message - */ - @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER) - @OrderBy("line") - @ElementJoinColumns({@ElementJoinColumn(name = "MAILBOX_ID", referencedColumnName = "MAILBOX_ID"), - @ElementJoinColumn(name = "MAIL_UID", referencedColumnName = "MAIL_UID")}) - private List properties; - - @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER, orphanRemoval = true) - @OrderBy("id") - @ElementJoinColumns({@ElementJoinColumn(name = "MAILBOX_ID", referencedColumnName = "MAILBOX_ID"), - @ElementJoinColumn(name = "MAIL_UID", referencedColumnName = "MAIL_UID")}) - private List userFlags; - - - protected AbstractJPAMailboxMessage() { - } - - protected AbstractJPAMailboxMessage(JPAMailbox mailbox, Date internalDate, Flags flags, long contentOctets, - int bodyStartOctet, PropertyBuilder propertyBuilder) { - this.mailbox = mailbox; - this.internalDate = internalDate; - userFlags = new ArrayList<>(); - - setFlags(flags); - this.contentOctets = contentOctets; - this.bodyStartOctet = bodyStartOctet; - Properties properties = propertyBuilder.build(); - this.textualLineCount = properties.getTextualLineCount(); - this.mediaType = properties.getMediaType(); - this.subType = properties.getSubType(); - final List propertiesAsList = properties.toProperties(); - this.properties = new ArrayList<>(propertiesAsList.size()); - int order = 0; - for (Property property : propertiesAsList) { - this.properties.add(new JPAProperty(property, order++)); - } - - } - - /** - * Constructs a copy of the given message. All properties are cloned except - * mailbox and UID. - * - * @param mailbox new mailbox - * @param uid new UID - * @param modSeq new modSeq - * @param original message to be copied, not null - */ - protected AbstractJPAMailboxMessage(JPAMailbox mailbox, MessageUid uid, ModSeq modSeq, MailboxMessage original) - throws MailboxException { - super(); - this.mailbox = mailbox; - this.uid = uid.asLong(); - this.modSeq = modSeq.asLong(); - this.userFlags = new ArrayList<>(); - setFlags(original.createFlags()); - - // A copy of a message is recent - // See MAILBOX-85 - this.recent = true; - - this.contentOctets = original.getFullContentOctets(); - this.bodyStartOctet = (int) (original.getFullContentOctets() - original.getBodyOctets()); - this.internalDate = original.getInternalDate(); - - this.textualLineCount = original.getTextualLineCount(); - this.mediaType = original.getMediaType(); - this.subType = original.getSubType(); - final List properties = original.getProperties().toProperties(); - this.properties = new ArrayList<>(properties.size()); - int order = 0; - for (Property property : properties) { - this.properties.add(new JPAProperty(property, order++)); - } - } - - @Override - public int hashCode() { - return Objects.hashCode(getMailboxId().getRawId(), uid); - } - - @Override - public boolean equals(Object obj) { - if (obj instanceof AbstractJPAMailboxMessage) { - AbstractJPAMailboxMessage other = (AbstractJPAMailboxMessage) obj; - return Objects.equal(getMailboxId(), other.getMailboxId()) - && Objects.equal(uid, other.getUid()); - } - return false; - } - - @Override - public ComposedMessageIdWithMetaData getComposedMessageIdWithMetaData() { - return ComposedMessageIdWithMetaData.builder() - .modSeq(getModSeq()) - .flags(createFlags()) - .composedMessageId(new ComposedMessageId(mailbox.getMailboxId(), getMessageId(), MessageUid.of(uid))) - .threadId(getThreadId()) - .build(); - } - - @Override - public ModSeq getModSeq() { - return ModSeq.of(modSeq); - } - - @Override - public void setModSeq(ModSeq modSeq) { - this.modSeq = modSeq.asLong(); - } - - @Override - public String getMediaType() { - return mediaType; - } - - @Override - public String getSubType() { - return subType; - } - - /** - * Gets a read-only list of meta-data properties. For properties with - * multiple values, this list will contain several enteries with the same - * namespace and local name. - * - * @return unmodifiable list of meta-data, not null - */ - @Override - public Properties getProperties() { - return new PropertyBuilder(properties.stream() - .map(JPAProperty::toProperty) - .collect(ImmutableList.toImmutableList())) - .build(); - } - - @Override - public Long getTextualLineCount() { - return textualLineCount; - } - - @Override - public long getFullContentOctets() { - return contentOctets; - } - - protected int getBodyStartOctet() { - return bodyStartOctet; - } - - @Override - public Date getInternalDate() { - return internalDate; - } - - @Override - public JPAId getMailboxId() { - return getMailbox().getMailboxId(); - } - - @Override - public MessageUid getUid() { - return MessageUid.of(uid); - } - - @Override - public boolean isAnswered() { - return answered; - } - - @Override - public boolean isDeleted() { - return deleted; - } - - @Override - public boolean isDraft() { - return draft; - } - - @Override - public boolean isFlagged() { - return flagged; - } - - @Override - public boolean isRecent() { - return recent; - } - - @Override - public boolean isSeen() { - return seen; - } - - @Override - public void setUid(MessageUid uid) { - this.uid = uid.asLong(); - } - - @Override - public void setSaveDate(Date saveDate) { - - } - - @Override - public long getHeaderOctets() { - return bodyStartOctet; - } - - @Override - public void setFlags(Flags flags) { - answered = flags.contains(Flags.Flag.ANSWERED); - deleted = flags.contains(Flags.Flag.DELETED); - draft = flags.contains(Flags.Flag.DRAFT); - flagged = flags.contains(Flags.Flag.FLAGGED); - recent = flags.contains(Flags.Flag.RECENT); - seen = flags.contains(Flags.Flag.SEEN); - - String[] userflags = flags.getUserFlags(); - userFlags.clear(); - for (String userflag : userflags) { - userFlags.add(new JPAUserFlag(userflag)); - } - } - - /** - * Utility getter on Mailbox. - */ - public JPAMailbox getMailbox() { - return mailbox; - } - - @Override - public Flags createFlags() { - return FlagsFactory.createFlags(this, createUserFlags()); - } - - protected String[] createUserFlags() { - return userFlags.stream() - .map(JPAUserFlag::getName) - .toArray(String[]::new); - } - - /** - * Utility setter on Mailbox. - */ - public void setMailbox(JPAMailbox mailbox) { - this.mailbox = mailbox; - } - - @Override - public InputStream getFullContent() throws IOException { - return new SequenceInputStream(getHeaderContent(), getBodyContent()); - } - - @Override - public long getBodyOctets() { - return getFullContentOctets() - getBodyStartOctet(); - } - - @Override - public MessageId getMessageId() { - return new DefaultMessageId(); - } - - @Override - public ThreadId getThreadId() { - return new ThreadId(getMessageId()); - } - - @Override - public Optional getSaveDate() { - return Optional.empty(); - } - - public String toString() { - return "message(" - + "mailboxId = " + this.getMailboxId() + TOSTRING_SEPARATOR - + "uid = " + this.uid + TOSTRING_SEPARATOR - + "internalDate = " + this.internalDate + TOSTRING_SEPARATOR - + "answered = " + this.answered + TOSTRING_SEPARATOR - + "deleted = " + this.deleted + TOSTRING_SEPARATOR - + "draft = " + this.draft + TOSTRING_SEPARATOR - + "flagged = " + this.flagged + TOSTRING_SEPARATOR - + "recent = " + this.recent + TOSTRING_SEPARATOR - + "seen = " + this.seen + TOSTRING_SEPARATOR - + " )"; - } - - @Override - public List getAttachments() { - try { - AtomicInteger counter = new AtomicInteger(0); - MessageParser.ParsingResult parsingResult = new MessageParser().retrieveAttachments(getFullContent()); - ImmutableList result = parsingResult - .getAttachments() - .stream() - .map(Throwing.function( - attachmentMetadata -> attachmentMetadata.asMessageAttachment(generateFixedAttachmentId(counter.incrementAndGet()), getMessageId())) - .sneakyThrow()) - .collect(ImmutableList.toImmutableList()); - parsingResult.dispose(); - return result; - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - private AttachmentId generateFixedAttachmentId(int position) { - return AttachmentId.from(getMailboxId().serialize() + "-" + getUid().asLong() + "-" + position); - } -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAMailboxMessage.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAMailboxMessage.java deleted file mode 100644 index 41fa1949c56..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAMailboxMessage.java +++ /dev/null @@ -1,126 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.mailbox.postgres.mail.model.openjpa; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.util.Date; - -import javax.mail.Flags; -import javax.persistence.Basic; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.FetchType; -import javax.persistence.Lob; -import javax.persistence.Table; - -import org.apache.commons.io.IOUtils; -import org.apache.commons.io.input.BoundedInputStream; -import org.apache.james.mailbox.MessageUid; -import org.apache.james.mailbox.ModSeq; -import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.model.Content; -import org.apache.james.mailbox.model.Mailbox; -import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; -import org.apache.james.mailbox.store.mail.model.MailboxMessage; -import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; - -import com.google.common.annotations.VisibleForTesting; - -@Entity(name = "MailboxMessage") -@Table(name = "JAMES_MAIL") -public class JPAMailboxMessage extends AbstractJPAMailboxMessage { - - private static final byte[] EMPTY_ARRAY = new byte[] {}; - - /** The value for the body field. Lazy loaded */ - /** We use a max length to represent 1gb data. Thats prolly overkill, but who knows */ - @Basic(optional = false, fetch = FetchType.LAZY) - @Column(name = "MAIL_BYTES", length = 1048576000, nullable = false) - @Lob private byte[] body; - - - /** The value for the header field. Lazy loaded */ - /** We use a max length to represent 10mb data. Thats prolly overkill, but who knows */ - @Basic(optional = false, fetch = FetchType.LAZY) - @Column(name = "HEADER_BYTES", length = 10485760, nullable = false) - @Lob private byte[] header; - - - public JPAMailboxMessage() { - - } - - @VisibleForTesting - protected JPAMailboxMessage(byte[] header, byte[] body) { - this.header = header; - this.body = body; - } - - public JPAMailboxMessage(JPAMailbox mailbox, Date internalDate, int size, Flags flags, Content content, int bodyStartOctet, PropertyBuilder propertyBuilder) throws MailboxException { - super(mailbox, internalDate, flags, size, bodyStartOctet, propertyBuilder); - try { - int headerEnd = bodyStartOctet; - if (headerEnd < 0) { - headerEnd = 0; - } - InputStream stream = content.getInputStream(); - this.header = IOUtils.toByteArray(new BoundedInputStream(stream, headerEnd)); - this.body = IOUtils.toByteArray(stream); - - } catch (IOException e) { - throw new MailboxException("Unable to parse message",e); - } - } - - /** - * Create a copy of the given message - */ - public JPAMailboxMessage(JPAMailbox mailbox, MessageUid uid, ModSeq modSeq, MailboxMessage message) throws MailboxException { - super(mailbox, uid, modSeq, message); - try { - this.body = IOUtils.toByteArray(message.getBodyContent()); - this.header = IOUtils.toByteArray(message.getHeaderContent()); - } catch (IOException e) { - throw new MailboxException("Unable to parse message",e); - } - } - - @Override - public InputStream getBodyContent() throws IOException { - if (body == null) { - return new ByteArrayInputStream(EMPTY_ARRAY); - } - return new ByteArrayInputStream(body); - } - - @Override - public InputStream getHeaderContent() throws IOException { - if (header == null) { - return new ByteArrayInputStream(EMPTY_ARRAY); - } - return new ByteArrayInputStream(header); - } - - @Override - public MailboxMessage copy(Mailbox mailbox) throws MailboxException { - return new JPAMailboxMessage(JPAMailbox.from(mailbox), getUid(), getModSeq(), this); - } -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMessageFactory.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMessageFactory.java deleted file mode 100644 index 56027df0fb8..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/openjpa/OpenJPAMessageFactory.java +++ /dev/null @@ -1,56 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.openjpa; - -import java.util.Date; -import java.util.List; - -import javax.mail.Flags; - -import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.model.Content; -import org.apache.james.mailbox.model.Mailbox; -import org.apache.james.mailbox.model.MessageAttachmentMetadata; -import org.apache.james.mailbox.model.MessageId; -import org.apache.james.mailbox.model.ThreadId; -import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; -import org.apache.james.mailbox.postgres.mail.model.openjpa.AbstractJPAMailboxMessage; -import org.apache.james.mailbox.postgres.mail.model.openjpa.JPAMailboxMessage; -import org.apache.james.mailbox.store.MessageFactory; -import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; - -public class OpenJPAMessageFactory implements MessageFactory { - private final AdvancedFeature feature; - - public OpenJPAMessageFactory(AdvancedFeature feature) { - this.feature = feature; - } - - public enum AdvancedFeature { - None, - Streaming, - Encryption - } - - @Override - public AbstractJPAMailboxMessage createMessage(MessageId messageId, ThreadId threadId, Mailbox mailbox, Date internalDate, Date saveDate, int size, int bodyStartOctet, Content content, Flags flags, PropertyBuilder propertyBuilder, List attachments) throws MailboxException { - return new JPAMailboxMessage(JPAMailbox.from(mailbox), internalDate, size, flags, content, bodyStartOctet, propertyBuilder); - } -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/JpaCurrentQuotaManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/JpaCurrentQuotaManager.java deleted file mode 100644 index 626078d1851..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/JpaCurrentQuotaManager.java +++ /dev/null @@ -1,131 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.quota; - -import java.util.Optional; - -import javax.inject.Inject; -import javax.persistence.EntityManager; -import javax.persistence.EntityManagerFactory; - -import org.apache.james.backends.jpa.EntityManagerUtils; -import org.apache.james.backends.jpa.TransactionRunner; -import org.apache.james.core.quota.QuotaCountUsage; -import org.apache.james.core.quota.QuotaSizeUsage; -import org.apache.james.mailbox.model.CurrentQuotas; -import org.apache.james.mailbox.model.QuotaOperation; -import org.apache.james.mailbox.model.QuotaRoot; -import org.apache.james.mailbox.postgres.quota.model.JpaCurrentQuota; -import org.apache.james.mailbox.quota.CurrentQuotaManager; - -import reactor.core.publisher.Mono; - -public class JpaCurrentQuotaManager implements CurrentQuotaManager { - - public static final long NO_MESSAGES = 0L; - public static final long NO_STORED_BYTES = 0L; - - private final EntityManagerFactory entityManagerFactory; - private final TransactionRunner transactionRunner; - - @Inject - public JpaCurrentQuotaManager(EntityManagerFactory entityManagerFactory) { - this.entityManagerFactory = entityManagerFactory; - this.transactionRunner = new TransactionRunner(entityManagerFactory); - } - - @Override - public Mono getCurrentMessageCount(QuotaRoot quotaRoot) { - EntityManager entityManager = entityManagerFactory.createEntityManager(); - - return Mono.fromCallable(() -> Optional.ofNullable(retrieveUserQuota(entityManager, quotaRoot)) - .map(JpaCurrentQuota::getMessageCount) - .orElse(QuotaCountUsage.count(NO_STORED_BYTES))) - .doFinally(any -> EntityManagerUtils.safelyClose(entityManager)); - } - - @Override - public Mono getCurrentStorage(QuotaRoot quotaRoot) { - EntityManager entityManager = entityManagerFactory.createEntityManager(); - - return Mono.fromCallable(() -> Optional.ofNullable(retrieveUserQuota(entityManager, quotaRoot)) - .map(JpaCurrentQuota::getSize) - .orElse(QuotaSizeUsage.size(NO_STORED_BYTES))) - .doFinally(any -> EntityManagerUtils.safelyClose(entityManager)); - } - - public Mono getCurrentQuotas(QuotaRoot quotaRoot) { - EntityManager entityManager = entityManagerFactory.createEntityManager(); - return Mono.fromCallable(() -> Optional.ofNullable(retrieveUserQuota(entityManager, quotaRoot)) - .map(jpaCurrentQuota -> new CurrentQuotas(jpaCurrentQuota.getMessageCount(), jpaCurrentQuota.getSize())) - .orElse(CurrentQuotas.emptyQuotas())) - .doFinally(any -> EntityManagerUtils.safelyClose(entityManager)); - } - - @Override - public Mono increase(QuotaOperation quotaOperation) { - return Mono.fromRunnable(() -> - transactionRunner.run( - entityManager -> { - QuotaRoot quotaRoot = quotaOperation.quotaRoot(); - - JpaCurrentQuota jpaCurrentQuota = Optional.ofNullable(retrieveUserQuota(entityManager, quotaRoot)) - .orElse(new JpaCurrentQuota(quotaRoot.getValue(), NO_MESSAGES, NO_STORED_BYTES)); - - entityManager.merge(new JpaCurrentQuota(quotaRoot.getValue(), - jpaCurrentQuota.getMessageCount().asLong() + quotaOperation.count().asLong(), - jpaCurrentQuota.getSize().asLong() + quotaOperation.size().asLong())); - })); - } - - @Override - public Mono decrease(QuotaOperation quotaOperation) { - return Mono.fromRunnable(() -> - transactionRunner.run( - entityManager -> { - QuotaRoot quotaRoot = quotaOperation.quotaRoot(); - - JpaCurrentQuota jpaCurrentQuota = Optional.ofNullable(retrieveUserQuota(entityManager, quotaRoot)) - .orElse(new JpaCurrentQuota(quotaRoot.getValue(), NO_MESSAGES, NO_STORED_BYTES)); - - entityManager.merge(new JpaCurrentQuota(quotaRoot.getValue(), - jpaCurrentQuota.getMessageCount().asLong() - quotaOperation.count().asLong(), - jpaCurrentQuota.getSize().asLong() - quotaOperation.size().asLong())); - })); - } - - @Override - public Mono setCurrentQuotas(QuotaOperation quotaOperation) { - return Mono.fromCallable(() -> getCurrentQuotas(quotaOperation.quotaRoot())) - .flatMap(storedQuotas -> Mono.fromRunnable(() -> - transactionRunner.run( - entityManager -> { - if (!storedQuotas.equals(CurrentQuotas.from(quotaOperation))) { - entityManager.merge(new JpaCurrentQuota(quotaOperation.quotaRoot().getValue(), - quotaOperation.count().asLong(), - quotaOperation.size().asLong())); - } - }))); - } - - private JpaCurrentQuota retrieveUserQuota(EntityManager entityManager, QuotaRoot quotaRoot) { - return entityManager.find(JpaCurrentQuota.class, quotaRoot.getValue()); - } -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/PostgresCurrentQuotaManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/PostgresCurrentQuotaManager.java index 6e7a2ee33e7..9e44f7ab92e 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/PostgresCurrentQuotaManager.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/PostgresCurrentQuotaManager.java @@ -23,6 +23,8 @@ import java.util.Optional; import java.util.function.Predicate; +import javax.inject.Inject; + import org.apache.james.backends.postgres.quota.PostgresQuotaCurrentValueDAO; import org.apache.james.core.quota.QuotaComponent; import org.apache.james.core.quota.QuotaCountUsage; @@ -40,6 +42,8 @@ public class PostgresCurrentQuotaManager implements CurrentQuotaManager { private final PostgresQuotaCurrentValueDAO currentValueDao; + @Inject + public PostgresCurrentQuotaManager(PostgresQuotaCurrentValueDAO currentValueDao) { this.currentValueDao = currentValueDao; } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/JpaCurrentQuota.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/JpaCurrentQuota.java deleted file mode 100644 index d9648610c3a..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/JpaCurrentQuota.java +++ /dev/null @@ -1,69 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.quota.model; - -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.Id; -import javax.persistence.Table; - -import org.apache.james.core.quota.QuotaCountUsage; -import org.apache.james.core.quota.QuotaSizeUsage; - -@Entity(name = "CurrentQuota") -@Table(name = "JAMES_QUOTA_CURRENTQUOTA") -public class JpaCurrentQuota { - - @Id - @Column(name = "CURRENTQUOTA_QUOTAROOT") - private String quotaRoot; - - @Column(name = "CURRENTQUOTA_MESSAGECOUNT") - private long messageCount; - - @Column(name = "CURRENTQUOTA_SIZE") - private long size; - - public JpaCurrentQuota() { - } - - public JpaCurrentQuota(String quotaRoot, long messageCount, long size) { - this.quotaRoot = quotaRoot; - this.messageCount = messageCount; - this.size = size; - } - - public QuotaCountUsage getMessageCount() { - return QuotaCountUsage.count(messageCount); - } - - public QuotaSizeUsage getSize() { - return QuotaSizeUsage.size(size); - } - - @Override - public String toString() { - return "JpaCurrentQuota{" + - "quotaRoot='" + quotaRoot + '\'' + - ", messageCount=" + messageCount + - ", size=" + size + - '}'; - } -} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxFixture.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxFixture.java index c254cc88d89..6c34d837d7d 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxFixture.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxFixture.java @@ -21,14 +21,6 @@ import java.util.List; -import org.apache.james.mailbox.postgres.mail.model.JPAAttachment; -import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; -import org.apache.james.mailbox.postgres.mail.model.JPAMailboxAnnotation; -import org.apache.james.mailbox.postgres.mail.model.JPAProperty; -import org.apache.james.mailbox.postgres.mail.model.JPAUserFlag; -import org.apache.james.mailbox.postgres.mail.model.openjpa.AbstractJPAMailboxMessage; -import org.apache.james.mailbox.postgres.mail.model.openjpa.JPAMailboxMessage; -import org.apache.james.mailbox.postgres.quota.model.JpaCurrentQuota; import org.apache.james.mailbox.postgres.quota.model.MaxDomainMessageCount; import org.apache.james.mailbox.postgres.quota.model.MaxDomainStorage; import org.apache.james.mailbox.postgres.quota.model.MaxGlobalMessageCount; @@ -40,33 +32,13 @@ public interface JPAMailboxFixture { - List> MAILBOX_PERSISTANCE_CLASSES = ImmutableList.of( - JPAMailbox.class, - AbstractJPAMailboxMessage.class, - JPAMailboxMessage.class, - JPAProperty.class, - JPAUserFlag.class, - JPAMailboxAnnotation.class, - JPAAttachment.class - ); - List> QUOTA_PERSISTANCE_CLASSES = ImmutableList.of( MaxGlobalMessageCount.class, MaxGlobalStorage.class, MaxDomainStorage.class, MaxDomainMessageCount.class, MaxUserMessageCount.class, - MaxUserStorage.class, - JpaCurrentQuota.class - ); - - List MAILBOX_TABLE_NAMES = ImmutableList.of( - "JAMES_MAIL_USERFLAG", - "JAMES_MAIL_PROPERTY", - "JAMES_MAILBOX_ANNOTATION", - "JAMES_MAILBOX", - "JAMES_MAIL", - "JAMES_ATTACHMENT"); + MaxUserStorage.class); List QUOTA_TABLES_NAMES = ImmutableList.of( "JAMES_MAX_GLOBAL_MESSAGE_COUNT", @@ -74,7 +46,6 @@ public interface JPAMailboxFixture { "JAMES_MAX_USER_MESSAGE_COUNT", "JAMES_MAX_USER_STORAGE", "JAMES_MAX_DOMAIN_MESSAGE_COUNT", - "JAMES_MAX_DOMAIN_STORAGE", - "JAMES_QUOTA_CURRENTQUOTA" + "JAMES_MAX_DOMAIN_STORAGE" ); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerProvider.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java similarity index 73% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerProvider.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java index d6100b2ade8..d7eca629f62 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerProvider.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java @@ -19,13 +19,14 @@ package org.apache.james.mailbox.postgres; +import java.time.Clock; import java.time.Instant; -import javax.persistence.EntityManagerFactory; - -import org.apache.james.backends.jpa.JPAConfiguration; -import org.apache.james.backends.jpa.JpaTestCluster; import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BucketName; +import org.apache.james.blob.api.HashBlobId; +import org.apache.james.blob.memory.MemoryBlobStoreDAO; import org.apache.james.events.EventBusTestFixture; import org.apache.james.events.InVMEventBus; import org.apache.james.events.MemoryEventDeadLetters; @@ -34,38 +35,28 @@ import org.apache.james.mailbox.Authorizator; import org.apache.james.mailbox.acl.MailboxACLResolver; import org.apache.james.mailbox.acl.UnionMailboxACLResolver; -import org.apache.james.mailbox.postgres.mail.JPAModSeqProvider; -import org.apache.james.mailbox.postgres.mail.JPAUidProvider; -import org.apache.james.mailbox.postgres.openjpa.OpenJPAMailboxManager; +import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; +import org.apache.james.mailbox.store.MailboxSessionMapperFactory; import org.apache.james.mailbox.store.SessionProviderImpl; import org.apache.james.mailbox.store.StoreMailboxAnnotationManager; import org.apache.james.mailbox.store.StoreRightManager; import org.apache.james.mailbox.store.extractor.DefaultTextExtractor; import org.apache.james.mailbox.store.mail.NaiveThreadIdGuessingAlgorithm; -import org.apache.james.mailbox.store.mail.model.DefaultMessageId; import org.apache.james.mailbox.store.mail.model.impl.MessageParser; import org.apache.james.mailbox.store.quota.QuotaComponents; import org.apache.james.mailbox.store.search.MessageSearchIndex; import org.apache.james.mailbox.store.search.SimpleMessageSearchIndex; import org.apache.james.metrics.tests.RecordingMetricFactory; +import org.apache.james.server.blob.deduplication.DeDuplicationBlobStore; import org.apache.james.utils.UpdatableTickingClock; -public class JpaMailboxManagerProvider { +public class PostgresMailboxManagerProvider { private static final int LIMIT_ANNOTATIONS = 3; private static final int LIMIT_ANNOTATION_SIZE = 30; - public static OpenJPAMailboxManager provideMailboxManager(JpaTestCluster jpaTestCluster, PostgresExtension postgresExtension) { - EntityManagerFactory entityManagerFactory = jpaTestCluster.getEntityManagerFactory(); - - JPAConfiguration jpaConfiguration = JPAConfiguration.builder() - .driverName("driverName") - .driverURL("driverUrl") - .attachmentStorage(true) - .build(); - - PostgresMailboxSessionMapperFactory mf = new PostgresMailboxSessionMapperFactory(entityManagerFactory, new JPAUidProvider(entityManagerFactory), - new JPAModSeqProvider(entityManagerFactory), jpaConfiguration, postgresExtension.getExecutorFactory()); + public static PostgresMailboxManager provideMailboxManager(PostgresExtension postgresExtension) { + MailboxSessionMapperFactory mf = provideMailboxSessionMapperFactory(postgresExtension); MailboxACLResolver aclResolver = new UnionMailboxACLResolver(); MessageParser messageParser = new MessageParser(); @@ -81,9 +72,20 @@ public static OpenJPAMailboxManager provideMailboxManager(JpaTestCluster jpaTest QuotaComponents quotaComponents = QuotaComponents.disabled(sessionProvider, mf); MessageSearchIndex index = new SimpleMessageSearchIndex(mf, mf, new DefaultTextExtractor(), new JPAAttachmentContentLoader()); - return new OpenJPAMailboxManager(mf, sessionProvider, - messageParser, new DefaultMessageId.Factory(), + return new PostgresMailboxManager((PostgresMailboxSessionMapperFactory) mf, sessionProvider, + messageParser, new PostgresMessageId.Factory(), eventBus, annotationManager, storeRightManager, quotaComponents, index, new NaiveThreadIdGuessingAlgorithm(), new UpdatableTickingClock(Instant.now())); } + + public static MailboxSessionMapperFactory provideMailboxSessionMapperFactory(PostgresExtension postgresExtension) { + BlobId.Factory blobIdFactory = new HashBlobId.Factory(); + DeDuplicationBlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory); + + return new PostgresMailboxSessionMapperFactory( + postgresExtension.getExecutorFactory(), + Clock.systemUTC(), + blobStore, + blobIdFactory); + } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerStressTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerStressTest.java similarity index 68% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerStressTest.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerStressTest.java index ea1ce952e42..c61c56eb3a6 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JpaMailboxManagerStressTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerStressTest.java @@ -21,26 +21,23 @@ import java.util.Optional; -import org.apache.james.backends.jpa.JpaTestCluster; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.events.EventBus; import org.apache.james.mailbox.MailboxManagerStressContract; -import org.apache.james.mailbox.postgres.openjpa.OpenJPAMailboxManager; -import org.junit.jupiter.api.AfterEach; +import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.RegisterExtension; -class JpaMailboxManagerStressTest implements MailboxManagerStressContract { +class PostgresMailboxManagerStressTest implements MailboxManagerStressContract { @RegisterExtension static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); - static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); - Optional openJPAMailboxManager = Optional.empty(); + Optional mailboxManager = Optional.empty(); @Override - public OpenJPAMailboxManager getManager() { - return openJPAMailboxManager.get(); + public PostgresMailboxManager getManager() { + return mailboxManager.get(); } @Override @@ -50,13 +47,9 @@ public EventBus retrieveEventBus() { @BeforeEach void setUp() { - if (!openJPAMailboxManager.isPresent()) { - openJPAMailboxManager = Optional.of(JpaMailboxManagerProvider.provideMailboxManager(JPA_TEST_CLUSTER, postgresExtension)); + if (mailboxManager.isEmpty()) { + mailboxManager = Optional.of(PostgresMailboxManagerProvider.provideMailboxManager(postgresExtension)); } } - @AfterEach - void tearDown() { - JPA_TEST_CLUSTER.clear(JPAMailboxFixture.MAILBOX_TABLE_NAMES); - } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerTest.java similarity index 65% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxManagerTest.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerTest.java index bc98c13a50c..d7fc4f355e1 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxManagerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerTest.java @@ -20,20 +20,17 @@ import java.util.Optional; -import org.apache.james.backends.jpa.JpaTestCluster; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.events.EventBus; import org.apache.james.mailbox.MailboxManagerTest; import org.apache.james.mailbox.SubscriptionManager; -import org.apache.james.mailbox.postgres.openjpa.OpenJPAMailboxManager; +import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; import org.apache.james.mailbox.store.StoreSubscriptionManager; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -class JPAMailboxManagerTest extends MailboxManagerTest { +class PostgresMailboxManagerTest extends MailboxManagerTest { @Disabled("JPAMailboxManager is using DefaultMessageId which doesn't support full feature of a messageId, which is an essential" + " element of the Vault") @@ -41,18 +38,22 @@ class JPAMailboxManagerTest extends MailboxManagerTest { class HookTests { } + @Disabled("//TODO https://github.com/apache/james-project/pull/1822") + @Nested + class AnnotationTests { + } + @RegisterExtension static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); - static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); - Optional openJPAMailboxManager = Optional.empty(); - + Optional mailboxManager = Optional.empty(); + @Override - protected OpenJPAMailboxManager provideMailboxManager() { - if (!openJPAMailboxManager.isPresent()) { - openJPAMailboxManager = Optional.of(JpaMailboxManagerProvider.provideMailboxManager(JPA_TEST_CLUSTER, postgresExtension)); + protected PostgresMailboxManager provideMailboxManager() { + if (mailboxManager.isEmpty()) { + mailboxManager = Optional.of(PostgresMailboxManagerProvider.provideMailboxManager(postgresExtension)); } - return openJPAMailboxManager.get(); + return mailboxManager.get(); } @Override @@ -60,26 +61,9 @@ protected SubscriptionManager provideSubscriptionManager() { return new StoreSubscriptionManager(provideMailboxManager().getMapperFactory(), provideMailboxManager().getMapperFactory(), provideMailboxManager().getEventBus()); } - @AfterEach - void tearDownJpa() { - JPA_TEST_CLUSTER.clear(JPAMailboxFixture.MAILBOX_TABLE_NAMES); - } - - @Disabled("MAILBOX-353 Creating concurrently mailboxes with the same parents with JPA") - @Test - @Override - public void creatingConcurrentlyMailboxesWithSameParentShouldNotFail() { - - } - - @Nested - @Disabled("JPA does not support saveDate.") - class SaveDateTests { - - } @Override - protected EventBus retrieveEventBus(OpenJPAMailboxManager mailboxManager) { + protected EventBus retrieveEventBus(PostgresMailboxManager mailboxManager) { return mailboxManager.getEventBus(); } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresSubscriptionManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresSubscriptionManagerTest.java index c68ed09b84d..356f08ef1c4 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresSubscriptionManagerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresSubscriptionManagerTest.java @@ -18,10 +18,6 @@ ****************************************************************/ package org.apache.james.mailbox.postgres; -import javax.persistence.EntityManagerFactory; - -import org.apache.james.backends.jpa.JPAConfiguration; -import org.apache.james.backends.jpa.JpaTestCluster; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.events.EventBusTestFixture; import org.apache.james.events.InVMEventBus; @@ -29,21 +25,16 @@ import org.apache.james.events.delivery.InVmEventDelivery; import org.apache.james.mailbox.SubscriptionManager; import org.apache.james.mailbox.SubscriptionManagerContract; -import org.apache.james.mailbox.postgres.mail.JPAModSeqProvider; -import org.apache.james.mailbox.postgres.mail.JPAUidProvider; -import org.apache.james.mailbox.postgres.user.PostgresSubscriptionModule; +import org.apache.james.mailbox.store.MailboxSessionMapperFactory; import org.apache.james.mailbox.store.StoreSubscriptionManager; import org.apache.james.metrics.tests.RecordingMetricFactory; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.RegisterExtension; class PostgresSubscriptionManagerTest implements SubscriptionManagerContract { @RegisterExtension - static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresSubscriptionModule.MODULE); - - static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); SubscriptionManager subscriptionManager; @@ -54,25 +45,10 @@ public SubscriptionManager getSubscriptionManager() { @BeforeEach void setUp() { - EntityManagerFactory entityManagerFactory = JPA_TEST_CLUSTER.getEntityManagerFactory(); - - JPAConfiguration jpaConfiguration = JPAConfiguration.builder() - .driverName("driverName") - .driverURL("driverUrl") - .build(); - - PostgresMailboxSessionMapperFactory mapperFactory = new PostgresMailboxSessionMapperFactory(entityManagerFactory, - new JPAUidProvider(entityManagerFactory), - new JPAModSeqProvider(entityManagerFactory), - jpaConfiguration, - postgresExtension.getExecutorFactory()); + MailboxSessionMapperFactory mapperFactory = PostgresMailboxManagerProvider.provideMailboxSessionMapperFactory(postgresExtension); InVMEventBus eventBus = new InVMEventBus(new InVmEventDelivery(new RecordingMetricFactory()), EventBusTestFixture.RETRY_BACKOFF_CONFIGURATION, new MemoryEventDeadLetters()); subscriptionManager = new StoreSubscriptionManager(mapperFactory, mapperFactory, eventBus); } - @AfterEach - void close() { - JPA_TEST_CLUSTER.clear(JPAMailboxFixture.MAILBOX_TABLE_NAMES); - } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JPAAttachmentMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JPAAttachmentMapperTest.java deleted file mode 100644 index e6ffcf1526d..00000000000 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JPAAttachmentMapperTest.java +++ /dev/null @@ -1,102 +0,0 @@ -/************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - **************************************************************/ - - - -package org.apache.james.mailbox.postgres.mail; - -import java.nio.charset.StandardCharsets; - -import org.apache.james.backends.jpa.JpaTestCluster; -import org.apache.james.mailbox.postgres.JPAMailboxFixture; -import org.apache.james.mailbox.model.AttachmentMetadata; -import org.apache.james.mailbox.model.ContentType; -import org.apache.james.mailbox.model.MessageId; -import org.apache.james.mailbox.model.ParsedAttachment; -import org.apache.james.mailbox.store.mail.AttachmentMapper; -import org.apache.james.mailbox.store.mail.model.AttachmentMapperTest; -import org.apache.james.mailbox.store.mail.model.DefaultMessageId; - -import com.google.common.collect.ImmutableList; -import com.google.common.io.ByteSource; - -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.AssertionsForClassTypes.tuple; - -class JPAAttachmentMapperTest extends AttachmentMapperTest { - - private static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); - - @AfterEach - void cleanUp() { - JPA_TEST_CLUSTER.clear(JPAMailboxFixture.MAILBOX_TABLE_NAMES); - } - - @Override - protected AttachmentMapper createAttachmentMapper() { - return new TransactionalAttachmentMapper(new JPAAttachmentMapper(JPA_TEST_CLUSTER.getEntityManagerFactory())); - } - - @Override - protected MessageId generateMessageId() { - return new DefaultMessageId.Factory().generate(); - } - - @Test - @Override - public void getAttachmentsShouldReturnTheAttachmentsWhenSome() throws Exception { - //Given - ContentType content1 = ContentType.of("content"); - byte[] bytes1 = "payload" .getBytes(StandardCharsets.UTF_8); - ContentType content2 = ContentType.of("content"); - byte[] bytes2 = "payload" .getBytes(StandardCharsets.UTF_8); - - MessageId messageId1 = generateMessageId(); - AttachmentMetadata stored1 = attachmentMapper.storeAttachments(ImmutableList.of(ParsedAttachment.builder() - .contentType(content1) - .content(ByteSource.wrap(bytes1)) - .noName() - .noCid() - .inline(false)), messageId1).get(0) - .getAttachment(); - AttachmentMetadata stored2 = attachmentMapper.storeAttachments(ImmutableList.of(ParsedAttachment.builder() - .contentType(content2) - .content(ByteSource.wrap(bytes2)) - .noName() - .noCid() - .inline(false)), messageId1).get(0) - .getAttachment(); - - // JPA does not support MessageId - assertThat(attachmentMapper.getAttachments(ImmutableList.of(stored1.getAttachmentId(), stored2.getAttachmentId()))) - .extracting( - AttachmentMetadata::getAttachmentId, - AttachmentMetadata::getSize, - AttachmentMetadata::getType - ) - .contains( - tuple(stored1.getAttachmentId(), stored1.getSize(), stored1.getType()), - tuple(stored2.getAttachmentId(), stored2.getSize(), stored2.getType()) - ); - } - -} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JPAMapperProvider.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JPAMapperProvider.java deleted file mode 100644 index 1fab3e4b8b5..00000000000 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JPAMapperProvider.java +++ /dev/null @@ -1,122 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.mail; - -import java.util.List; -import java.util.concurrent.ThreadLocalRandom; - -import javax.persistence.EntityManagerFactory; - -import org.apache.commons.lang3.NotImplementedException; -import org.apache.james.backends.jpa.JPAConfiguration; -import org.apache.james.backends.jpa.JpaTestCluster; -import org.apache.james.mailbox.MessageUid; -import org.apache.james.mailbox.ModSeq; -import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.postgres.JPAId; -import org.apache.james.mailbox.model.Mailbox; -import org.apache.james.mailbox.model.MailboxId; -import org.apache.james.mailbox.model.MessageId; -import org.apache.james.mailbox.store.mail.AttachmentMapper; -import org.apache.james.mailbox.store.mail.MailboxMapper; -import org.apache.james.mailbox.store.mail.MessageIdMapper; -import org.apache.james.mailbox.store.mail.MessageMapper; -import org.apache.james.mailbox.store.mail.model.DefaultMessageId; -import org.apache.james.mailbox.store.mail.model.MapperProvider; - -import com.google.common.collect.ImmutableList; - -public class JPAMapperProvider implements MapperProvider { - - private final JpaTestCluster jpaTestCluster; - - public JPAMapperProvider(JpaTestCluster jpaTestCluster) { - this.jpaTestCluster = jpaTestCluster; - } - - @Override - public MailboxMapper createMailboxMapper() { - return new TransactionalMailboxMapper(new JPAMailboxMapper(jpaTestCluster.getEntityManagerFactory())); - } - - @Override - public MessageMapper createMessageMapper() { - EntityManagerFactory entityManagerFactory = jpaTestCluster.getEntityManagerFactory(); - - JPAConfiguration jpaConfiguration = JPAConfiguration.builder() - .driverName("driverName") - .driverURL("driverUrl") - .attachmentStorage(true) - .build(); - - JPAMessageMapper messageMapper = new JPAMessageMapper(new JPAUidProvider(entityManagerFactory), - new JPAModSeqProvider(entityManagerFactory), - entityManagerFactory, - jpaConfiguration); - - return new TransactionalMessageMapper(messageMapper); - } - - @Override - public AttachmentMapper createAttachmentMapper() throws MailboxException { - return new TransactionalAttachmentMapper(new JPAAttachmentMapper(jpaTestCluster.getEntityManagerFactory())); - } - - @Override - public MailboxId generateId() { - return JPAId.of(Math.abs(ThreadLocalRandom.current().nextInt())); - } - - @Override - public MessageId generateMessageId() { - return new DefaultMessageId.Factory().generate(); - } - - @Override - public boolean supportPartialAttachmentFetch() { - return false; - } - - @Override - public List getSupportedCapabilities() { - return ImmutableList.of(Capabilities.ANNOTATION, Capabilities.MAILBOX, Capabilities.MESSAGE, Capabilities.MOVE, Capabilities.ATTACHMENT); - } - - @Override - public MessageIdMapper createMessageIdMapper() throws MailboxException { - throw new NotImplementedException("not implemented"); - } - - @Override - public MessageUid generateMessageUid() { - throw new NotImplementedException("not implemented"); - } - - @Override - public ModSeq generateModSeq(Mailbox mailbox) throws MailboxException { - throw new NotImplementedException("not implemented"); - } - - @Override - public ModSeq highestModSeq(Mailbox mailbox) throws MailboxException { - throw new NotImplementedException("not implemented"); - } - -} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaAnnotationMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaAnnotationMapperTest.java deleted file mode 100644 index 667714a800f..00000000000 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaAnnotationMapperTest.java +++ /dev/null @@ -1,52 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.mail; - -import java.util.concurrent.atomic.AtomicInteger; - -import org.apache.james.backends.jpa.JpaTestCluster; -import org.apache.james.mailbox.postgres.JPAId; -import org.apache.james.mailbox.postgres.JPAMailboxFixture; -import org.apache.james.mailbox.model.MailboxId; -import org.apache.james.mailbox.store.mail.AnnotationMapper; -import org.apache.james.mailbox.store.mail.model.AnnotationMapperTest; -import org.junit.jupiter.api.AfterEach; - -class JpaAnnotationMapperTest extends AnnotationMapperTest { - - static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); - - final AtomicInteger counter = new AtomicInteger(); - - @AfterEach - void tearDown() { - JPA_TEST_CLUSTER.clear(JPAMailboxFixture.MAILBOX_TABLE_NAMES); - } - - @Override - protected AnnotationMapper createAnnotationMapper() { - return new TransactionalAnnotationMapper(new JPAAnnotationMapper(JPA_TEST_CLUSTER.getEntityManagerFactory())); - } - - @Override - protected MailboxId generateMailboxId() { - return JPAId.of(counter.incrementAndGet()); - } -} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaMailboxMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaMailboxMapperTest.java deleted file mode 100644 index c48dbe4f42f..00000000000 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/JpaMailboxMapperTest.java +++ /dev/null @@ -1,90 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.mail; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.util.concurrent.atomic.AtomicInteger; - -import javax.persistence.EntityManager; - -import org.apache.james.backends.jpa.JpaTestCluster; -import org.apache.james.mailbox.postgres.JPAId; -import org.apache.james.mailbox.postgres.JPAMailboxFixture; -import org.apache.james.mailbox.postgres.mail.model.JPAMailbox; -import org.apache.james.mailbox.model.Mailbox; -import org.apache.james.mailbox.model.MailboxId; -import org.apache.james.mailbox.store.mail.MailboxMapper; -import org.apache.james.mailbox.store.mail.model.MailboxMapperTest; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; - -class JpaMailboxMapperTest extends MailboxMapperTest { - - static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES); - - final AtomicInteger counter = new AtomicInteger(); - - @Override - protected MailboxMapper createMailboxMapper() { - return new TransactionalMailboxMapper(new JPAMailboxMapper(JPA_TEST_CLUSTER.getEntityManagerFactory())); - } - - @Override - protected MailboxId generateId() { - return JPAId.of(counter.incrementAndGet()); - } - - @AfterEach - void cleanUp() { - JPA_TEST_CLUSTER.clear(JPAMailboxFixture.MAILBOX_TABLE_NAMES); - } - - @Test - void invalidUidValidityShouldBeSanitized() throws Exception { - EntityManager entityManager = JPA_TEST_CLUSTER.getEntityManagerFactory().createEntityManager(); - - entityManager.getTransaction().begin(); - JPAMailbox jpaMailbox = new JPAMailbox(benwaInboxPath, -1L);// set an invalid uid validity - jpaMailbox.setUidValidity(-1L); - entityManager.persist(jpaMailbox); - entityManager.getTransaction().commit(); - - Mailbox readMailbox = mailboxMapper.findMailboxByPath(benwaInboxPath).block(); - - assertThat(readMailbox.getUidValidity().isValid()).isTrue(); - } - - @Test - void uidValiditySanitizingShouldPersistTheSanitizedUidValidity() throws Exception { - EntityManager entityManager = JPA_TEST_CLUSTER.getEntityManagerFactory().createEntityManager(); - - entityManager.getTransaction().begin(); - JPAMailbox jpaMailbox = new JPAMailbox(benwaInboxPath, -1L);// set an invalid uid validity - jpaMailbox.setUidValidity(-1L); - entityManager.persist(jpaMailbox); - entityManager.getTransaction().commit(); - - Mailbox readMailbox1 = mailboxMapper.findMailboxByPath(benwaInboxPath).block(); - Mailbox readMailbox2 = mailboxMapper.findMailboxByPath(benwaInboxPath).block(); - - assertThat(readMailbox1.getUidValidity()).isEqualTo(readMailbox2.getUidValidity()); - } -} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalAnnotationMapper.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalAnnotationMapper.java deleted file mode 100644 index ff419a36f9b..00000000000 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalAnnotationMapper.java +++ /dev/null @@ -1,87 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.mail; - -import java.util.List; -import java.util.Set; - -import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.model.MailboxAnnotation; -import org.apache.james.mailbox.model.MailboxAnnotationKey; -import org.apache.james.mailbox.model.MailboxId; -import org.apache.james.mailbox.postgres.mail.JPAAnnotationMapper; -import org.apache.james.mailbox.store.mail.AnnotationMapper; -import org.apache.james.mailbox.store.transaction.Mapper; - -public class TransactionalAnnotationMapper implements AnnotationMapper { - private final JPAAnnotationMapper wrapped; - - public TransactionalAnnotationMapper(JPAAnnotationMapper wrapped) { - this.wrapped = wrapped; - } - - @Override - public List getAllAnnotations(MailboxId mailboxId) { - return wrapped.getAllAnnotations(mailboxId); - } - - @Override - public List getAnnotationsByKeys(MailboxId mailboxId, Set keys) { - return wrapped.getAnnotationsByKeys(mailboxId, keys); - } - - @Override - public List getAnnotationsByKeysWithOneDepth(MailboxId mailboxId, Set keys) { - return wrapped.getAnnotationsByKeysWithOneDepth(mailboxId, keys); - } - - @Override - public List getAnnotationsByKeysWithAllDepth(MailboxId mailboxId, Set keys) { - return wrapped.getAnnotationsByKeysWithAllDepth(mailboxId, keys); - } - - @Override - public void deleteAnnotation(final MailboxId mailboxId, final MailboxAnnotationKey key) { - try { - wrapped.execute(Mapper.toTransaction(() -> wrapped.deleteAnnotation(mailboxId, key))); - } catch (MailboxException e) { - throw new RuntimeException(e); - } - } - - @Override - public void insertAnnotation(final MailboxId mailboxId, final MailboxAnnotation mailboxAnnotation) { - try { - wrapped.execute(Mapper.toTransaction(() -> wrapped.insertAnnotation(mailboxId, mailboxAnnotation))); - } catch (MailboxException e) { - throw new RuntimeException(e); - } - } - - @Override - public boolean exist(MailboxId mailboxId, MailboxAnnotation mailboxAnnotation) { - return wrapped.exist(mailboxId, mailboxAnnotation); - } - - @Override - public int countAnnotations(MailboxId mailboxId) { - return wrapped.countAnnotations(mailboxId); - } -} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalAttachmentMapper.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalAttachmentMapper.java deleted file mode 100644 index 6fc5b805424..00000000000 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalAttachmentMapper.java +++ /dev/null @@ -1,79 +0,0 @@ -/*************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.mail; - -import java.io.InputStream; -import java.util.Collection; -import java.util.List; - -import org.apache.james.mailbox.exception.AttachmentNotFoundException; -import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.model.AttachmentId; -import org.apache.james.mailbox.model.AttachmentMetadata; -import org.apache.james.mailbox.model.MessageAttachmentMetadata; -import org.apache.james.mailbox.model.MessageId; -import org.apache.james.mailbox.model.ParsedAttachment; -import org.apache.james.mailbox.postgres.mail.JPAAttachmentMapper; -import org.apache.james.mailbox.store.mail.AttachmentMapper; - -import reactor.core.publisher.Mono; - -public class TransactionalAttachmentMapper implements AttachmentMapper { - private final JPAAttachmentMapper attachmentMapper; - - public TransactionalAttachmentMapper(JPAAttachmentMapper attachmentMapper) { - this.attachmentMapper = attachmentMapper; - } - - @Override - public InputStream loadAttachmentContent(AttachmentId attachmentId) { - return attachmentMapper.loadAttachmentContent(attachmentId); - } - - @Override - public Mono loadAttachmentContentReactive(AttachmentId attachmentId) { - return attachmentMapper.executeReactive(attachmentMapper.loadAttachmentContentReactive(attachmentId)); - } - - @Override - public AttachmentMetadata getAttachment(AttachmentId attachmentId) throws AttachmentNotFoundException { - return attachmentMapper.getAttachment(attachmentId); - } - - @Override - public Mono getAttachmentReactive(AttachmentId attachmentId) { - return attachmentMapper.executeReactive(attachmentMapper.getAttachmentReactive(attachmentId)); - } - - @Override - public List getAttachments(Collection attachmentIds) { - return attachmentMapper.getAttachments(attachmentIds); - } - - @Override - public List storeAttachments(Collection attachments, MessageId ownerMessageId) throws MailboxException { - return attachmentMapper.execute(() -> attachmentMapper.storeAttachments(attachments, ownerMessageId)); - } - - @Override - public Mono> storeAttachmentsReactive(Collection attachments, MessageId ownerMessageId) { - return attachmentMapper.executeReactive(attachmentMapper.storeAttachmentsReactive(attachments, ownerMessageId)); - } -} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalMailboxMapper.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalMailboxMapper.java deleted file mode 100644 index 36608def8db..00000000000 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalMailboxMapper.java +++ /dev/null @@ -1,99 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.mail; - -import org.apache.james.core.Username; -import org.apache.james.mailbox.acl.ACLDiff; -import org.apache.james.mailbox.model.Mailbox; -import org.apache.james.mailbox.model.MailboxACL; -import org.apache.james.mailbox.model.MailboxACL.Right; -import org.apache.james.mailbox.model.MailboxId; -import org.apache.james.mailbox.model.MailboxPath; -import org.apache.james.mailbox.model.UidValidity; -import org.apache.james.mailbox.model.search.MailboxQuery; -import org.apache.james.mailbox.postgres.mail.JPAMailboxMapper; -import org.apache.james.mailbox.store.mail.MailboxMapper; - -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - -public class TransactionalMailboxMapper implements MailboxMapper { - private final JPAMailboxMapper wrapped; - - public TransactionalMailboxMapper(JPAMailboxMapper wrapped) { - this.wrapped = wrapped; - } - - @Override - public Mono create(MailboxPath mailboxPath, UidValidity uidValidity) { - return wrapped.executeReactive(wrapped.create(mailboxPath, uidValidity)); - } - - @Override - public Mono rename(Mailbox mailbox) { - return wrapped.executeReactive(wrapped.rename(mailbox)); - } - - @Override - public Mono delete(Mailbox mailbox) { - return wrapped.executeReactive(wrapped.delete(mailbox)); - } - - @Override - public Mono findMailboxByPath(MailboxPath mailboxPath) { - return wrapped.findMailboxByPath(mailboxPath); - } - - @Override - public Mono findMailboxById(MailboxId mailboxId) { - return wrapped.findMailboxById(mailboxId); - } - - @Override - public Flux findMailboxWithPathLike(MailboxQuery.UserBound query) { - return wrapped.findMailboxWithPathLike(query); - } - - @Override - public Mono hasChildren(Mailbox mailbox, char delimiter) { - return wrapped.hasChildren(mailbox, delimiter); - } - - @Override - public Mono updateACL(Mailbox mailbox, MailboxACL.ACLCommand mailboxACLCommand) { - return wrapped.updateACL(mailbox, mailboxACLCommand); - } - - @Override - public Mono setACL(Mailbox mailbox, MailboxACL mailboxACL) { - return wrapped.setACL(mailbox, mailboxACL); - } - - @Override - public Flux list() { - return wrapped.list(); - } - - @Override - public Flux findNonPersonalMailboxes(Username userName, Right right) { - return wrapped.findNonPersonalMailboxes(userName, right); - } - -} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalMessageMapper.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalMessageMapper.java deleted file mode 100644 index f779af3c30f..00000000000 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/TransactionalMessageMapper.java +++ /dev/null @@ -1,147 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.mail; - -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -import javax.mail.Flags; - -import org.apache.james.mailbox.MessageUid; -import org.apache.james.mailbox.ModSeq; -import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.model.Mailbox; -import org.apache.james.mailbox.model.MailboxCounters; -import org.apache.james.mailbox.model.MessageMetaData; -import org.apache.james.mailbox.model.MessageRange; -import org.apache.james.mailbox.model.UpdatedFlags; -import org.apache.james.mailbox.postgres.mail.JPAMessageMapper; -import org.apache.james.mailbox.store.FlagsUpdateCalculator; -import org.apache.james.mailbox.store.mail.MessageMapper; -import org.apache.james.mailbox.store.mail.model.MailboxMessage; -import org.apache.james.mailbox.store.transaction.Mapper; - -import reactor.core.publisher.Flux; - -public class TransactionalMessageMapper implements MessageMapper { - private final JPAMessageMapper messageMapper; - - public TransactionalMessageMapper(JPAMessageMapper messageMapper) { - this.messageMapper = messageMapper; - } - - @Override - public MailboxCounters getMailboxCounters(Mailbox mailbox) throws MailboxException { - return MailboxCounters.builder() - .mailboxId(mailbox.getMailboxId()) - .count(countMessagesInMailbox(mailbox)) - .unseen(countUnseenMessagesInMailbox(mailbox)) - .build(); - } - - @Override - public Flux listAllMessageUids(Mailbox mailbox) { - return messageMapper.listAllMessageUids(mailbox); - } - - @Override - public Iterator findInMailbox(Mailbox mailbox, MessageRange set, FetchType type, int limit) - throws MailboxException { - return messageMapper.findInMailbox(mailbox, set, type, limit); - } - - @Override - public List retrieveMessagesMarkedForDeletion(Mailbox mailbox, MessageRange messageRange) throws MailboxException { - return messageMapper.execute( - () -> messageMapper.retrieveMessagesMarkedForDeletion(mailbox, messageRange)); - } - - @Override - public Map deleteMessages(Mailbox mailbox, List uids) throws MailboxException { - return messageMapper.execute( - () -> messageMapper.deleteMessages(mailbox, uids)); - } - - @Override - public long countMessagesInMailbox(Mailbox mailbox) throws MailboxException { - return messageMapper.countMessagesInMailbox(mailbox); - } - - private long countUnseenMessagesInMailbox(Mailbox mailbox) throws MailboxException { - return messageMapper.countUnseenMessagesInMailbox(mailbox); - } - - @Override - public void delete(final Mailbox mailbox, final MailboxMessage message) throws MailboxException { - messageMapper.execute(Mapper.toTransaction(() -> messageMapper.delete(mailbox, message))); - } - - @Override - public MessageUid findFirstUnseenMessageUid(Mailbox mailbox) throws MailboxException { - return messageMapper.findFirstUnseenMessageUid(mailbox); - } - - @Override - public List findRecentMessageUidsInMailbox(Mailbox mailbox) throws MailboxException { - return messageMapper.findRecentMessageUidsInMailbox(mailbox); - } - - @Override - public MessageMetaData add(final Mailbox mailbox, final MailboxMessage message) throws MailboxException { - return messageMapper.execute( - () -> messageMapper.add(mailbox, message)); - } - - @Override - public Iterator updateFlags(final Mailbox mailbox, final FlagsUpdateCalculator flagsUpdateCalculator, - final MessageRange set) throws MailboxException { - return messageMapper.execute( - () -> messageMapper.updateFlags(mailbox, flagsUpdateCalculator, set)); - } - - @Override - public MessageMetaData copy(final Mailbox mailbox, final MailboxMessage original) throws MailboxException { - return messageMapper.execute( - () -> messageMapper.copy(mailbox, original)); - } - - @Override - public MessageMetaData move(Mailbox mailbox, MailboxMessage original) throws MailboxException { - return messageMapper.execute( - () -> messageMapper.move(mailbox, original)); - } - - @Override - public Optional getLastUid(Mailbox mailbox) throws MailboxException { - return messageMapper.getLastUid(mailbox); - } - - @Override - public ModSeq getHighestModSeq(Mailbox mailbox) throws MailboxException { - return messageMapper.getHighestModSeq(mailbox); - } - - @Override - public Flags getApplicableFlag(Mailbox mailbox) throws MailboxException { - return messageMapper.getApplicableFlag(mailbox); - } -} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAMailboxMessageTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAMailboxMessageTest.java deleted file mode 100644 index cc34126ed43..00000000000 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/model/openjpa/JPAMailboxMessageTest.java +++ /dev/null @@ -1,57 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.mailbox.postgres.mail.model.openjpa; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.nio.charset.StandardCharsets; - -import org.apache.commons.io.IOUtils; -import org.apache.james.mailbox.postgres.mail.model.openjpa.JPAMailboxMessage; -import org.junit.jupiter.api.Test; - -class JPAMailboxMessageTest { - - private static final byte[] EMPTY = new byte[] {}; - /** - * Even though there should never be a null body, it does happen. See JAMES-2384 - */ - @Test - void getFullContentShouldReturnOriginalContentWhenBodyFieldIsNull() throws Exception { - - // Prepare the message - byte[] content = "Subject: the null message".getBytes(StandardCharsets.UTF_8); - JPAMailboxMessage message = new JPAMailboxMessage(content, null); - - // Get and check - assertThat(IOUtils.toByteArray(message.getFullContent())).containsExactly(content); - - } - - @Test - void getAnyMessagePartThatIsNullShouldYieldEmptyArray() throws Exception { - - // Prepare the message - JPAMailboxMessage message = new JPAMailboxMessage(null, null); - assertThat(IOUtils.toByteArray(message.getHeaderContent())).containsExactly(EMPTY); - assertThat(IOUtils.toByteArray(message.getBodyContent())).containsExactly(EMPTY); - assertThat(IOUtils.toByteArray(message.getFullContent())).containsExactly(EMPTY); - } - -} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/JPARecomputeCurrentQuotasServiceTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/PostgresRecomputeCurrentQuotasServiceTest.java similarity index 62% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/JPARecomputeCurrentQuotasServiceTest.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/PostgresRecomputeCurrentQuotasServiceTest.java index 077c249c19c..3e95b0eee68 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/JPARecomputeCurrentQuotasServiceTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/PostgresRecomputeCurrentQuotasServiceTest.java @@ -19,56 +19,49 @@ package org.apache.james.mailbox.postgres.mail.task; -import javax.persistence.EntityManagerFactory; - import org.apache.commons.configuration2.BaseHierarchicalConfiguration; -import org.apache.james.backends.jpa.JPAConfiguration; import org.apache.james.backends.jpa.JpaTestCluster; import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.quota.PostgresQuotaCurrentValueDAO; +import org.apache.james.backends.postgres.quota.PostgresQuotaModule; import org.apache.james.domainlist.api.DomainList; -import org.apache.james.domainlist.jpa.model.JPADomain; import org.apache.james.mailbox.MailboxManager; import org.apache.james.mailbox.SessionProvider; -import org.apache.james.mailbox.postgres.JPAMailboxFixture; -import org.apache.james.mailbox.postgres.JpaMailboxManagerProvider; -import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; -import org.apache.james.mailbox.postgres.mail.JPAModSeqProvider; -import org.apache.james.mailbox.postgres.mail.JPAUidProvider; -import org.apache.james.mailbox.postgres.quota.JpaCurrentQuotaManager; -import org.apache.james.mailbox.postgres.user.PostgresSubscriptionModule; +import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; +import org.apache.james.mailbox.postgres.PostgresMailboxManagerProvider; +import org.apache.james.mailbox.postgres.quota.PostgresCurrentQuotaManager; import org.apache.james.mailbox.quota.CurrentQuotaManager; import org.apache.james.mailbox.quota.UserQuotaRootResolver; import org.apache.james.mailbox.quota.task.RecomputeCurrentQuotasService; import org.apache.james.mailbox.quota.task.RecomputeCurrentQuotasServiceContract; import org.apache.james.mailbox.quota.task.RecomputeMailboxCurrentQuotasService; +import org.apache.james.mailbox.store.MailboxSessionMapperFactory; import org.apache.james.mailbox.store.StoreMailboxManager; import org.apache.james.mailbox.store.quota.CurrentQuotaCalculator; import org.apache.james.mailbox.store.quota.DefaultUserQuotaRootResolver; import org.apache.james.user.api.UsersRepository; -import org.apache.james.user.jpa.JPAUsersRepository; -import org.apache.james.user.jpa.model.JPAUser; -import org.junit.jupiter.api.AfterEach; +import org.apache.james.user.postgres.PostgresUserModule; +import org.apache.james.user.postgres.PostgresUsersDAO; +import org.apache.james.user.postgres.PostgresUsersRepository; +import org.apache.james.user.postgres.PostgresUsersRepositoryConfiguration; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.RegisterExtension; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; -class JPARecomputeCurrentQuotasServiceTest implements RecomputeCurrentQuotasServiceContract { +class PostgresRecomputeCurrentQuotasServiceTest implements RecomputeCurrentQuotasServiceContract { @RegisterExtension - static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresSubscriptionModule.MODULE); + static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresModule.aggregateModules( + PostgresMailboxAggregateModule.MODULE, + PostgresQuotaModule.MODULE, + PostgresUserModule.MODULE)); static final DomainList NO_DOMAIN_LIST = null; - static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(ImmutableList.>builder() - .addAll(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES) - .addAll(JPAMailboxFixture.QUOTA_PERSISTANCE_CLASSES) - .add(JPAUser.class) - .add(JPADomain.class) - .build()); - - JPAUsersRepository usersRepository; + PostgresUsersRepository usersRepository; StoreMailboxManager mailboxManager; SessionProvider sessionProvider; CurrentQuotaManager currentQuotaManager; @@ -77,28 +70,19 @@ class JPARecomputeCurrentQuotasServiceTest implements RecomputeCurrentQuotasServ @BeforeEach void setUp() throws Exception { - EntityManagerFactory entityManagerFactory = JPA_TEST_CLUSTER.getEntityManagerFactory(); - - JPAConfiguration jpaConfiguration = JPAConfiguration.builder() - .driverName("driverName") - .driverURL("driverUrl") - .build(); + MailboxSessionMapperFactory mapperFactory = PostgresMailboxManagerProvider.provideMailboxSessionMapperFactory(postgresExtension); - PostgresMailboxSessionMapperFactory mapperFactory = new PostgresMailboxSessionMapperFactory(entityManagerFactory, - new JPAUidProvider(entityManagerFactory), - new JPAModSeqProvider(entityManagerFactory), - jpaConfiguration, - postgresExtension.getExecutorFactory()); + PostgresUsersDAO usersDAO = new PostgresUsersDAO(postgresExtension.getPostgresExecutor(), + PostgresUsersRepositoryConfiguration.DEFAULT); - usersRepository = new JPAUsersRepository(NO_DOMAIN_LIST); - usersRepository.setEntityManagerFactory(JPA_TEST_CLUSTER.getEntityManagerFactory()); + usersRepository = new PostgresUsersRepository(NO_DOMAIN_LIST, usersDAO); BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); configuration.addProperty("enableVirtualHosting", "false"); usersRepository.configure(configuration); - mailboxManager = JpaMailboxManagerProvider.provideMailboxManager(JPA_TEST_CLUSTER, postgresExtension); + mailboxManager = PostgresMailboxManagerProvider.provideMailboxManager(postgresExtension); sessionProvider = mailboxManager.getSessionProvider(); - currentQuotaManager = new JpaCurrentQuotaManager(entityManagerFactory); + currentQuotaManager = new PostgresCurrentQuotaManager(new PostgresQuotaCurrentValueDAO(postgresExtension.getPostgresExecutor())); userQuotaRootResolver = new DefaultUserQuotaRootResolver(sessionProvider, mapperFactory); @@ -113,16 +97,6 @@ void setUp() throws Exception { RECOMPUTE_JMAP_UPLOAD_CURRENT_QUOTAS_SERVICE)); } - @AfterEach - void tearDownJpa() { - JPA_TEST_CLUSTER.clear(ImmutableList.builder() - .addAll(JPAMailboxFixture.MAILBOX_TABLE_NAMES) - .addAll(JPAMailboxFixture.QUOTA_TABLES_NAMES) - .add("JAMES_USER") - .add("JAMES_DOMAIN") - .build()); - } - @Override public UsersRepository usersRepository() { return usersRepository; diff --git a/mailbox/postgres/src/test/resources/persistence.xml b/mailbox/postgres/src/test/resources/persistence.xml index 83201af5261..21199cfdd48 100644 --- a/mailbox/postgres/src/test/resources/persistence.xml +++ b/mailbox/postgres/src/test/resources/persistence.xml @@ -24,22 +24,12 @@ version="2.0"> - org.apache.james.mailbox.postgres.mail.model.JPAMailbox - org.apache.james.mailbox.postgres.mail.model.JPAUserFlag - org.apache.james.mailbox.postgres.mail.model.openjpa.AbstractJPAMailboxMessage - org.apache.james.mailbox.postgres.mail.model.openjpa.JPAMailboxMessage - org.apache.james.mailbox.postgres.mail.model.JPAAttachment - org.apache.james.mailbox.postgres.mail.model.JPAProperty org.apache.james.mailbox.postgres.quota.model.MaxDomainMessageCount org.apache.james.mailbox.postgres.quota.model.MaxDomainStorage org.apache.james.mailbox.postgres.quota.model.MaxGlobalMessageCount org.apache.james.mailbox.postgres.quota.model.MaxGlobalStorage org.apache.james.mailbox.postgres.quota.model.MaxUserMessageCount org.apache.james.mailbox.postgres.quota.model.MaxUserStorage - org.apache.james.mailbox.postgres.quota.model.JpaCurrentQuota - org.apache.james.mailbox.postgres.mail.model.JPAMailboxAnnotation - org.apache.james.mailbox.postgres.mail.model.JPAMailboxAnnotationId - org.apache.james.mailbox.postgres.mail.model.openjpa.AbstractJPAMailboxMessage$MailboxIdUidKey diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java index eff72a7c4c7..5c5ab7e6882 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java @@ -47,9 +47,9 @@ import org.apache.james.mailbox.acl.MailboxACLResolver; import org.apache.james.mailbox.acl.UnionMailboxACLResolver; import org.apache.james.mailbox.postgres.JPAMailboxFixture; -import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactoryTODO; +import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; import org.apache.james.mailbox.postgres.PostgresMessageId; -import org.apache.james.mailbox.postgres.openjpa.OpenJPAMailboxManager; +import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; import org.apache.james.mailbox.postgres.quota.JPAPerUserMaxQuotaDAO; import org.apache.james.mailbox.postgres.quota.JPAPerUserMaxQuotaManager; import org.apache.james.mailbox.postgres.quota.PostgresCurrentQuotaManager; @@ -83,7 +83,6 @@ public class PostgresHostSystem extends JamesImapHostSystem { private static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create( ImmutableList.>builder() - .addAll(JPAMailboxFixture.MAILBOX_PERSISTANCE_CLASSES) .addAll(JPAMailboxFixture.QUOTA_PERSISTANCE_CLASSES) .build()); @@ -100,7 +99,7 @@ static PostgresHostSystem build(PostgresExtension postgresExtension) { } private JPAPerUserMaxQuotaManager maxQuotaManager; - private OpenJPAMailboxManager mailboxManager; + private PostgresMailboxManager mailboxManager; private final PostgresExtension postgresExtension; public PostgresHostSystem(PostgresExtension postgresExtension) { @@ -119,7 +118,7 @@ public void beforeTest() throws Exception { BlobId.Factory blobIdFactory = new HashBlobId.Factory(); DeDuplicationBlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory); - PostgresMailboxSessionMapperFactoryTODO mapperFactory = new PostgresMailboxSessionMapperFactoryTODO(postgresExtension.getExecutorFactory(), Clock.systemUTC(), blobStore, blobIdFactory); + PostgresMailboxSessionMapperFactory mapperFactory = new PostgresMailboxSessionMapperFactory(postgresExtension.getExecutorFactory(), Clock.systemUTC(), blobStore, blobIdFactory); MailboxACLResolver aclResolver = new UnionMailboxACLResolver(); MessageParser messageParser = new MessageParser(); @@ -138,7 +137,7 @@ public void beforeTest() throws Exception { AttachmentContentLoader attachmentContentLoader = null; MessageSearchIndex index = new SimpleMessageSearchIndex(mapperFactory, mapperFactory, new DefaultTextExtractor(), attachmentContentLoader); - mailboxManager = new OpenJPAMailboxManager(mapperFactory, sessionProvider, messageParser, + mailboxManager = new PostgresMailboxManager(mapperFactory, sessionProvider, messageParser, new PostgresMessageId.Factory(), eventBus, annotationManager, storeRightManager, quotaComponents, index, new NaiveThreadIdGuessingAlgorithm(), new UpdatableTickingClock(Instant.now())); @@ -164,7 +163,6 @@ public void beforeTest() throws Exception { @Override public void afterTest() { JPA_TEST_CLUSTER.clear(ImmutableList.builder() - .addAll(JPAMailboxFixture.MAILBOX_TABLE_NAMES) .addAll(JPAMailboxFixture.QUOTA_TABLES_NAMES) .build()); } diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index 8ecba40bb71..1191382350f 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -27,7 +27,6 @@ import org.apache.james.modules.data.PostgresUsersRepositoryModule; import org.apache.james.modules.data.SievePostgresRepositoryModules; import org.apache.james.modules.mailbox.DefaultEventModule; -import org.apache.james.modules.mailbox.JPAMailboxModule; import org.apache.james.modules.mailbox.LuceneSearchMailboxModule; import org.apache.james.modules.mailbox.MemoryDeadLetterModule; import org.apache.james.modules.mailbox.PostgresMailboxModule; @@ -82,7 +81,6 @@ public class PostgresJamesServerMain implements JamesServerMain { new ActiveMQQueueModule(), new NaiveDelegationStoreModule(), new DefaultProcessorsConfigurationProviderModule(), - new JPAMailboxModule(), new PostgresMailboxModule(), new PostgresDataModule(), new MailboxModule(), diff --git a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml index 165c6456cd1..e2237925132 100644 --- a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml +++ b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml @@ -24,12 +24,6 @@ version="2.0"> - org.apache.james.mailbox.postgres.mail.model.JPAMailbox - org.apache.james.mailbox.postgres.mail.model.JPAUserFlag - org.apache.james.mailbox.postgres.mail.model.openjpa.AbstractJPAMailboxMessage - org.apache.james.mailbox.postgres.mail.model.openjpa.JPAMailboxMessage - org.apache.james.mailbox.postgres.mail.model.JPAProperty - org.apache.james.mailrepository.jpa.model.JPAUrl org.apache.james.mailrepository.jpa.model.JPAMail org.apache.james.rrt.jpa.model.JPARecipientRewrite @@ -43,10 +37,6 @@ org.apache.james.mailbox.postgres.quota.model.MaxUserStorage org.apache.james.mailbox.postgres.quota.model.MaxDomainStorage org.apache.james.mailbox.postgres.quota.model.MaxDomainMessageCount - org.apache.james.mailbox.postgres.quota.model.JpaCurrentQuota - - org.apache.james.mailbox.postgres.mail.model.JPAMailboxAnnotation - org.apache.james.mailbox.postgres.mail.model.JPAMailboxAnnotationId diff --git a/server/container/guice/mailbox-postgres/pom.xml b/server/container/guice/mailbox-postgres/pom.xml index e07ac357ace..6aa052c0414 100644 --- a/server/container/guice/mailbox-postgres/pom.xml +++ b/server/container/guice/mailbox-postgres/pom.xml @@ -45,6 +45,10 @@ ${james.groupId} apache-james-mailbox-quota-search-scanning + + ${james.groupId} + blob-memory-guice + ${james.groupId} james-server-data-postgres diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JPAMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JPAMailboxModule.java deleted file mode 100644 index 8f92d0e27cb..00000000000 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JPAMailboxModule.java +++ /dev/null @@ -1,152 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.modules.mailbox; - -import static org.apache.james.modules.Names.MAILBOXMANAGER_NAME; - -import javax.inject.Singleton; - -import org.apache.james.adapter.mailbox.ACLUsernameChangeTaskStep; -import org.apache.james.adapter.mailbox.MailboxUserDeletionTaskStep; -import org.apache.james.adapter.mailbox.MailboxUsernameChangeTaskStep; -import org.apache.james.adapter.mailbox.QuotaUsernameChangeTaskStep; -import org.apache.james.adapter.mailbox.UserRepositoryAuthenticator; -import org.apache.james.adapter.mailbox.UserRepositoryAuthorizator; -import org.apache.james.events.EventListener; -import org.apache.james.mailbox.AttachmentContentLoader; -import org.apache.james.mailbox.Authenticator; -import org.apache.james.mailbox.Authorizator; -import org.apache.james.mailbox.MailboxManager; -import org.apache.james.mailbox.MailboxPathLocker; -import org.apache.james.mailbox.SessionProvider; -import org.apache.james.mailbox.SubscriptionManager; -import org.apache.james.mailbox.acl.MailboxACLResolver; -import org.apache.james.mailbox.acl.UnionMailboxACLResolver; -import org.apache.james.mailbox.indexer.ReIndexer; -import org.apache.james.mailbox.model.MailboxId; -import org.apache.james.mailbox.model.MessageId; -import org.apache.james.mailbox.postgres.JPAAttachmentContentLoader; -import org.apache.james.mailbox.postgres.JPAId; -import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; -import org.apache.james.mailbox.postgres.mail.JPAModSeqProvider; -import org.apache.james.mailbox.postgres.mail.JPAUidProvider; -import org.apache.james.mailbox.postgres.openjpa.OpenJPAMailboxManager; -import org.apache.james.mailbox.store.JVMMailboxPathLocker; -import org.apache.james.mailbox.store.MailboxManagerConfiguration; -import org.apache.james.mailbox.store.MailboxSessionMapperFactory; -import org.apache.james.mailbox.store.SessionProviderImpl; -import org.apache.james.mailbox.store.StoreMailboxManager; -import org.apache.james.mailbox.store.StoreSubscriptionManager; -import org.apache.james.mailbox.store.event.MailboxAnnotationListener; -import org.apache.james.mailbox.store.event.MailboxSubscriptionListener; -import org.apache.james.mailbox.store.mail.MailboxMapperFactory; -import org.apache.james.mailbox.store.mail.MessageMapperFactory; -import org.apache.james.mailbox.store.mail.ModSeqProvider; -import org.apache.james.mailbox.store.mail.NaiveThreadIdGuessingAlgorithm; -import org.apache.james.mailbox.store.mail.ThreadIdGuessingAlgorithm; -import org.apache.james.mailbox.store.mail.UidProvider; -import org.apache.james.mailbox.store.mail.model.DefaultMessageId; -import org.apache.james.mailbox.store.user.SubscriptionMapperFactory; -import org.apache.james.modules.data.JPAEntityManagerModule; -import org.apache.james.user.api.DeleteUserDataTaskStep; -import org.apache.james.user.api.UsernameChangeTaskStep; -import org.apache.james.utils.MailboxManagerDefinition; -import org.apache.mailbox.tools.indexer.ReIndexerImpl; - -import com.google.inject.AbstractModule; -import com.google.inject.Inject; -import com.google.inject.Scopes; -import com.google.inject.multibindings.Multibinder; -import com.google.inject.name.Names; - -public class JPAMailboxModule extends AbstractModule { - - @Override - protected void configure() { - install(new JpaQuotaModule()); - install(new JPAQuotaSearchModule()); - install(new JPAEntityManagerModule()); - - bind(PostgresMailboxSessionMapperFactory.class).in(Scopes.SINGLETON); - bind(OpenJPAMailboxManager.class).in(Scopes.SINGLETON); - bind(JVMMailboxPathLocker.class).in(Scopes.SINGLETON); - bind(StoreSubscriptionManager.class).in(Scopes.SINGLETON); - bind(JPAModSeqProvider.class).in(Scopes.SINGLETON); - bind(JPAUidProvider.class).in(Scopes.SINGLETON); - bind(UserRepositoryAuthenticator.class).in(Scopes.SINGLETON); - bind(UserRepositoryAuthorizator.class).in(Scopes.SINGLETON); - bind(JPAId.Factory.class).in(Scopes.SINGLETON); - bind(UnionMailboxACLResolver.class).in(Scopes.SINGLETON); - bind(DefaultMessageId.Factory.class).in(Scopes.SINGLETON); - bind(NaiveThreadIdGuessingAlgorithm.class).in(Scopes.SINGLETON); - bind(ReIndexerImpl.class).in(Scopes.SINGLETON); - bind(SessionProviderImpl.class).in(Scopes.SINGLETON); - - bind(SubscriptionMapperFactory.class).to(PostgresMailboxSessionMapperFactory.class); - bind(MessageMapperFactory.class).to(PostgresMailboxSessionMapperFactory.class); - bind(MailboxMapperFactory.class).to(PostgresMailboxSessionMapperFactory.class); - bind(MailboxSessionMapperFactory.class).to(PostgresMailboxSessionMapperFactory.class); - bind(MessageId.Factory.class).to(DefaultMessageId.Factory.class); - bind(ThreadIdGuessingAlgorithm.class).to(NaiveThreadIdGuessingAlgorithm.class); - - bind(ModSeqProvider.class).to(JPAModSeqProvider.class); - bind(UidProvider.class).to(JPAUidProvider.class); - bind(SubscriptionManager.class).to(StoreSubscriptionManager.class); - bind(MailboxPathLocker.class).to(JVMMailboxPathLocker.class); - bind(Authenticator.class).to(UserRepositoryAuthenticator.class); - bind(MailboxManager.class).to(OpenJPAMailboxManager.class); - bind(StoreMailboxManager.class).to(OpenJPAMailboxManager.class); - bind(SessionProvider.class).to(SessionProviderImpl.class); - bind(Authorizator.class).to(UserRepositoryAuthorizator.class); - bind(MailboxId.Factory.class).to(JPAId.Factory.class); - bind(MailboxACLResolver.class).to(UnionMailboxACLResolver.class); - bind(AttachmentContentLoader.class).to(JPAAttachmentContentLoader.class); - - bind(ReIndexer.class).to(ReIndexerImpl.class); - - Multibinder.newSetBinder(binder(), MailboxManagerDefinition.class).addBinding().to(JPAMailboxManagerDefinition.class); - - Multibinder.newSetBinder(binder(), EventListener.GroupEventListener.class) - .addBinding() - .to(MailboxAnnotationListener.class); - - Multibinder.newSetBinder(binder(), EventListener.GroupEventListener.class) - .addBinding() - .to(MailboxSubscriptionListener.class); - - bind(MailboxManager.class).annotatedWith(Names.named(MAILBOXMANAGER_NAME)).to(MailboxManager.class); - bind(MailboxManagerConfiguration.class).toInstance(MailboxManagerConfiguration.DEFAULT); - - Multibinder usernameChangeTaskStepMultibinder = Multibinder.newSetBinder(binder(), UsernameChangeTaskStep.class); - usernameChangeTaskStepMultibinder.addBinding().to(MailboxUsernameChangeTaskStep.class); - usernameChangeTaskStepMultibinder.addBinding().to(ACLUsernameChangeTaskStep.class); - usernameChangeTaskStepMultibinder.addBinding().to(QuotaUsernameChangeTaskStep.class); - - Multibinder deleteUserDataTaskStepMultibinder = Multibinder.newSetBinder(binder(), DeleteUserDataTaskStep.class); - deleteUserDataTaskStepMultibinder.addBinding().to(MailboxUserDeletionTaskStep.class); - } - - @Singleton - private static class JPAMailboxManagerDefinition extends MailboxManagerDefinition { - @Inject - private JPAMailboxManagerDefinition(OpenJPAMailboxManager manager) { - super("jpa-mailboxmanager", manager); - } - } -} \ No newline at end of file diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java index 4ef3119e078..2d8d79ec7bd 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java @@ -18,21 +18,136 @@ ****************************************************************/ package org.apache.james.modules.mailbox; +import static org.apache.james.modules.Names.MAILBOXMANAGER_NAME; + +import javax.inject.Singleton; + +import org.apache.james.adapter.mailbox.ACLUsernameChangeTaskStep; +import org.apache.james.adapter.mailbox.MailboxUserDeletionTaskStep; +import org.apache.james.adapter.mailbox.MailboxUsernameChangeTaskStep; +import org.apache.james.adapter.mailbox.QuotaUsernameChangeTaskStep; +import org.apache.james.adapter.mailbox.UserRepositoryAuthenticator; +import org.apache.james.adapter.mailbox.UserRepositoryAuthorizator; import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.events.EventListener; +import org.apache.james.mailbox.AttachmentContentLoader; +import org.apache.james.mailbox.Authenticator; +import org.apache.james.mailbox.Authorizator; +import org.apache.james.mailbox.MailboxManager; +import org.apache.james.mailbox.MailboxPathLocker; +import org.apache.james.mailbox.SessionProvider; +import org.apache.james.mailbox.SubscriptionManager; +import org.apache.james.mailbox.acl.MailboxACLResolver; +import org.apache.james.mailbox.acl.UnionMailboxACLResolver; +import org.apache.james.mailbox.indexer.ReIndexer; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.postgres.JPAAttachmentContentLoader; import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; +import org.apache.james.mailbox.store.MailboxManagerConfiguration; +import org.apache.james.mailbox.store.MailboxSessionMapperFactory; +import org.apache.james.mailbox.store.NoMailboxPathLocker; +import org.apache.james.mailbox.store.SessionProviderImpl; +import org.apache.james.mailbox.store.StoreMailboxManager; +import org.apache.james.mailbox.store.StoreSubscriptionManager; +import org.apache.james.mailbox.store.event.MailboxAnnotationListener; +import org.apache.james.mailbox.store.event.MailboxSubscriptionListener; +import org.apache.james.mailbox.store.mail.MailboxMapperFactory; +import org.apache.james.mailbox.store.mail.MessageMapperFactory; +import org.apache.james.mailbox.store.mail.NaiveThreadIdGuessingAlgorithm; +import org.apache.james.mailbox.store.mail.ThreadIdGuessingAlgorithm; +import org.apache.james.mailbox.store.user.SubscriptionMapperFactory; +import org.apache.james.modules.BlobMemoryModule; +import org.apache.james.modules.data.JPAEntityManagerModule; import org.apache.james.modules.data.PostgresCommonModule; +import org.apache.james.user.api.DeleteUserDataTaskStep; +import org.apache.james.user.api.UsernameChangeTaskStep; +import org.apache.james.utils.MailboxManagerDefinition; +import org.apache.mailbox.tools.indexer.ReIndexerImpl; import com.google.inject.AbstractModule; +import com.google.inject.Inject; +import com.google.inject.Scopes; import com.google.inject.multibindings.Multibinder; +import com.google.inject.name.Names; public class PostgresMailboxModule extends AbstractModule { @Override protected void configure() { install(new PostgresCommonModule()); + install(new BlobMemoryModule()); Multibinder postgresDataDefinitions = Multibinder.newSetBinder(binder(), PostgresModule.class); postgresDataDefinitions.addBinding().toInstance(PostgresMailboxAggregateModule.MODULE); + + install(new PostgresQuotaModule()); + install(new JPAQuotaSearchModule()); + install(new JPAEntityManagerModule()); + + bind(PostgresMailboxSessionMapperFactory.class).in(Scopes.SINGLETON); + bind(PostgresMailboxManager.class).in(Scopes.SINGLETON); + bind(NoMailboxPathLocker.class).in(Scopes.SINGLETON); + bind(StoreSubscriptionManager.class).in(Scopes.SINGLETON); + bind(UserRepositoryAuthenticator.class).in(Scopes.SINGLETON); + bind(UserRepositoryAuthorizator.class).in(Scopes.SINGLETON); + bind(UnionMailboxACLResolver.class).in(Scopes.SINGLETON); + bind(PostgresMessageId.Factory.class).in(Scopes.SINGLETON); + bind(NaiveThreadIdGuessingAlgorithm.class).in(Scopes.SINGLETON); + bind(ReIndexerImpl.class).in(Scopes.SINGLETON); + bind(SessionProviderImpl.class).in(Scopes.SINGLETON); + + bind(SubscriptionMapperFactory.class).to(PostgresMailboxSessionMapperFactory.class); + bind(MessageMapperFactory.class).to(PostgresMailboxSessionMapperFactory.class); + bind(MailboxMapperFactory.class).to(PostgresMailboxSessionMapperFactory.class); + bind(MailboxSessionMapperFactory.class).to(PostgresMailboxSessionMapperFactory.class); + bind(MessageId.Factory.class).to(PostgresMessageId.Factory.class); + bind(ThreadIdGuessingAlgorithm.class).to(NaiveThreadIdGuessingAlgorithm.class); + + bind(SubscriptionManager.class).to(StoreSubscriptionManager.class); + bind(MailboxPathLocker.class).to(NoMailboxPathLocker.class); + bind(Authenticator.class).to(UserRepositoryAuthenticator.class); + bind(MailboxManager.class).to(PostgresMailboxManager.class); + bind(StoreMailboxManager.class).to(PostgresMailboxManager.class); + bind(SessionProvider.class).to(SessionProviderImpl.class); + bind(Authorizator.class).to(UserRepositoryAuthorizator.class); + bind(MailboxId.Factory.class).to(PostgresMailboxId.Factory.class); + bind(MailboxACLResolver.class).to(UnionMailboxACLResolver.class); + bind(AttachmentContentLoader.class).to(JPAAttachmentContentLoader.class); + + bind(ReIndexer.class).to(ReIndexerImpl.class); + + Multibinder.newSetBinder(binder(), MailboxManagerDefinition.class).addBinding().to(PostgresMailboxManagerDefinition.class); + + Multibinder.newSetBinder(binder(), EventListener.GroupEventListener.class) + .addBinding() + .to(MailboxAnnotationListener.class); + + Multibinder.newSetBinder(binder(), EventListener.GroupEventListener.class) + .addBinding() + .to(MailboxSubscriptionListener.class); + + bind(MailboxManager.class).annotatedWith(Names.named(MAILBOXMANAGER_NAME)).to(MailboxManager.class); + bind(MailboxManagerConfiguration.class).toInstance(MailboxManagerConfiguration.DEFAULT); + + Multibinder usernameChangeTaskStepMultibinder = Multibinder.newSetBinder(binder(), UsernameChangeTaskStep.class); + usernameChangeTaskStepMultibinder.addBinding().to(MailboxUsernameChangeTaskStep.class); + usernameChangeTaskStepMultibinder.addBinding().to(ACLUsernameChangeTaskStep.class); + usernameChangeTaskStepMultibinder.addBinding().to(QuotaUsernameChangeTaskStep.class); + + Multibinder deleteUserDataTaskStepMultibinder = Multibinder.newSetBinder(binder(), DeleteUserDataTaskStep.class); + deleteUserDataTaskStepMultibinder.addBinding().to(MailboxUserDeletionTaskStep.class); } + @Singleton + private static class PostgresMailboxManagerDefinition extends MailboxManagerDefinition { + @Inject + private PostgresMailboxManagerDefinition(PostgresMailboxManager manager) { + super("postgres-mailboxmanager", manager); + } + } } \ No newline at end of file diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JpaQuotaModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresQuotaModule.java similarity index 91% rename from server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JpaQuotaModule.java rename to server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresQuotaModule.java index 49faa418205..8815b27812e 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JpaQuotaModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresQuotaModule.java @@ -21,7 +21,7 @@ import org.apache.james.events.EventListener; import org.apache.james.mailbox.postgres.quota.JPAPerUserMaxQuotaManager; -import org.apache.james.mailbox.postgres.quota.JpaCurrentQuotaManager; +import org.apache.james.mailbox.postgres.quota.PostgresCurrentQuotaManager; import org.apache.james.mailbox.quota.CurrentQuotaManager; import org.apache.james.mailbox.quota.MaxQuotaManager; import org.apache.james.mailbox.quota.QuotaManager; @@ -37,21 +37,21 @@ import com.google.inject.Scopes; import com.google.inject.multibindings.Multibinder; -public class JpaQuotaModule extends AbstractModule { +public class PostgresQuotaModule extends AbstractModule { @Override protected void configure() { bind(DefaultUserQuotaRootResolver.class).in(Scopes.SINGLETON); bind(JPAPerUserMaxQuotaManager.class).in(Scopes.SINGLETON); bind(StoreQuotaManager.class).in(Scopes.SINGLETON); - bind(JpaCurrentQuotaManager.class).in(Scopes.SINGLETON); + bind(PostgresCurrentQuotaManager.class).in(Scopes.SINGLETON); bind(UserQuotaRootResolver.class).to(DefaultUserQuotaRootResolver.class); bind(QuotaRootResolver.class).to(DefaultUserQuotaRootResolver.class); bind(QuotaRootDeserializer.class).to(DefaultUserQuotaRootResolver.class); bind(MaxQuotaManager.class).to(JPAPerUserMaxQuotaManager.class); bind(QuotaManager.class).to(StoreQuotaManager.class); - bind(CurrentQuotaManager.class).to(JpaCurrentQuotaManager.class); + bind(CurrentQuotaManager.class).to(PostgresCurrentQuotaManager.class); bind(ListeningCurrentQuotaUpdater.class).in(Scopes.SINGLETON); bind(QuotaUpdater.class).to(ListeningCurrentQuotaUpdater.class); From 9a23da7e19706f9a77b326f6e04a8cb6a4ecebbb Mon Sep 17 00:00:00 2001 From: hung phan Date: Mon, 4 Dec 2023 15:55:25 +0700 Subject: [PATCH 096/341] JAMES-2586 Fix Postgres Mailbox Annotation mpt imap test --- .../org/apache/james/imap/scripts/Metadata.test | 9 ++++++--- .../postgres/PostgresMailboxAnnotationTest.java | 1 - 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/mpt/impl/imap-mailbox/core/src/main/resources/org/apache/james/imap/scripts/Metadata.test b/mpt/impl/imap-mailbox/core/src/main/resources/org/apache/james/imap/scripts/Metadata.test index 7e247345a59..e77ad93c049 100644 --- a/mpt/impl/imap-mailbox/core/src/main/resources/org/apache/james/imap/scripts/Metadata.test +++ b/mpt/impl/imap-mailbox/core/src/main/resources/org/apache/james/imap/scripts/Metadata.test @@ -85,7 +85,8 @@ S: \* METADATA "INBOX" \((\/private\/comment "My own comment" \/shared\/comment S: g3 OK GETMETADATA completed. C: g4 GETMETADATA "INBOX" -S: \* METADATA "INBOX" \(\/private\/comment "My own comment" \/shared\/comment "The shared comment"\) +# Regex used to be order agnostic. Annotation1 Annotation2 OR Annotation2 Annotation1 +S: \* METADATA "INBOX" \((\/private\/comment "My own comment" \/shared\/comment "The shared comment"|\/shared\/comment "The shared comment" \/private\/comment "My own comment")\) S: g4 OK GETMETADATA completed. C: g5 GETMETADATA "INBOX" /shared/comment /private/comment) @@ -102,7 +103,8 @@ S: \* METADATA "INBOX" \(\/private\/comment "My own comment"\) S: g8 OK \[METADATA LONGENTRIES 18\] GETMETADATA completed. C: g9 GETMETADATA "INBOX" (MAXSIZE 100) -S: \* METADATA "INBOX" \(\/private\/comment "My own comment" \/shared\/comment "The shared comment"\) +# Regex used to be order agnostic. Annotation1 Annotation2 OR Annotation2 Annotation1 +S: \* METADATA "INBOX" \((\/private\/comment "My own comment" \/shared\/comment "The shared comment"|\/shared\/comment "The shared comment" \/private\/comment "My own comment")\) S: g9 OK GETMETADATA completed. C: s3 SETMETADATA INBOX (/private/comment/user "My own comment for user") @@ -169,7 +171,8 @@ C: m03 SETMETADATA mailboxTest (/shared/comment "The mailboxTest shared comment" S: m03 OK SETMETADATA completed. C: m04 GETMETADATA "mailboxTest" -S: \* METADATA "mailboxTest" \(\/private\/comment "The mailboxTest private comment" \/shared\/comment "The mailboxTest shared comment"\) +# Regex used to be order agnostic. Annotation1 Annotation2 OR Annotation2 Annotation1 +S: \* METADATA "mailboxTest" \((\/private\/comment "The mailboxTest private comment" \/shared\/comment "The mailboxTest shared comment"|\/shared\/comment "The mailboxTest shared comment" \/private\/comment "The mailboxTest private comment")\) S: m04 OK GETMETADATA completed. C: m05 DELETE mailboxTest diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxAnnotationTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxAnnotationTest.java index dce51c7c0d7..40b8a88903e 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxAnnotationTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxAnnotationTest.java @@ -25,7 +25,6 @@ import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.extension.RegisterExtension; -@Disabled("TODO https://github.com/apache/james-project/pull/1822") public class PostgresMailboxAnnotationTest extends MailboxAnnotation { @RegisterExtension public static PostgresHostSystemExtension hostSystemExtension = new PostgresHostSystemExtension(); From c83f9fc5ba01fd9ccd495686e7d9e96f46403dc2 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 5 Dec 2023 10:53:52 +0700 Subject: [PATCH 097/341] JAMES-2586 Rework ConnectionThreadSafetyTest -> PostgresExecutorThreadSafetyTest We do not control directly r2dbc-postgresql Connection but library owner, thus we can do nothing upon tests failure. But we can handle library failure at James layer using PostgresExecutor wrapper. --- ... => PostgresExecutorThreadSafetyTest.java} | 135 ++++++++---------- 1 file changed, 59 insertions(+), 76 deletions(-) rename backends-common/postgres/src/test/java/org/apache/james/backends/postgres/{ConnectionThreadSafetyTest.java => PostgresExecutorThreadSafetyTest.java} (55%) diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/ConnectionThreadSafetyTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExecutorThreadSafetyTest.java similarity index 55% rename from backends-common/postgres/src/test/java/org/apache/james/backends/postgres/ConnectionThreadSafetyTest.java rename to backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExecutorThreadSafetyTest.java index 4cdecdc86da..e8c3d6a9f84 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/ConnectionThreadSafetyTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExecutorThreadSafetyTest.java @@ -23,79 +23,65 @@ import java.time.Duration; import java.util.List; -import java.util.Optional; import java.util.Set; import java.util.Vector; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; import java.util.stream.Stream; -import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; -import org.apache.james.core.Domain; +import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.util.concurrency.ConcurrentTestRunner; -import org.jetbrains.annotations.NotNull; +import org.jooq.Record; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; -import io.r2dbc.postgresql.api.PostgresqlConnection; -import io.r2dbc.postgresql.api.PostgresqlResult; -import io.r2dbc.spi.Connection; -import io.r2dbc.spi.Result; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -public class ConnectionThreadSafetyTest { +class PostgresExecutorThreadSafetyTest { static final int NUMBER_OF_THREAD = 100; - static final String CREATE_TABLE_STATEMENT = "CREATE TABLE IF NOT EXISTS person (\n" + - "\tid serial PRIMARY KEY,\n" + - "\tname VARCHAR ( 50 ) UNIQUE NOT NULL\n" + - ");"; @RegisterExtension static PostgresExtension postgresExtension = PostgresExtension.empty(); - private static PostgresqlConnection postgresqlConnection; - private static DomainImplPostgresConnectionFactory jamesPostgresConnectionFactory; + private static PostgresExecutor postgresExecutor; @BeforeAll static void beforeAll() { - jamesPostgresConnectionFactory = new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory()); - postgresqlConnection = (PostgresqlConnection) postgresExtension.getConnection().block(); + postgresExecutor = postgresExtension.getPostgresExecutor(); } @BeforeEach void beforeEach() { - postgresqlConnection.createStatement(CREATE_TABLE_STATEMENT) - .execute() - .flatMap(PostgresqlResult::getRowsUpdated) - .then() + postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.createTableIfNotExists("person") + .column("id", SQLDataType.INTEGER.identity(true)) + .column("name", SQLDataType.VARCHAR(50).nullable(false)) + .constraints(DSL.constraint().primaryKey("id")) + .unique("name"))) .block(); } @AfterEach void afterEach() { - postgresqlConnection.createStatement("DROP TABLE person") - .execute() - .flatMap(PostgresqlResult::getRowsUpdated) - .then() + postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.dropTableIfExists("person"))) .block(); } @Test - void connectionShouldWorkWellWhenItIsUsedByMultipleThreadsAndAllQueriesAreSelect() throws Exception { - createData(NUMBER_OF_THREAD); - - Connection connection = jamesPostgresConnectionFactory.getConnection(Domain.of("james")).block(); + void postgresExecutorShouldWorkWellWhenItIsUsedByMultipleThreadsAndAllQueriesAreSelect() throws Exception { + provisionData(NUMBER_OF_THREAD); List actual = new Vector<>(); ConcurrentTestRunner.builder() - .reactorOperation((threadNumber, step) -> getData(connection, threadNumber) - .doOnNext(s -> actual.add(s)) + .reactorOperation((threadNumber, step) -> getData(threadNumber) + .doOnNext(actual::add) .then()) .threadCount(NUMBER_OF_THREAD) .operationCount(1) @@ -107,11 +93,9 @@ void connectionShouldWorkWellWhenItIsUsedByMultipleThreadsAndAllQueriesAreSelect } @Test - void connectionShouldWorkWellWhenItIsUsedByMultipleThreadsAndAllQueriesAreInsert() throws Exception { - Connection connection = jamesPostgresConnectionFactory.getConnection(Domain.of("james")).block(); - + void postgresExecutorShouldWorkWellWhenItIsUsedByMultipleThreadsAndAllQueriesAreInsert() throws Exception { ConcurrentTestRunner.builder() - .reactorOperation((threadNumber, step) -> createData(connection, threadNumber)) + .reactorOperation((threadNumber, step) -> createData(threadNumber)) .threadCount(NUMBER_OF_THREAD) .operationCount(1) .runSuccessfullyWithin(Duration.ofMinutes(1)); @@ -123,14 +107,12 @@ void connectionShouldWorkWellWhenItIsUsedByMultipleThreadsAndAllQueriesAreInsert } @Test - void connectionShouldWorkWellWhenItIsUsedByMultipleThreadsAndInsertQueriesAreDuplicated() throws Exception { - Connection connection = jamesPostgresConnectionFactory.getConnection(Domain.of("james")).block(); - + void postgresExecutorShouldWorkWellWhenItIsUsedByMultipleThreadsAndInsertQueriesAreDuplicated() throws Exception { AtomicInteger numberOfSuccess = new AtomicInteger(0); AtomicInteger numberOfFail = new AtomicInteger(0); ConcurrentTestRunner.builder() - .reactorOperation((threadNumber, step) -> createData(connection, threadNumber % 10) - .then(Mono.fromCallable(() -> numberOfSuccess.incrementAndGet())) + .reactorOperation((threadNumber, step) -> createData(threadNumber % 10) + .then(Mono.fromCallable(numberOfSuccess::incrementAndGet)) .then() .onErrorResume(throwable -> { if (throwable.getMessage().contains("duplicate key value violates unique constraint")) { @@ -151,20 +133,18 @@ void connectionShouldWorkWellWhenItIsUsedByMultipleThreadsAndInsertQueriesAreDup } @Test - void connectionShouldWorkWellWhenItIsUsedByMultipleThreadsAndQueriesIncludeBothSelectAndInsert() throws Exception { - createData(50); - - Connection connection = jamesPostgresConnectionFactory.getConnection(Optional.empty()).block(); + void postgresExecutorShouldWorkWellWhenItIsUsedByMultipleThreadsAndQueriesIncludeBothSelectAndInsert() throws Exception { + provisionData(50); List actualSelect = new Vector<>(); ConcurrentTestRunner.builder() .reactorOperation((threadNumber, step) -> { if (threadNumber < 50) { - return getData(connection, threadNumber) - .doOnNext(s -> actualSelect.add(s)) + return getData(threadNumber) + .doOnNext(actualSelect::add) .then(); } else { - return createData(connection, threadNumber); + return createData(threadNumber); } }) .threadCount(NUMBER_OF_THREAD) @@ -180,40 +160,43 @@ void connectionShouldWorkWellWhenItIsUsedByMultipleThreadsAndQueriesIncludeBothS assertThat(actualInsert).containsExactlyInAnyOrderElementsOf(expectedInsert); } - private Flux getData(Connection connection, int threadNumber) { - return Flux.from(connection.createStatement("SELECT id, name FROM PERSON WHERE id = $1") - .bind("$1", threadNumber) - .execute()) - .flatMap(result -> result.map((row, rowMetadata) -> row.get("id", Long.class) + "|" + row.get("name", String.class))); + public Flux getData(int threadNumber) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext + .select(DSL.field("id"), DSL.field("name")) + .from(DSL.table("person")) + .where(DSL.field("id").eq(threadNumber)))) + .map(recordToString()); } - @NotNull - private Mono createData(Connection connection, int threadNumber) { - return Flux.from(connection.createStatement("INSERT INTO person (id, name) VALUES ($1, $2)") - .bind("$1", threadNumber) - .bind("$2", "Peter" + threadNumber) - .execute()) - .flatMap(Result::getRowsUpdated) - .then(); + public Mono createData(int threadNumber) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext + .insertInto(DSL.table("person"), DSL.field("id"), DSL.field("name")) + .values(threadNumber, "Peter" + threadNumber))); } private List getData(int lowerBound, int upperBound) { - return Flux.from(postgresqlConnection.createStatement("SELECT id, name FROM person WHERE id >= $1 AND id < $2") - .bind("$1", lowerBound) - .bind("$2", upperBound) - .execute()) - .flatMap(result -> result.map((row, rowMetadata) -> row.get("id", Long.class) + "|" + row.get("name", String.class))) - .collect(ImmutableList.toImmutableList()).block(); + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext + .select(DSL.field("id"), DSL.field("name")) + .from(DSL.table("person")) + .where(DSL.field("id").greaterOrEqual(lowerBound).and(DSL.field("id").lessThan(upperBound))))) + .map(recordToString()) + .collectList() + .block(); } - private void createData(int upperBound) { - for (int i = 0; i < upperBound; i++) { - postgresqlConnection.createStatement("INSERT INTO person (id, name) VALUES ($1, $2)") - .bind("$1", i) - .bind("$2", "Peter" + i) - .execute().flatMap(PostgresqlResult::getRowsUpdated) - .then() - .block(); - } + private void provisionData(int upperBound) { + Flux.range(0, upperBound) + .flatMap(i -> insertPerson(i, "Peter" + i)) + .then() + .block(); + } + + private Mono insertPerson(int id, String name) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(DSL.table("person"), DSL.field("id"), DSL.field("name")) + .values(id, name))); + } + + private Function recordToString() { + return record -> record.get(DSL.field("id", Long.class)) + "|" + record.get(DSL.field("name", String.class)); } } From 2f7084727697cc6528e89f483bd7fb1fd358c3e4 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 5 Dec 2023 12:15:33 +0700 Subject: [PATCH 098/341] JAMES-2586 PostgresExecutor: Retry upon PreparedStatement conflicts PreparedStatement id is unique per PG connection. We share a PG connection across multi threads leads to PreparedStatement id conflicts. We can retry upon PreparedStatement id conflicts. --- .../backends/postgres/utils/PostgresExecutor.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index 1fa3ccb4103..b8c39ae88f9 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -19,6 +19,7 @@ package org.apache.james.backends.postgres.utils; +import java.time.Duration; import java.util.Optional; import java.util.function.Function; @@ -38,10 +39,13 @@ import io.r2dbc.spi.Connection; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; public class PostgresExecutor { public static final String DEFAULT_INJECT = "default"; + public static final int MAX_RETRY_ATTEMPTS = 5; + public static final Duration MIN_BACKOFF = Duration.ofMillis(1); public static class Factory { @@ -78,22 +82,26 @@ public Mono dslContext() { public Mono executeVoid(Function> queryFunction) { return dslContext() .flatMap(queryFunction) + .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF)) .then(); } public Flux executeRows(Function> queryFunction) { return dslContext() - .flatMapMany(queryFunction); + .flatMapMany(queryFunction) + .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF)); } public Mono executeRow(Function> queryFunction) { return dslContext() - .flatMap(queryFunction); + .flatMap(queryFunction) + .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF)); } public Mono executeCount(Function>> queryFunction) { return dslContext() .flatMap(queryFunction) + .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF)) .map(Record1::value1); } From bb28f39f134ba53a4f8c5b70461fdf3d0f9427e1 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 5 Dec 2023 12:25:00 +0700 Subject: [PATCH 099/341] JAMES-2586 PostgresExecutor: Retry only upon PreparedStatement conflict exception io.r2dbc.postgresql.ExceptionFactory$PostgresqlBadGrammarException: [42P05] prepared statement "S_0" already exists Should not retry upon other fatal exception e.g. database failure, invalid authorization... --- .../postgres/utils/PostgresExecutor.java | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index b8c39ae88f9..b530405f092 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -22,6 +22,7 @@ import java.time.Duration; import java.util.Optional; import java.util.function.Function; +import java.util.function.Predicate; import javax.inject.Inject; @@ -37,6 +38,7 @@ import com.google.common.annotations.VisibleForTesting; import io.r2dbc.spi.Connection; +import io.r2dbc.spi.R2dbcBadGrammarException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; @@ -82,26 +84,30 @@ public Mono dslContext() { public Mono executeVoid(Function> queryFunction) { return dslContext() .flatMap(queryFunction) - .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF)) + .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) + .filter(preparedStatementConflictException())) .then(); } public Flux executeRows(Function> queryFunction) { return dslContext() .flatMapMany(queryFunction) - .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF)); + .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) + .filter(preparedStatementConflictException())); } public Mono executeRow(Function> queryFunction) { return dslContext() .flatMap(queryFunction) - .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF)); + .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) + .filter(preparedStatementConflictException())); } public Mono executeCount(Function>> queryFunction) { return dslContext() .flatMap(queryFunction) - .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF)) + .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) + .filter(preparedStatementConflictException())) .map(Record1::value1); } @@ -113,4 +119,8 @@ public Mono connection() { public Mono dispose() { return connection.flatMap(con -> Mono.from(con.close())); } + + private Predicate preparedStatementConflictException() { + return throwable -> throwable.getCause() instanceof R2dbcBadGrammarException; + } } From 49f61c7aa6f41efe997f2fc86f9a01d4e92825fb Mon Sep 17 00:00:00 2001 From: hungphan227 <45198168+hungphan227@users.noreply.github.com> Date: Wed, 6 Dec 2023 11:16:38 +0700 Subject: [PATCH 100/341] JAMES-2586 Implement PostgresPerUserMaxQuotaManager (#1839) --- .../apache/james/mailbox}/quota/Limits.java | 2 +- .../james/mailbox}/quota/QuotaCodec.java | 8 +- .../quota/CassandraGlobalMaxQuotaDao.java | 2 + .../quota/CassandraPerDomainMaxQuotaDao.java | 2 + .../quota/CassandraPerUserMaxQuotaDao.java | 2 + .../CassandraPerUserMaxQuotaManagerV1.java | 1 + .../CassandraPerUserMaxQuotaManagerV2.java | 2 + mailbox/postgres/pom.xml | 21 - .../postgres/quota/JPAPerUserMaxQuotaDAO.java | 238 ------------ .../quota/JPAPerUserMaxQuotaManager.java | 292 -------------- .../quota/PostgresPerUserMaxQuotaManager.java | 361 ++++++++++++++++++ .../quota/model/MaxDomainMessageCount.java | 54 --- .../quota/model/MaxDomainStorage.java | 55 --- .../quota/model/MaxGlobalMessageCount.java | 54 --- .../quota/model/MaxGlobalStorage.java | 54 --- .../quota/model/MaxUserMessageCount.java | 52 --- .../postgres/quota/model/MaxUserStorage.java | 53 --- .../mailbox/postgres/JPAMailboxFixture.java | 51 --- ...> PostgresPerUserMaxQuotaManagerTest.java} | 22 +- .../src/test/resources/persistence.xml | 42 -- .../postgres/host/PostgresHostSystem.java | 26 +- .../main/resources/META-INF/persistence.xml | 9 - .../modules/mailbox/PostgresQuotaModule.java | 10 +- 23 files changed, 394 insertions(+), 1019 deletions(-) rename mailbox/{cassandra/src/main/java/org/apache/james/mailbox/cassandra => api/src/main/java/org/apache/james/mailbox}/quota/Limits.java (97%) rename mailbox/{cassandra/src/main/java/org/apache/james/mailbox/cassandra => api/src/main/java/org/apache/james/mailbox}/quota/QuotaCodec.java (90%) delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/JPAPerUserMaxQuotaDAO.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/JPAPerUserMaxQuotaManager.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/PostgresPerUserMaxQuotaManager.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxDomainMessageCount.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxDomainStorage.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxGlobalMessageCount.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxGlobalStorage.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxUserMessageCount.java delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxUserStorage.java delete mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxFixture.java rename mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/{JPAPerUserMaxQuotaTest.java => PostgresPerUserMaxQuotaManagerTest.java} (65%) delete mode 100644 mailbox/postgres/src/test/resources/persistence.xml diff --git a/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/Limits.java b/mailbox/api/src/main/java/org/apache/james/mailbox/quota/Limits.java similarity index 97% rename from mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/Limits.java rename to mailbox/api/src/main/java/org/apache/james/mailbox/quota/Limits.java index 3ef7aec0975..f278d03ed75 100644 --- a/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/Limits.java +++ b/mailbox/api/src/main/java/org/apache/james/mailbox/quota/Limits.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.cassandra.quota; +package org.apache.james.mailbox.quota; import java.util.Optional; diff --git a/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/QuotaCodec.java b/mailbox/api/src/main/java/org/apache/james/mailbox/quota/QuotaCodec.java similarity index 90% rename from mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/QuotaCodec.java rename to mailbox/api/src/main/java/org/apache/james/mailbox/quota/QuotaCodec.java index 87b6cdcef79..d3d9b5cd67a 100644 --- a/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/QuotaCodec.java +++ b/mailbox/api/src/main/java/org/apache/james/mailbox/quota/QuotaCodec.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.mailbox.cassandra.quota; +package org.apache.james.mailbox.quota; import java.util.Optional; import java.util.function.Function; @@ -30,18 +30,18 @@ public class QuotaCodec { private static final long INFINITE = -1; private static final long NO_RIGHT = 0L; - static Long quotaValueToLong(QuotaLimitValue value) { + public static Long quotaValueToLong(QuotaLimitValue value) { if (value.isUnlimited()) { return INFINITE; } return value.asLong(); } - static Optional longToQuotaSize(Long value) { + public static Optional longToQuotaSize(Long value) { return longToQuotaValue(value, QuotaSizeLimit.unlimited(), QuotaSizeLimit::size); } - static Optional longToQuotaCount(Long value) { + public static Optional longToQuotaCount(Long value) { return longToQuotaValue(value, QuotaCountLimit.unlimited(), QuotaCountLimit::count); } diff --git a/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraGlobalMaxQuotaDao.java b/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraGlobalMaxQuotaDao.java index 02e777d7f18..3be44c6c6d6 100644 --- a/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraGlobalMaxQuotaDao.java +++ b/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraGlobalMaxQuotaDao.java @@ -39,6 +39,8 @@ import org.apache.james.backends.cassandra.utils.CassandraAsyncExecutor; import org.apache.james.core.quota.QuotaCountLimit; import org.apache.james.core.quota.QuotaSizeLimit; +import org.apache.james.mailbox.quota.Limits; +import org.apache.james.mailbox.quota.QuotaCodec; import com.datastax.oss.driver.api.core.CqlSession; import com.datastax.oss.driver.api.core.cql.PreparedStatement; diff --git a/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraPerDomainMaxQuotaDao.java b/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraPerDomainMaxQuotaDao.java index c583a6a487d..53267376eda 100644 --- a/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraPerDomainMaxQuotaDao.java +++ b/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraPerDomainMaxQuotaDao.java @@ -35,6 +35,8 @@ import org.apache.james.core.quota.QuotaCountLimit; import org.apache.james.core.quota.QuotaSizeLimit; import org.apache.james.mailbox.cassandra.table.CassandraDomainMaxQuota; +import org.apache.james.mailbox.quota.Limits; +import org.apache.james.mailbox.quota.QuotaCodec; import com.datastax.oss.driver.api.core.CqlSession; import com.datastax.oss.driver.api.core.cql.PreparedStatement; diff --git a/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraPerUserMaxQuotaDao.java b/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraPerUserMaxQuotaDao.java index db1c36eeaa0..932da5ea912 100644 --- a/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraPerUserMaxQuotaDao.java +++ b/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraPerUserMaxQuotaDao.java @@ -35,6 +35,8 @@ import org.apache.james.core.quota.QuotaSizeLimit; import org.apache.james.mailbox.cassandra.table.CassandraMaxQuota; import org.apache.james.mailbox.model.QuotaRoot; +import org.apache.james.mailbox.quota.Limits; +import org.apache.james.mailbox.quota.QuotaCodec; import com.datastax.oss.driver.api.core.CqlSession; import com.datastax.oss.driver.api.core.cql.PreparedStatement; diff --git a/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraPerUserMaxQuotaManagerV1.java b/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraPerUserMaxQuotaManagerV1.java index d6dead22c40..6016288f5d5 100644 --- a/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraPerUserMaxQuotaManagerV1.java +++ b/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraPerUserMaxQuotaManagerV1.java @@ -32,6 +32,7 @@ import org.apache.james.core.quota.QuotaSizeLimit; import org.apache.james.mailbox.model.Quota; import org.apache.james.mailbox.model.QuotaRoot; +import org.apache.james.mailbox.quota.Limits; import org.apache.james.mailbox.quota.MaxQuotaManager; import com.google.common.collect.ImmutableMap; diff --git a/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraPerUserMaxQuotaManagerV2.java b/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraPerUserMaxQuotaManagerV2.java index 6dc23e14229..e698d8c9df9 100644 --- a/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraPerUserMaxQuotaManagerV2.java +++ b/mailbox/cassandra/src/main/java/org/apache/james/mailbox/cassandra/quota/CassandraPerUserMaxQuotaManagerV2.java @@ -40,7 +40,9 @@ import org.apache.james.core.quota.QuotaType; import org.apache.james.mailbox.model.Quota; import org.apache.james.mailbox.model.QuotaRoot; +import org.apache.james.mailbox.quota.Limits; import org.apache.james.mailbox.quota.MaxQuotaManager; +import org.apache.james.mailbox.quota.QuotaCodec; import com.google.common.collect.ImmutableMap; diff --git a/mailbox/postgres/pom.xml b/mailbox/postgres/pom.xml index edc6bfac4b2..e2f2d9bcd77 100644 --- a/mailbox/postgres/pom.xml +++ b/mailbox/postgres/pom.xml @@ -195,27 +195,6 @@ -Xms512m -Xmx1024m -Dopenjpa.Multithreaded=true - - org.apache.openjpa - openjpa-maven-plugin - ${apache.openjpa.version} - - org/apache/james/mailbox/jpa/*/model/**/*.class - org/apache/james/mailbox/jpa/mail/model/openjpa/EncryptDecryptHelper.class - true - true - ${basedir}/src/test/resources/persistence.xml - - - - enhancer - - enhance - - process-classes - - - diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/JPAPerUserMaxQuotaDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/JPAPerUserMaxQuotaDAO.java deleted file mode 100644 index 31630798d3e..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/JPAPerUserMaxQuotaDAO.java +++ /dev/null @@ -1,238 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.quota; - -import java.util.Optional; -import java.util.function.Function; - -import javax.inject.Inject; -import javax.persistence.EntityManager; -import javax.persistence.EntityManagerFactory; - -import org.apache.james.backends.jpa.TransactionRunner; -import org.apache.james.core.Domain; -import org.apache.james.core.quota.QuotaCountLimit; -import org.apache.james.core.quota.QuotaLimitValue; -import org.apache.james.core.quota.QuotaSizeLimit; -import org.apache.james.mailbox.model.QuotaRoot; -import org.apache.james.mailbox.postgres.quota.model.MaxDomainMessageCount; -import org.apache.james.mailbox.postgres.quota.model.MaxDomainStorage; -import org.apache.james.mailbox.postgres.quota.model.MaxGlobalMessageCount; -import org.apache.james.mailbox.postgres.quota.model.MaxGlobalStorage; -import org.apache.james.mailbox.postgres.quota.model.MaxUserMessageCount; -import org.apache.james.mailbox.postgres.quota.model.MaxUserStorage; - -public class JPAPerUserMaxQuotaDAO { - - private static final long INFINITE = -1; - private final TransactionRunner transactionRunner; - - @Inject - public JPAPerUserMaxQuotaDAO(EntityManagerFactory entityManagerFactory) { - this.transactionRunner = new TransactionRunner(entityManagerFactory); - } - - public void setMaxStorage(QuotaRoot quotaRoot, Optional maxStorageQuota) { - transactionRunner.run( - entityManager -> { - MaxUserStorage storedValue = getMaxUserStorageEntity(entityManager, quotaRoot, maxStorageQuota); - entityManager.persist(storedValue); - }); - } - - private MaxUserStorage getMaxUserStorageEntity(EntityManager entityManager, QuotaRoot quotaRoot, Optional maxStorageQuota) { - MaxUserStorage storedValue = entityManager.find(MaxUserStorage.class, quotaRoot.getValue()); - Long value = quotaValueToLong(maxStorageQuota); - if (storedValue == null) { - return new MaxUserStorage(quotaRoot.getValue(), value); - } - storedValue.setValue(value); - return storedValue; - } - - public void setMaxMessage(QuotaRoot quotaRoot, Optional maxMessageCount) { - transactionRunner.run( - entityManager -> { - MaxUserMessageCount storedValue = getMaxUserMessageEntity(entityManager, quotaRoot, maxMessageCount); - entityManager.persist(storedValue); - }); - } - - private MaxUserMessageCount getMaxUserMessageEntity(EntityManager entityManager, QuotaRoot quotaRoot, Optional maxMessageQuota) { - MaxUserMessageCount storedValue = entityManager.find(MaxUserMessageCount.class, quotaRoot.getValue()); - Long value = quotaValueToLong(maxMessageQuota); - if (storedValue == null) { - return new MaxUserMessageCount(quotaRoot.getValue(), value); - } - storedValue.setValue(value); - return storedValue; - } - - public void setDomainMaxMessage(Domain domain, Optional count) { - transactionRunner.run( - entityManager -> { - MaxDomainMessageCount storedValue = getMaxDomainMessageEntity(entityManager, domain, count); - entityManager.persist(storedValue); - }); - } - - - public void setDomainMaxStorage(Domain domain, Optional size) { - transactionRunner.run( - entityManager -> { - MaxDomainStorage storedValue = getMaxDomainStorageEntity(entityManager, domain, size); - entityManager.persist(storedValue); - }); - } - - private MaxDomainMessageCount getMaxDomainMessageEntity(EntityManager entityManager, Domain domain, Optional maxMessageQuota) { - MaxDomainMessageCount storedValue = entityManager.find(MaxDomainMessageCount.class, domain.asString()); - Long value = quotaValueToLong(maxMessageQuota); - if (storedValue == null) { - return new MaxDomainMessageCount(domain, value); - } - storedValue.setValue(value); - return storedValue; - } - - private MaxDomainStorage getMaxDomainStorageEntity(EntityManager entityManager, Domain domain, Optional maxStorageQuota) { - MaxDomainStorage storedValue = entityManager.find(MaxDomainStorage.class, domain.asString()); - Long value = quotaValueToLong(maxStorageQuota); - if (storedValue == null) { - return new MaxDomainStorage(domain, value); - } - storedValue.setValue(value); - return storedValue; - } - - - public void setGlobalMaxStorage(Optional globalMaxStorage) { - transactionRunner.run( - entityManager -> { - MaxGlobalStorage globalMaxStorageEntity = getGlobalMaxStorageEntity(entityManager, globalMaxStorage); - entityManager.persist(globalMaxStorageEntity); - }); - } - - private MaxGlobalStorage getGlobalMaxStorageEntity(EntityManager entityManager, Optional maxSizeQuota) { - MaxGlobalStorage storedValue = entityManager.find(MaxGlobalStorage.class, MaxGlobalStorage.DEFAULT_KEY); - Long value = quotaValueToLong(maxSizeQuota); - if (storedValue == null) { - return new MaxGlobalStorage(value); - } - storedValue.setValue(value); - return storedValue; - } - - public void setGlobalMaxMessage(Optional globalMaxMessageCount) { - transactionRunner.run( - entityManager -> { - MaxGlobalMessageCount globalMaxMessageEntity = getGlobalMaxMessageEntity(entityManager, globalMaxMessageCount); - entityManager.persist(globalMaxMessageEntity); - }); - } - - private MaxGlobalMessageCount getGlobalMaxMessageEntity(EntityManager entityManager, Optional maxMessageQuota) { - MaxGlobalMessageCount storedValue = entityManager.find(MaxGlobalMessageCount.class, MaxGlobalMessageCount.DEFAULT_KEY); - Long value = quotaValueToLong(maxMessageQuota); - if (storedValue == null) { - return new MaxGlobalMessageCount(value); - } - storedValue.setValue(value); - return storedValue; - } - - public Optional getGlobalMaxStorage(EntityManager entityManager) { - MaxGlobalStorage storedValue = entityManager.find(MaxGlobalStorage.class, MaxGlobalStorage.DEFAULT_KEY); - if (storedValue == null) { - return Optional.empty(); - } - return longToQuotaSize(storedValue.getValue()); - } - - public Optional getGlobalMaxMessage(EntityManager entityManager) { - MaxGlobalMessageCount storedValue = entityManager.find(MaxGlobalMessageCount.class, MaxGlobalMessageCount.DEFAULT_KEY); - if (storedValue == null) { - return Optional.empty(); - } - return longToQuotaCount(storedValue.getValue()); - } - - public Optional getMaxStorage(EntityManager entityManager, QuotaRoot quotaRoot) { - MaxUserStorage storedValue = entityManager.find(MaxUserStorage.class, quotaRoot.getValue()); - if (storedValue == null) { - return Optional.empty(); - } - return longToQuotaSize(storedValue.getValue()); - } - - public Optional getMaxMessage(EntityManager entityManager, QuotaRoot quotaRoot) { - MaxUserMessageCount storedValue = entityManager.find(MaxUserMessageCount.class, quotaRoot.getValue()); - if (storedValue == null) { - return Optional.empty(); - } - return longToQuotaCount(storedValue.getValue()); - } - - public Optional getDomainMaxMessage(EntityManager entityManager, Domain domain) { - MaxDomainMessageCount storedValue = entityManager.find(MaxDomainMessageCount.class, domain.asString()); - if (storedValue == null) { - return Optional.empty(); - } - return longToQuotaCount(storedValue.getValue()); - } - - public Optional getDomainMaxStorage(EntityManager entityManager, Domain domain) { - MaxDomainStorage storedValue = entityManager.find(MaxDomainStorage.class, domain.asString()); - if (storedValue == null) { - return Optional.empty(); - } - return longToQuotaSize(storedValue.getValue()); - } - - - private Long quotaValueToLong(Optional> maxStorageQuota) { - return maxStorageQuota.map(value -> { - if (value.isUnlimited()) { - return INFINITE; - } - return value.asLong(); - }).orElse(null); - } - - private Optional longToQuotaSize(Long value) { - return longToQuotaValue(value, QuotaSizeLimit.unlimited(), QuotaSizeLimit::size); - } - - private Optional longToQuotaCount(Long value) { - return longToQuotaValue(value, QuotaCountLimit.unlimited(), QuotaCountLimit::count); - } - - private > Optional longToQuotaValue(Long value, T infiniteValue, Function quotaFactory) { - if (value == null) { - return Optional.empty(); - } - if (value == INFINITE) { - return Optional.of(infiniteValue); - } - return Optional.of(quotaFactory.apply(value)); - } - -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/JPAPerUserMaxQuotaManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/JPAPerUserMaxQuotaManager.java deleted file mode 100644 index 6572b71ea52..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/JPAPerUserMaxQuotaManager.java +++ /dev/null @@ -1,292 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.quota; - -import java.util.Map; -import java.util.Optional; -import java.util.function.Function; -import java.util.stream.Stream; - -import javax.inject.Inject; -import javax.persistence.EntityManager; -import javax.persistence.EntityManagerFactory; - -import org.apache.commons.lang3.tuple.Pair; -import org.apache.james.backends.jpa.EntityManagerUtils; -import org.apache.james.core.Domain; -import org.apache.james.core.quota.QuotaCountLimit; -import org.apache.james.core.quota.QuotaSizeLimit; -import org.apache.james.mailbox.model.Quota; -import org.apache.james.mailbox.model.QuotaRoot; -import org.apache.james.mailbox.quota.MaxQuotaManager; -import org.reactivestreams.Publisher; - -import com.github.fge.lambdas.Throwing; -import com.google.common.collect.ImmutableMap; - -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; - -public class JPAPerUserMaxQuotaManager implements MaxQuotaManager { - private final EntityManagerFactory entityManagerFactory; - private final JPAPerUserMaxQuotaDAO dao; - - @Inject - public JPAPerUserMaxQuotaManager(EntityManagerFactory entityManagerFactory, JPAPerUserMaxQuotaDAO dao) { - this.entityManagerFactory = entityManagerFactory; - this.dao = dao; - } - - @Override - public void setMaxStorage(QuotaRoot quotaRoot, QuotaSizeLimit maxStorageQuota) { - dao.setMaxStorage(quotaRoot, Optional.of(maxStorageQuota)); - } - - @Override - public Publisher setMaxStorageReactive(QuotaRoot quotaRoot, QuotaSizeLimit maxStorageQuota) { - return Mono.fromRunnable(() -> setMaxStorage(quotaRoot, maxStorageQuota)); - } - - @Override - public void setMaxMessage(QuotaRoot quotaRoot, QuotaCountLimit maxMessageCount) { - dao.setMaxMessage(quotaRoot, Optional.of(maxMessageCount)); - } - - @Override - public Publisher setMaxMessageReactive(QuotaRoot quotaRoot, QuotaCountLimit maxMessageCount) { - return Mono.fromRunnable(() -> setMaxMessage(quotaRoot, maxMessageCount)); - } - - @Override - public void setDomainMaxMessage(Domain domain, QuotaCountLimit count) { - dao.setDomainMaxMessage(domain, Optional.of(count)); - } - - @Override - public Publisher setDomainMaxMessageReactive(Domain domain, QuotaCountLimit count) { - return Mono.fromRunnable(() -> setDomainMaxMessage(domain, count)); - } - - @Override - public void setDomainMaxStorage(Domain domain, QuotaSizeLimit size) { - dao.setDomainMaxStorage(domain, Optional.of(size)); - } - - @Override - public Publisher setDomainMaxStorageReactive(Domain domain, QuotaSizeLimit size) { - return Mono.fromRunnable(() -> setDomainMaxStorage(domain, size)); - } - - @Override - public void removeDomainMaxMessage(Domain domain) { - dao.setDomainMaxMessage(domain, Optional.empty()); - } - - @Override - public Publisher removeDomainMaxMessageReactive(Domain domain) { - return Mono.fromRunnable(() -> removeDomainMaxMessage(domain)); - } - - @Override - public void removeDomainMaxStorage(Domain domain) { - dao.setDomainMaxStorage(domain, Optional.empty()); - } - - @Override - public Publisher removeDomainMaxStorageReactive(Domain domain) { - return Mono.fromRunnable(() -> removeDomainMaxStorage(domain)); - } - - @Override - public Optional getDomainMaxMessage(Domain domain) { - EntityManager entityManager = entityManagerFactory.createEntityManager(); - try { - return dao.getDomainMaxMessage(entityManager, domain); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - @Override - public Publisher getDomainMaxMessageReactive(Domain domain) { - return Mono.fromSupplier(() -> getDomainMaxMessage(domain)) - .flatMap(Mono::justOrEmpty); - } - - @Override - public Optional getDomainMaxStorage(Domain domain) { - EntityManager entityManager = entityManagerFactory.createEntityManager(); - try { - return dao.getDomainMaxStorage(entityManager, domain); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - @Override - public Publisher getDomainMaxStorageReactive(Domain domain) { - return Mono.fromSupplier(() -> getDomainMaxStorage(domain)) - .flatMap(Mono::justOrEmpty); - } - - @Override - public void removeMaxMessage(QuotaRoot quotaRoot) { - dao.setMaxMessage(quotaRoot, Optional.empty()); - } - - @Override - public Publisher removeMaxMessageReactive(QuotaRoot quotaRoot) { - return Mono.fromRunnable(() -> removeMaxMessage(quotaRoot)); - } - - @Override - public void setGlobalMaxStorage(QuotaSizeLimit globalMaxStorage) { - dao.setGlobalMaxStorage(Optional.of(globalMaxStorage)); - } - - @Override - public Publisher setGlobalMaxStorageReactive(QuotaSizeLimit globalMaxStorage) { - return Mono.fromRunnable(() -> setGlobalMaxStorage(globalMaxStorage)); - } - - @Override - public void removeGlobalMaxMessage() { - dao.setGlobalMaxMessage(Optional.empty()); - } - - @Override - public Publisher removeGlobalMaxMessageReactive() { - return Mono.fromRunnable(this::removeGlobalMaxMessage); - } - - @Override - public void setGlobalMaxMessage(QuotaCountLimit globalMaxMessageCount) { - dao.setGlobalMaxMessage(Optional.of(globalMaxMessageCount)); - } - - @Override - public Publisher setGlobalMaxMessageReactive(QuotaCountLimit globalMaxMessageCount) { - return Mono.fromRunnable(() -> setGlobalMaxMessage(globalMaxMessageCount)); - } - - @Override - public Optional getGlobalMaxStorage() { - EntityManager entityManager = entityManagerFactory.createEntityManager(); - try { - return dao.getGlobalMaxStorage(entityManager); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - @Override - public Publisher getGlobalMaxStorageReactive() { - return Mono.fromSupplier(this::getGlobalMaxStorage) - .flatMap(Mono::justOrEmpty); - } - - @Override - public Optional getGlobalMaxMessage() { - EntityManager entityManager = entityManagerFactory.createEntityManager(); - try { - return dao.getGlobalMaxMessage(entityManager); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - @Override - public Publisher getGlobalMaxMessageReactive() { - return Mono.fromSupplier(this::getGlobalMaxMessage) - .flatMap(Mono::justOrEmpty); - } - - @Override - public Publisher quotaDetailsReactive(QuotaRoot quotaRoot) { - EntityManager entityManager = entityManagerFactory.createEntityManager(); - return Mono.zip( - Mono.fromCallable(() -> listMaxMessagesDetails(quotaRoot, entityManager)), - Mono.fromCallable(() -> listMaxStorageDetails(quotaRoot, entityManager))) - .map(tuple -> new QuotaDetails(tuple.getT1(), tuple.getT2())) - .subscribeOn(Schedulers.boundedElastic()) - .doFinally(any -> EntityManagerUtils.safelyClose(entityManager)); - } - - @Override - public Map listMaxMessagesDetails(QuotaRoot quotaRoot) { - EntityManager entityManager = entityManagerFactory.createEntityManager(); - try { - return listMaxMessagesDetails(quotaRoot, entityManager); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - private ImmutableMap listMaxMessagesDetails(QuotaRoot quotaRoot, EntityManager entityManager) { - Function> domainQuotaFunction = Throwing.function(domain -> dao.getDomainMaxMessage(entityManager, domain)); - return Stream.of( - Pair.of(Quota.Scope.User, dao.getMaxMessage(entityManager, quotaRoot)), - Pair.of(Quota.Scope.Domain, quotaRoot.getDomain().flatMap(domainQuotaFunction)), - Pair.of(Quota.Scope.Global, dao.getGlobalMaxMessage(entityManager))) - .filter(pair -> pair.getValue().isPresent()) - .collect(ImmutableMap.toImmutableMap(Pair::getKey, value -> value.getValue().get())); - } - - @Override - public Map listMaxStorageDetails(QuotaRoot quotaRoot) { - EntityManager entityManager = entityManagerFactory.createEntityManager(); - try { - return listMaxStorageDetails(quotaRoot, entityManager); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - private ImmutableMap listMaxStorageDetails(QuotaRoot quotaRoot, EntityManager entityManager) { - Function> domainQuotaFunction = Throwing.function(domain -> dao.getDomainMaxStorage(entityManager, domain)); - return Stream.of( - Pair.of(Quota.Scope.User, dao.getMaxStorage(entityManager, quotaRoot)), - Pair.of(Quota.Scope.Domain, quotaRoot.getDomain().flatMap(domainQuotaFunction)), - Pair.of(Quota.Scope.Global, dao.getGlobalMaxStorage(entityManager))) - .filter(pair -> pair.getValue().isPresent()) - .collect(ImmutableMap.toImmutableMap(Pair::getKey, value -> value.getValue().get())); - } - - @Override - public void removeMaxStorage(QuotaRoot quotaRoot) { - dao.setMaxStorage(quotaRoot, Optional.empty()); - } - - @Override - public Publisher removeMaxStorageReactive(QuotaRoot quotaRoot) { - return Mono.fromRunnable(() -> removeMaxStorage(quotaRoot)); - } - - @Override - public void removeGlobalMaxStorage() { - dao.setGlobalMaxStorage(Optional.empty()); - } - - @Override - public Publisher removeGlobalMaxStorageReactive() { - return Mono.fromRunnable(this::removeGlobalMaxStorage); - } - -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/PostgresPerUserMaxQuotaManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/PostgresPerUserMaxQuotaManager.java new file mode 100644 index 00000000000..e39ff808e1b --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/PostgresPerUserMaxQuotaManager.java @@ -0,0 +1,361 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.quota; + +import static org.apache.james.util.ReactorUtils.publishIfPresent; + +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import javax.inject.Inject; + +import org.apache.commons.lang3.tuple.Pair; +import org.apache.james.backends.postgres.quota.PostgresQuotaLimitDAO; +import org.apache.james.core.Domain; +import org.apache.james.core.quota.QuotaComponent; +import org.apache.james.core.quota.QuotaCountLimit; +import org.apache.james.core.quota.QuotaLimit; +import org.apache.james.core.quota.QuotaScope; +import org.apache.james.core.quota.QuotaSizeLimit; +import org.apache.james.core.quota.QuotaType; +import org.apache.james.mailbox.model.Quota; +import org.apache.james.mailbox.model.QuotaRoot; +import org.apache.james.mailbox.quota.Limits; +import org.apache.james.mailbox.quota.MaxQuotaManager; +import org.apache.james.mailbox.quota.QuotaCodec; + +import com.google.common.collect.ImmutableMap; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresPerUserMaxQuotaManager implements MaxQuotaManager { + private static final String GLOBAL_IDENTIFIER = "global"; + + private final PostgresQuotaLimitDAO postgresQuotaLimitDAO; + + @Inject + public PostgresPerUserMaxQuotaManager(PostgresQuotaLimitDAO postgresQuotaLimitDAO) { + this.postgresQuotaLimitDAO = postgresQuotaLimitDAO; + } + + @Override + public void setMaxStorage(QuotaRoot quotaRoot, QuotaSizeLimit maxStorageQuota) { + setMaxStorageReactive(quotaRoot, maxStorageQuota).block(); + } + + @Override + public Mono setMaxStorageReactive(QuotaRoot quotaRoot, QuotaSizeLimit maxStorageQuota) { + return postgresQuotaLimitDAO.setQuotaLimit(QuotaLimit.builder() + .quotaScope(QuotaScope.USER) + .identifier(quotaRoot.getValue()) + .quotaComponent(QuotaComponent.MAILBOX) + .quotaType(QuotaType.SIZE) + .quotaLimit(QuotaCodec.quotaValueToLong(maxStorageQuota)) + .build()); + } + + @Override + public void setMaxMessage(QuotaRoot quotaRoot, QuotaCountLimit maxMessageCount) { + setMaxMessageReactive(quotaRoot, maxMessageCount).block(); + } + + @Override + public Mono setMaxMessageReactive(QuotaRoot quotaRoot, QuotaCountLimit maxMessageCount) { + return postgresQuotaLimitDAO.setQuotaLimit(QuotaLimit.builder() + .quotaScope(QuotaScope.USER) + .identifier(quotaRoot.getValue()) + .quotaComponent(QuotaComponent.MAILBOX) + .quotaType(QuotaType.COUNT) + .quotaLimit(QuotaCodec.quotaValueToLong(maxMessageCount)) + .build()); + } + + @Override + public void setDomainMaxMessage(Domain domain, QuotaCountLimit count) { + setDomainMaxMessageReactive(domain, count).block(); + } + + @Override + public Mono setDomainMaxMessageReactive(Domain domain, QuotaCountLimit count) { + return postgresQuotaLimitDAO.setQuotaLimit(QuotaLimit.builder() + .quotaScope(QuotaScope.DOMAIN) + .identifier(domain.asString()) + .quotaComponent(QuotaComponent.MAILBOX) + .quotaType(QuotaType.COUNT) + .quotaLimit(QuotaCodec.quotaValueToLong(count)) + .build()); + } + + @Override + public void setDomainMaxStorage(Domain domain, QuotaSizeLimit size) { + setDomainMaxStorageReactive(domain, size).block(); + } + + @Override + public Mono setDomainMaxStorageReactive(Domain domain, QuotaSizeLimit size) { + return postgresQuotaLimitDAO.setQuotaLimit(QuotaLimit.builder() + .quotaScope(QuotaScope.DOMAIN) + .identifier(domain.asString()) + .quotaComponent(QuotaComponent.MAILBOX) + .quotaType(QuotaType.SIZE) + .quotaLimit(QuotaCodec.quotaValueToLong(size)) + .build()); + } + + @Override + public void removeDomainMaxMessage(Domain domain) { + removeDomainMaxMessageReactive(domain).block(); + } + + @Override + public Mono removeDomainMaxMessageReactive(Domain domain) { + return postgresQuotaLimitDAO.deleteQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.DOMAIN, domain.asString(), QuotaType.COUNT)); + } + + @Override + public void removeDomainMaxStorage(Domain domain) { + removeDomainMaxStorageReactive(domain).block(); + } + + @Override + public Mono removeDomainMaxStorageReactive(Domain domain) { + return postgresQuotaLimitDAO.deleteQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.DOMAIN, domain.asString(), QuotaType.SIZE)); + } + + @Override + public Optional getDomainMaxMessage(Domain domain) { + return getDomainMaxMessageReactive(domain).blockOptional(); + } + + @Override + public Mono getDomainMaxMessageReactive(Domain domain) { + return getMaxMessageReactive(QuotaScope.DOMAIN, domain.asString()); + } + + @Override + public Optional getDomainMaxStorage(Domain domain) { + return getDomainMaxStorageReactive(domain).blockOptional(); + } + + @Override + public Mono getDomainMaxStorageReactive(Domain domain) { + return getMaxStorageReactive(QuotaScope.DOMAIN, domain.asString()); + } + + @Override + public void removeMaxMessage(QuotaRoot quotaRoot) { + removeMaxMessageReactive(quotaRoot).block(); + } + + @Override + public Mono removeMaxMessageReactive(QuotaRoot quotaRoot) { + return postgresQuotaLimitDAO.deleteQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.USER, quotaRoot.getValue(), QuotaType.COUNT)); + } + + @Override + public void removeMaxStorage(QuotaRoot quotaRoot) { + removeMaxStorageReactive(quotaRoot).block(); + } + + @Override + public Mono removeMaxStorageReactive(QuotaRoot quotaRoot) { + return postgresQuotaLimitDAO.deleteQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.USER, quotaRoot.getValue(), QuotaType.SIZE)); + } + + @Override + public void setGlobalMaxStorage(QuotaSizeLimit globalMaxStorage) { + setGlobalMaxStorageReactive(globalMaxStorage).block(); + } + + @Override + public Mono setGlobalMaxStorageReactive(QuotaSizeLimit globalMaxStorage) { + return postgresQuotaLimitDAO.setQuotaLimit(QuotaLimit.builder() + .quotaScope(QuotaScope.GLOBAL).identifier(GLOBAL_IDENTIFIER) + .quotaComponent(QuotaComponent.MAILBOX) + .quotaType(QuotaType.SIZE) + .quotaLimit(QuotaCodec.quotaValueToLong(globalMaxStorage)) + .build()); + } + + @Override + public void removeGlobalMaxStorage() { + removeGlobalMaxStorageReactive().block(); + } + + @Override + public Mono removeGlobalMaxStorageReactive() { + return postgresQuotaLimitDAO.deleteQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.GLOBAL, GLOBAL_IDENTIFIER, QuotaType.SIZE)); + } + + @Override + public void setGlobalMaxMessage(QuotaCountLimit globalMaxMessageCount) { + setGlobalMaxMessageReactive(globalMaxMessageCount).block(); + } + + @Override + public Mono setGlobalMaxMessageReactive(QuotaCountLimit globalMaxMessageCount) { + return postgresQuotaLimitDAO.setQuotaLimit(QuotaLimit.builder() + .quotaScope(QuotaScope.GLOBAL).identifier(GLOBAL_IDENTIFIER) + .quotaComponent(QuotaComponent.MAILBOX) + .quotaType(QuotaType.COUNT) + .quotaLimit(QuotaCodec.quotaValueToLong(globalMaxMessageCount)) + .build()); + } + + @Override + public void removeGlobalMaxMessage() { + removeGlobalMaxMessageReactive().block(); + } + + @Override + public Mono removeGlobalMaxMessageReactive() { + return postgresQuotaLimitDAO.deleteQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.GLOBAL, GLOBAL_IDENTIFIER, QuotaType.COUNT)); + } + + @Override + public Optional getGlobalMaxStorage() { + return getGlobalMaxStorageReactive().blockOptional(); + } + + @Override + public Mono getGlobalMaxStorageReactive() { + return getMaxStorageReactive(QuotaScope.GLOBAL, GLOBAL_IDENTIFIER); + } + + @Override + public Optional getGlobalMaxMessage() { + return getGlobalMaxMessageReactive().blockOptional(); + } + + @Override + public Mono getGlobalMaxMessageReactive() { + return getMaxMessageReactive(QuotaScope.GLOBAL, GLOBAL_IDENTIFIER); + } + + @Override + public Map listMaxMessagesDetails(QuotaRoot quotaRoot) { + return listMaxMessagesDetailsReactive(quotaRoot).block(); + } + + @Override + public Mono> listMaxMessagesDetailsReactive(QuotaRoot quotaRoot) { + return Flux.merge( + getMaxMessageReactive(QuotaScope.USER, quotaRoot.getValue()) + .map(limit -> Pair.of(Quota.Scope.User, limit)), + Mono.justOrEmpty(quotaRoot.getDomain()) + .flatMap(domain -> getMaxMessageReactive(QuotaScope.DOMAIN, domain.asString())) + .map(limit -> Pair.of(Quota.Scope.Domain, limit)), + getGlobalMaxMessageReactive() + .map(limit -> Pair.of(Quota.Scope.Global, limit))) + .collect(ImmutableMap.toImmutableMap( + Pair::getKey, + Pair::getValue)); + } + + @Override + public Map listMaxStorageDetails(QuotaRoot quotaRoot) { + return listMaxStorageDetailsReactive(quotaRoot).block(); + } + + @Override + public Mono> listMaxStorageDetailsReactive(QuotaRoot quotaRoot) { + return Flux.merge( + getMaxStorageReactive(QuotaScope.USER, quotaRoot.getValue()) + .map(limit -> Pair.of(Quota.Scope.User, limit)), + Mono.justOrEmpty(quotaRoot.getDomain()) + .flatMap(domain -> getMaxStorageReactive(QuotaScope.DOMAIN, domain.asString())) + .map(limit -> Pair.of(Quota.Scope.Domain, limit)), + getGlobalMaxStorageReactive() + .map(limit -> Pair.of(Quota.Scope.Global, limit))) + .collect(ImmutableMap.toImmutableMap( + Pair::getKey, + Pair::getValue)); + } + + @Override + public QuotaDetails quotaDetails(QuotaRoot quotaRoot) { + return quotaDetailsReactive(quotaRoot) + .block(); + } + + @Override + public Mono quotaDetailsReactive(QuotaRoot quotaRoot) { + return Mono.zip( + getLimits(QuotaScope.USER, quotaRoot.getValue()), + Mono.justOrEmpty(quotaRoot.getDomain()).flatMap(domain -> getLimits(QuotaScope.DOMAIN, domain.asString())).switchIfEmpty(Mono.just(Limits.empty())), + getLimits(QuotaScope.GLOBAL, GLOBAL_IDENTIFIER)) + .map(tuple -> new QuotaDetails( + countDetails(tuple.getT1(), tuple.getT2(), tuple.getT3().getCountLimit()), + sizeDetails(tuple.getT1(), tuple.getT2(), tuple.getT3().getSizeLimit()))); + } + + private Mono getLimits(QuotaScope quotaScope, String identifier) { + return postgresQuotaLimitDAO.getQuotaLimits(QuotaComponent.MAILBOX, quotaScope, identifier) + .collectList() + .map(list -> { + Map> map = list.stream().collect(Collectors.toMap(QuotaLimit::getQuotaType, QuotaLimit::getQuotaLimit)); + return new Limits( + map.getOrDefault(QuotaType.SIZE, Optional.empty()).flatMap(QuotaCodec::longToQuotaSize), + map.getOrDefault(QuotaType.COUNT, Optional.empty()).flatMap(QuotaCodec::longToQuotaCount)); + }).switchIfEmpty(Mono.just(Limits.empty())); + } + + private Mono getMaxMessageReactive(QuotaScope quotaScope, String identifier) { + return postgresQuotaLimitDAO.getQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, quotaScope, identifier, QuotaType.COUNT)) + .map(QuotaLimit::getQuotaLimit) + .handle(publishIfPresent()) + .map(QuotaCodec::longToQuotaCount) + .handle(publishIfPresent()); + } + + public Mono getMaxStorageReactive(QuotaScope quotaScope, String identifier) { + return postgresQuotaLimitDAO.getQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, quotaScope, identifier, QuotaType.SIZE)) + .map(QuotaLimit::getQuotaLimit) + .handle(publishIfPresent()) + .map(QuotaCodec::longToQuotaSize) + .handle(publishIfPresent()); + } + + private Map sizeDetails(Limits userLimits, Limits domainLimits, Optional globalLimits) { + return Stream.of( + userLimits.getSizeLimit().stream().map(limit -> Pair.of(Quota.Scope.User, limit)), + domainLimits.getSizeLimit().stream().map(limit -> Pair.of(Quota.Scope.Domain, limit)), + globalLimits.stream().map(limit -> Pair.of(Quota.Scope.Global, limit))) + .flatMap(Function.identity()) + .collect(ImmutableMap.toImmutableMap( + Pair::getKey, + Pair::getValue)); + } + + private Map countDetails(Limits userLimits, Limits domainLimits, Optional globalLimits) { + return Stream.of( + userLimits.getCountLimit().stream().map(limit -> Pair.of(Quota.Scope.User, limit)), + domainLimits.getCountLimit().stream().map(limit -> Pair.of(Quota.Scope.Domain, limit)), + globalLimits.stream().map(limit -> Pair.of(Quota.Scope.Global, limit))) + .flatMap(Function.identity()) + .collect(ImmutableMap.toImmutableMap( + Pair::getKey, + Pair::getValue)); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxDomainMessageCount.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxDomainMessageCount.java deleted file mode 100644 index be4cf2a30a0..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxDomainMessageCount.java +++ /dev/null @@ -1,54 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.quota.model; - -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.Id; -import javax.persistence.Table; - -import org.apache.james.core.Domain; - -@Entity(name = "MaxDomainMessageCount") -@Table(name = "JAMES_MAX_DOMAIN_MESSAGE_COUNT") -public class MaxDomainMessageCount { - @Id - @Column(name = "DOMAIN") - private String domain; - - @Column(name = "VALUE", nullable = true) - private Long value; - - public MaxDomainMessageCount(Domain domain, Long value) { - this.domain = domain.asString(); - this.value = value; - } - - public MaxDomainMessageCount() { - } - - public Long getValue() { - return value; - } - - public void setValue(Long value) { - this.value = value; - } -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxDomainStorage.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxDomainStorage.java deleted file mode 100644 index ec668421dcf..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxDomainStorage.java +++ /dev/null @@ -1,55 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.quota.model; - -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.Id; -import javax.persistence.Table; - -import org.apache.james.core.Domain; - -@Entity(name = "MaxDomainStorage") -@Table(name = "JAMES_MAX_DOMAIN_STORAGE") -public class MaxDomainStorage { - - @Id - @Column(name = "DOMAIN") - private String domain; - - @Column(name = "VALUE", nullable = true) - private Long value; - - public MaxDomainStorage(Domain domain, Long value) { - this.domain = domain.asString(); - this.value = value; - } - - public MaxDomainStorage() { - } - - public Long getValue() { - return value; - } - - public void setValue(Long value) { - this.value = value; - } -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxGlobalMessageCount.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxGlobalMessageCount.java deleted file mode 100644 index 1041e75533b..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxGlobalMessageCount.java +++ /dev/null @@ -1,54 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.quota.model; - -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.Id; -import javax.persistence.Table; - -@Entity(name = "MaxGlobalMessageCount") -@Table(name = "JAMES_MAX_GLOBAL_MESSAGE_COUNT") -public class MaxGlobalMessageCount { - public static final String DEFAULT_KEY = "default_key"; - - @Id - @Column(name = "QUOTAROOT_ID") - private String quotaRoot = DEFAULT_KEY; - - @Column(name = "VALUE", nullable = true) - private Long value; - - public MaxGlobalMessageCount(Long value) { - this.quotaRoot = DEFAULT_KEY; - this.value = value; - } - - public MaxGlobalMessageCount() { - } - - public Long getValue() { - return value; - } - - public void setValue(Long value) { - this.value = value; - } -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxGlobalStorage.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxGlobalStorage.java deleted file mode 100644 index 59b9a1601c1..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxGlobalStorage.java +++ /dev/null @@ -1,54 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.quota.model; - -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.Id; -import javax.persistence.Table; - -@Entity(name = "MaxGlobalStorage") -@Table(name = "JAMES_MAX_Global_STORAGE") -public class MaxGlobalStorage { - public static final String DEFAULT_KEY = "default_key"; - - @Id - @Column(name = "QUOTAROOT_ID") - private String quotaRoot = DEFAULT_KEY; - - @Column(name = "VALUE", nullable = true) - private Long value; - - public MaxGlobalStorage(Long value) { - this.quotaRoot = DEFAULT_KEY; - this.value = value; - } - - public MaxGlobalStorage() { - } - - public Long getValue() { - return value; - } - - public void setValue(Long value) { - this.value = value; - } -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxUserMessageCount.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxUserMessageCount.java deleted file mode 100644 index 9f31a8ef5ea..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxUserMessageCount.java +++ /dev/null @@ -1,52 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.quota.model; - -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.Id; -import javax.persistence.Table; - -@Entity(name = "MaxUserMessageCount") -@Table(name = "JAMES_MAX_USER_MESSAGE_COUNT") -public class MaxUserMessageCount { - @Id - @Column(name = "QUOTAROOT_ID") - private String quotaRoot; - - @Column(name = "VALUE", nullable = true) - private Long value; - - public MaxUserMessageCount(String quotaRoot, Long value) { - this.quotaRoot = quotaRoot; - this.value = value; - } - - public MaxUserMessageCount() { - } - - public Long getValue() { - return value; - } - - public void setValue(Long value) { - this.value = value; - } -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxUserStorage.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxUserStorage.java deleted file mode 100644 index a4633380d08..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/model/MaxUserStorage.java +++ /dev/null @@ -1,53 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.quota.model; - -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.Id; -import javax.persistence.Table; - -@Entity(name = "MaxUserStorage") -@Table(name = "JAMES_MAX_USER_STORAGE") -public class MaxUserStorage { - - @Id - @Column(name = "QUOTAROOT_ID") - private String quotaRoot; - - @Column(name = "VALUE", nullable = true) - private Long value; - - public MaxUserStorage(String quotaRoot, Long value) { - this.quotaRoot = quotaRoot; - this.value = value; - } - - public MaxUserStorage() { - } - - public Long getValue() { - return value; - } - - public void setValue(Long value) { - this.value = value; - } -} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxFixture.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxFixture.java deleted file mode 100644 index 6c34d837d7d..00000000000 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/JPAMailboxFixture.java +++ /dev/null @@ -1,51 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres; - -import java.util.List; - -import org.apache.james.mailbox.postgres.quota.model.MaxDomainMessageCount; -import org.apache.james.mailbox.postgres.quota.model.MaxDomainStorage; -import org.apache.james.mailbox.postgres.quota.model.MaxGlobalMessageCount; -import org.apache.james.mailbox.postgres.quota.model.MaxGlobalStorage; -import org.apache.james.mailbox.postgres.quota.model.MaxUserMessageCount; -import org.apache.james.mailbox.postgres.quota.model.MaxUserStorage; - -import com.google.common.collect.ImmutableList; - -public interface JPAMailboxFixture { - - List> QUOTA_PERSISTANCE_CLASSES = ImmutableList.of( - MaxGlobalMessageCount.class, - MaxGlobalStorage.class, - MaxDomainStorage.class, - MaxDomainMessageCount.class, - MaxUserMessageCount.class, - MaxUserStorage.class); - - List QUOTA_TABLES_NAMES = ImmutableList.of( - "JAMES_MAX_GLOBAL_MESSAGE_COUNT", - "JAMES_MAX_GLOBAL_STORAGE", - "JAMES_MAX_USER_MESSAGE_COUNT", - "JAMES_MAX_USER_STORAGE", - "JAMES_MAX_DOMAIN_MESSAGE_COUNT", - "JAMES_MAX_DOMAIN_STORAGE" - ); -} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/JPAPerUserMaxQuotaTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/PostgresPerUserMaxQuotaManagerTest.java similarity index 65% rename from mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/JPAPerUserMaxQuotaTest.java rename to mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/PostgresPerUserMaxQuotaManagerTest.java index 6b14d6f83cd..56da9f23784 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/JPAPerUserMaxQuotaTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/PostgresPerUserMaxQuotaManagerTest.java @@ -19,25 +19,19 @@ package org.apache.james.mailbox.postgres.quota; -import org.apache.james.backends.jpa.JpaTestCluster; -import org.apache.james.mailbox.postgres.JPAMailboxFixture; -import org.apache.james.mailbox.postgres.quota.JPAPerUserMaxQuotaDAO; -import org.apache.james.mailbox.postgres.quota.JPAPerUserMaxQuotaManager; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.quota.PostgresQuotaLimitDAO; +import org.apache.james.backends.postgres.quota.PostgresQuotaModule; import org.apache.james.mailbox.quota.MaxQuotaManager; import org.apache.james.mailbox.store.quota.GenericMaxQuotaManagerTest; -import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.extension.RegisterExtension; -class JPAPerUserMaxQuotaTest extends GenericMaxQuotaManagerTest { - - static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMailboxFixture.QUOTA_PERSISTANCE_CLASSES); +public class PostgresPerUserMaxQuotaManagerTest extends GenericMaxQuotaManagerTest { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresQuotaModule.MODULE); @Override protected MaxQuotaManager provideMaxQuotaManager() { - return new JPAPerUserMaxQuotaManager(JPA_TEST_CLUSTER.getEntityManagerFactory(), new JPAPerUserMaxQuotaDAO(JPA_TEST_CLUSTER.getEntityManagerFactory())); - } - - @AfterEach - void cleanUp() { - JPA_TEST_CLUSTER.clear(JPAMailboxFixture.QUOTA_TABLES_NAMES); + return new PostgresPerUserMaxQuotaManager(new PostgresQuotaLimitDAO(postgresExtension.getPostgresExecutor())); } } diff --git a/mailbox/postgres/src/test/resources/persistence.xml b/mailbox/postgres/src/test/resources/persistence.xml deleted file mode 100644 index 21199cfdd48..00000000000 --- a/mailbox/postgres/src/test/resources/persistence.xml +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - org.apache.james.mailbox.postgres.quota.model.MaxDomainMessageCount - org.apache.james.mailbox.postgres.quota.model.MaxDomainStorage - org.apache.james.mailbox.postgres.quota.model.MaxGlobalMessageCount - org.apache.james.mailbox.postgres.quota.model.MaxGlobalStorage - org.apache.james.mailbox.postgres.quota.model.MaxUserMessageCount - org.apache.james.mailbox.postgres.quota.model.MaxUserStorage - - - - - - - - - - diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java index 5c5ab7e6882..0e2a041730b 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java @@ -22,11 +22,9 @@ import java.time.Clock; import java.time.Instant; -import javax.persistence.EntityManagerFactory; - -import org.apache.james.backends.jpa.JpaTestCluster; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.backends.postgres.quota.PostgresQuotaCurrentValueDAO; +import org.apache.james.backends.postgres.quota.PostgresQuotaLimitDAO; import org.apache.james.blob.api.BlobId; import org.apache.james.blob.api.BucketName; import org.apache.james.blob.api.HashBlobId; @@ -46,13 +44,11 @@ import org.apache.james.mailbox.SubscriptionManager; import org.apache.james.mailbox.acl.MailboxACLResolver; import org.apache.james.mailbox.acl.UnionMailboxACLResolver; -import org.apache.james.mailbox.postgres.JPAMailboxFixture; import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; import org.apache.james.mailbox.postgres.PostgresMessageId; import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; -import org.apache.james.mailbox.postgres.quota.JPAPerUserMaxQuotaDAO; -import org.apache.james.mailbox.postgres.quota.JPAPerUserMaxQuotaManager; import org.apache.james.mailbox.postgres.quota.PostgresCurrentQuotaManager; +import org.apache.james.mailbox.postgres.quota.PostgresPerUserMaxQuotaManager; import org.apache.james.mailbox.quota.CurrentQuotaManager; import org.apache.james.mailbox.store.SessionProviderImpl; import org.apache.james.mailbox.store.StoreMailboxAnnotationManager; @@ -77,15 +73,9 @@ import org.apache.james.utils.UpdatableTickingClock; import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; public class PostgresHostSystem extends JamesImapHostSystem { - private static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create( - ImmutableList.>builder() - .addAll(JPAMailboxFixture.QUOTA_PERSISTANCE_CLASSES) - .build()); - private static final ImapFeatures SUPPORTED_FEATURES = ImapFeatures.of(Feature.NAMESPACE_SUPPORT, Feature.USER_FLAGS_SUPPORT, Feature.ANNOTATION_SUPPORT, @@ -98,7 +88,7 @@ static PostgresHostSystem build(PostgresExtension postgresExtension) { return new PostgresHostSystem(postgresExtension); } - private JPAPerUserMaxQuotaManager maxQuotaManager; + private PostgresPerUserMaxQuotaManager maxQuotaManager; private PostgresMailboxManager mailboxManager; private final PostgresExtension postgresExtension; @@ -113,7 +103,6 @@ public void beforeAll() { @Override public void beforeTest() throws Exception { super.beforeTest(); - EntityManagerFactory entityManagerFactory = JPA_TEST_CLUSTER.getEntityManagerFactory(); BlobId.Factory blobIdFactory = new HashBlobId.Factory(); DeDuplicationBlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory); @@ -130,7 +119,7 @@ public void beforeTest() throws Exception { SessionProviderImpl sessionProvider = new SessionProviderImpl(authenticator, authorizator); DefaultUserQuotaRootResolver quotaRootResolver = new DefaultUserQuotaRootResolver(sessionProvider, mapperFactory); CurrentQuotaManager currentQuotaManager = new PostgresCurrentQuotaManager(new PostgresQuotaCurrentValueDAO(postgresExtension.getPostgresExecutor())); - maxQuotaManager = new JPAPerUserMaxQuotaManager(entityManagerFactory, new JPAPerUserMaxQuotaDAO(entityManagerFactory)); + maxQuotaManager = new PostgresPerUserMaxQuotaManager(new PostgresQuotaLimitDAO(postgresExtension.getPostgresExecutor())); StoreQuotaManager storeQuotaManager = new StoreQuotaManager(currentQuotaManager, maxQuotaManager); ListeningCurrentQuotaUpdater quotaUpdater = new ListeningCurrentQuotaUpdater(currentQuotaManager, quotaRootResolver, eventBus, storeQuotaManager); QuotaComponents quotaComponents = new QuotaComponents(maxQuotaManager, storeQuotaManager, quotaRootResolver); @@ -160,13 +149,6 @@ public void beforeTest() throws Exception { defaultImapProcessorFactory); } - @Override - public void afterTest() { - JPA_TEST_CLUSTER.clear(ImmutableList.builder() - .addAll(JPAMailboxFixture.QUOTA_TABLES_NAMES) - .build()); - } - @Override protected MailboxManager getMailboxManager() { return mailboxManager; diff --git a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml index e2237925132..d074a13385a 100644 --- a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml +++ b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml @@ -29,15 +29,6 @@ org.apache.james.rrt.jpa.model.JPARecipientRewrite org.apache.james.sieve.postgres.model.JPASieveScript - org.apache.james.mailbox.postgres.quota.model.MaxDomainMessageCount - org.apache.james.mailbox.postgres.quota.model.MaxDomainStorage - org.apache.james.mailbox.postgres.quota.model.MaxGlobalMessageCount - org.apache.james.mailbox.postgres.quota.model.MaxGlobalStorage - org.apache.james.mailbox.postgres.quota.model.MaxUserMessageCount - org.apache.james.mailbox.postgres.quota.model.MaxUserStorage - org.apache.james.mailbox.postgres.quota.model.MaxDomainStorage - org.apache.james.mailbox.postgres.quota.model.MaxDomainMessageCount - diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresQuotaModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresQuotaModule.java index 8815b27812e..19894e74afc 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresQuotaModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresQuotaModule.java @@ -19,9 +19,10 @@ package org.apache.james.modules.mailbox; +import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.events.EventListener; -import org.apache.james.mailbox.postgres.quota.JPAPerUserMaxQuotaManager; import org.apache.james.mailbox.postgres.quota.PostgresCurrentQuotaManager; +import org.apache.james.mailbox.postgres.quota.PostgresPerUserMaxQuotaManager; import org.apache.james.mailbox.quota.CurrentQuotaManager; import org.apache.james.mailbox.quota.MaxQuotaManager; import org.apache.james.mailbox.quota.QuotaManager; @@ -41,15 +42,18 @@ public class PostgresQuotaModule extends AbstractModule { @Override protected void configure() { + Multibinder postgresDataDefinitions = Multibinder.newSetBinder(binder(), PostgresModule.class); + postgresDataDefinitions.addBinding().toInstance(org.apache.james.backends.postgres.quota.PostgresQuotaModule.MODULE); + bind(DefaultUserQuotaRootResolver.class).in(Scopes.SINGLETON); - bind(JPAPerUserMaxQuotaManager.class).in(Scopes.SINGLETON); + bind(PostgresPerUserMaxQuotaManager.class).in(Scopes.SINGLETON); bind(StoreQuotaManager.class).in(Scopes.SINGLETON); bind(PostgresCurrentQuotaManager.class).in(Scopes.SINGLETON); bind(UserQuotaRootResolver.class).to(DefaultUserQuotaRootResolver.class); bind(QuotaRootResolver.class).to(DefaultUserQuotaRootResolver.class); bind(QuotaRootDeserializer.class).to(DefaultUserQuotaRootResolver.class); - bind(MaxQuotaManager.class).to(JPAPerUserMaxQuotaManager.class); + bind(MaxQuotaManager.class).to(PostgresPerUserMaxQuotaManager.class); bind(QuotaManager.class).to(StoreQuotaManager.class); bind(CurrentQuotaManager.class).to(PostgresCurrentQuotaManager.class); From 5d6e0f4d7f58ed92aca0f11c6db10cc273fa607e Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Tue, 5 Dec 2023 10:15:34 +0700 Subject: [PATCH 101/341] JAMES-2586 [PGSQL] Initialization to configure users repository --- .../modules/data/PostgresUsersRepositoryModule.java | 10 ++++++++++ .../org/apache/james/user/lib/UsersRepositoryImpl.java | 2 ++ 2 files changed, 12 insertions(+) diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresUsersRepositoryModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresUsersRepositoryModule.java index 99289c5ce41..575f7621f0d 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresUsersRepositoryModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresUsersRepositoryModule.java @@ -28,12 +28,15 @@ import org.apache.james.user.postgres.PostgresUsersDAO; import org.apache.james.user.postgres.PostgresUsersRepository; import org.apache.james.user.postgres.PostgresUsersRepositoryConfiguration; +import org.apache.james.utils.InitializationOperation; +import org.apache.james.utils.InitilizationOperationBuilder; import com.google.inject.AbstractModule; import com.google.inject.Provides; import com.google.inject.Scopes; import com.google.inject.Singleton; import com.google.inject.multibindings.Multibinder; +import com.google.inject.multibindings.ProvidesIntoSet; public class PostgresUsersRepositoryModule extends AbstractModule { @Override @@ -54,4 +57,11 @@ public PostgresUsersRepositoryConfiguration provideConfiguration(ConfigurationPr return PostgresUsersRepositoryConfiguration.from( configurationProvider.getConfiguration("usersrepository")); } + + @ProvidesIntoSet + InitializationOperation configureInitialization(ConfigurationProvider configurationProvider, PostgresUsersRepository usersRepository) { + return InitilizationOperationBuilder + .forClass(PostgresUsersRepository.class) + .init(() -> usersRepository.configure(configurationProvider.getConfiguration("usersrepository"))); + } } diff --git a/server/data/data-library/src/main/java/org/apache/james/user/lib/UsersRepositoryImpl.java b/server/data/data-library/src/main/java/org/apache/james/user/lib/UsersRepositoryImpl.java index e22f459de08..ccfff59dd62 100644 --- a/server/data/data-library/src/main/java/org/apache/james/user/lib/UsersRepositoryImpl.java +++ b/server/data/data-library/src/main/java/org/apache/james/user/lib/UsersRepositoryImpl.java @@ -81,6 +81,8 @@ public void configure(HierarchicalConfiguration configuration) th verifyFailureDelay = Optional.ofNullable(configuration.getString("verifyFailureDelay")) .map(string -> DurationParser.parse(string, ChronoUnit.SECONDS).toMillis()) .orElse(0L); + LOGGER.debug("Init configure users repository with virtualHosting {}, administratorId {}, verifyFailureDelay {}", + virtualHosting, administratorId, verifyFailureDelay); } public void setEnableVirtualHosting(boolean virtualHosting) { From 83967a637db48b93f530d5b9a39dda0df0f942c3 Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Tue, 5 Dec 2023 15:09:42 +0100 Subject: [PATCH 102/341] JAMES-2586 Enable ACL support for PG Runs 41 tests more onto the PostgresMailboxManager --- .../james/mailbox/postgres/mail/PostgresMailboxManager.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxManager.java index e0197d67774..f3bc9304f37 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxManager.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxManager.java @@ -48,7 +48,8 @@ public class PostgresMailboxManager extends StoreMailboxManager { MailboxCapabilities.UserFlag, MailboxCapabilities.Namespace, MailboxCapabilities.Move, - MailboxCapabilities.Annotation); + MailboxCapabilities.Annotation, + MailboxCapabilities.ACL); @Inject public PostgresMailboxManager(PostgresMailboxSessionMapperFactory mapperFactory, From de9c9adac0aedd8790b826ace8af98bf9a02f58f Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Tue, 5 Dec 2023 15:10:23 +0100 Subject: [PATCH 103/341] JAMES-2586 Remove unused class MessageUtils.java --- .../mailbox/postgres/mail/MessageUtils.java | 113 ------------------ .../postgres/mail/MessageUtilsTest.java | 106 ---------------- 2 files changed, 219 deletions(-) delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/MessageUtils.java delete mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/MessageUtilsTest.java diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/MessageUtils.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/MessageUtils.java deleted file mode 100644 index ca717a26782..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/MessageUtils.java +++ /dev/null @@ -1,113 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.mail; - -import java.util.Iterator; -import java.util.List; - -import javax.mail.Flags; - -import org.apache.james.mailbox.MessageUid; -import org.apache.james.mailbox.ModSeq; -import org.apache.james.mailbox.exception.MailboxException; -import org.apache.james.mailbox.model.Mailbox; -import org.apache.james.mailbox.model.UpdatedFlags; -import org.apache.james.mailbox.store.FlagsUpdateCalculator; -import org.apache.james.mailbox.store.mail.ModSeqProvider; -import org.apache.james.mailbox.store.mail.UidProvider; -import org.apache.james.mailbox.store.mail.model.MailboxMessage; - -import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; - -class MessageUtils { - private final UidProvider uidProvider; - private final ModSeqProvider modSeqProvider; - - MessageUtils(UidProvider uidProvider, ModSeqProvider modSeqProvider) { - Preconditions.checkNotNull(uidProvider); - Preconditions.checkNotNull(modSeqProvider); - - this.uidProvider = uidProvider; - this.modSeqProvider = modSeqProvider; - } - - void enrichMessage(Mailbox mailbox, MailboxMessage message) throws MailboxException { - message.setUid(nextUid(mailbox)); - message.setModSeq(nextModSeq(mailbox)); - } - - MessageChangedFlags updateFlags(Mailbox mailbox, FlagsUpdateCalculator flagsUpdateCalculator, - Iterator messages) throws MailboxException { - ImmutableList.Builder updatedFlags = ImmutableList.builder(); - ImmutableList.Builder changedFlags = ImmutableList.builder(); - - ModSeq modSeq = nextModSeq(mailbox); - - while (messages.hasNext()) { - MailboxMessage member = messages.next(); - Flags originalFlags = member.createFlags(); - member.setFlags(flagsUpdateCalculator.buildNewFlags(originalFlags)); - Flags newFlags = member.createFlags(); - if (UpdatedFlags.flagsChanged(originalFlags, newFlags)) { - member.setModSeq(modSeq); - changedFlags.add(member); - } - - updatedFlags.add(UpdatedFlags.builder() - .uid(member.getUid()) - .modSeq(member.getModSeq()) - .newFlags(newFlags) - .oldFlags(originalFlags) - .build()); - } - - return new MessageChangedFlags(updatedFlags.build().iterator(), changedFlags.build()); - } - - @VisibleForTesting - MessageUid nextUid(Mailbox mailbox) throws MailboxException { - return uidProvider.nextUid(mailbox); - } - - @VisibleForTesting - ModSeq nextModSeq(Mailbox mailbox) throws MailboxException { - return modSeqProvider.nextModSeq(mailbox); - } - - static class MessageChangedFlags { - private final Iterator updatedFlags; - private final List changedFlags; - - public MessageChangedFlags(Iterator updatedFlags, List changedFlags) { - this.updatedFlags = updatedFlags; - this.changedFlags = changedFlags; - } - - public Iterator getUpdatedFlags() { - return updatedFlags; - } - - public List getChangedFlags() { - return changedFlags; - } - } -} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/MessageUtilsTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/MessageUtilsTest.java deleted file mode 100644 index fac4513ed43..00000000000 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/MessageUtilsTest.java +++ /dev/null @@ -1,106 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.mail; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.util.Date; - -import javax.mail.Flags; - -import org.apache.james.mailbox.MessageUid; -import org.apache.james.mailbox.ModSeq; -import org.apache.james.mailbox.model.ByteContent; -import org.apache.james.mailbox.model.Mailbox; -import org.apache.james.mailbox.model.MessageId; -import org.apache.james.mailbox.model.ThreadId; -import org.apache.james.mailbox.postgres.mail.MessageUtils; -import org.apache.james.mailbox.store.mail.ModSeqProvider; -import org.apache.james.mailbox.store.mail.UidProvider; -import org.apache.james.mailbox.store.mail.model.DefaultMessageId; -import org.apache.james.mailbox.store.mail.model.MailboxMessage; -import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; -import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; - -class MessageUtilsTest { - static final MessageUid MESSAGE_UID = MessageUid.of(1); - static final MessageId MESSAGE_ID = new DefaultMessageId(); - static final ThreadId THREAD_ID = ThreadId.fromBaseMessageId(MESSAGE_ID); - static final int BODY_START = 16; - static final String CONTENT = "anycontent"; - - @Mock ModSeqProvider modSeqProvider; - @Mock UidProvider uidProvider; - @Mock Mailbox mailbox; - - MessageUtils messageUtils; - MailboxMessage message; - - @BeforeEach - void setUp() { - MockitoAnnotations.initMocks(this); - messageUtils = new MessageUtils(uidProvider, modSeqProvider); - message = new SimpleMailboxMessage(MESSAGE_ID, THREAD_ID, new Date(), CONTENT.length(), BODY_START, - new ByteContent(CONTENT.getBytes()), new Flags(), new PropertyBuilder().build(), mailbox.getMailboxId()); - } - - @Test - void newInstanceShouldFailWhenNullUidProvider() { - assertThatThrownBy(() -> new MessageUtils(null, modSeqProvider)) - .isInstanceOf(NullPointerException.class); - } - - @Test - void newInstanceShouldFailWhenNullModSeqProvider() { - assertThatThrownBy(() -> new MessageUtils(uidProvider, null)) - .isInstanceOf(NullPointerException.class); - } - - @Test - void nextModSeqShouldCallModSeqProvider() throws Exception { - messageUtils.nextModSeq(mailbox); - verify(modSeqProvider).nextModSeq(eq(mailbox)); - } - - @Test - void nextUidShouldCallUidProvider() throws Exception { - messageUtils.nextUid(mailbox); - verify(uidProvider).nextUid(eq(mailbox)); - } - - @Test - void enrichMesageShouldEnrichUidAndModSeq() throws Exception { - when(uidProvider.nextUid(eq(mailbox))).thenReturn(MESSAGE_UID); - when(modSeqProvider.nextModSeq(eq(mailbox))).thenReturn(ModSeq.of(11)); - - messageUtils.enrichMessage(mailbox, message); - - assertThat(message.getUid()).isEqualTo(MESSAGE_UID); - assertThat(message.getModSeq()).isEqualTo(ModSeq.of(11)); - } -} From 52b322a5373c243152674a32cff6cf6d5c57f115 Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Tue, 5 Dec 2023 16:01:48 +0100 Subject: [PATCH 104/341] JAMES-2586 Remove unused method in MessageManager --- .../main/java/org/apache/james/mailbox/MessageManager.java | 2 -- .../org/apache/james/mailbox/store/StoreMessageManager.java | 5 ----- 2 files changed, 7 deletions(-) diff --git a/mailbox/api/src/main/java/org/apache/james/mailbox/MessageManager.java b/mailbox/api/src/main/java/org/apache/james/mailbox/MessageManager.java index bab6c535309..c87729aed68 100644 --- a/mailbox/api/src/main/java/org/apache/james/mailbox/MessageManager.java +++ b/mailbox/api/src/main/java/org/apache/james/mailbox/MessageManager.java @@ -38,7 +38,6 @@ import jakarta.mail.internet.SharedInputStream; import org.apache.commons.io.IOUtils; -import org.apache.james.mailbox.MailboxManager.MessageCapabilities; import org.apache.james.mailbox.MessageManager.MailboxMetaData.RecentMode; import org.apache.james.mailbox.exception.MailboxException; import org.apache.james.mailbox.exception.UnsupportedCriteriaException; @@ -441,7 +440,6 @@ default Publisher getMessagesReactive(MessageRange set, FetchGrou */ Mailbox getMailboxEntity() throws MailboxException; - EnumSet getSupportedMessageCapabilities(); /** * Gets the id of the referenced mailbox diff --git a/mailbox/store/src/main/java/org/apache/james/mailbox/store/StoreMessageManager.java b/mailbox/store/src/main/java/org/apache/james/mailbox/store/StoreMessageManager.java index 79f3b522453..c30dc4449f4 100644 --- a/mailbox/store/src/main/java/org/apache/james/mailbox/store/StoreMessageManager.java +++ b/mailbox/store/src/main/java/org/apache/james/mailbox/store/StoreMessageManager.java @@ -1012,9 +1012,4 @@ private Flux listAllMessageUids(MailboxSession session) throws Mailb return messageMapper.execute( () -> messageMapper.listAllMessageUids(mailbox)); } - - @Override - public EnumSet getSupportedMessageCapabilities() { - return messageCapabilities; - } } From 1c6a3a1a3e27efa36fc1472b54c577b327152dd0 Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Tue, 5 Dec 2023 16:03:17 +0100 Subject: [PATCH 105/341] JAMES-2586 Enable UniqueID support for PostgresMailboxManager We generate a unique UUID so this is supported. Note that turns 5 tests on in the PostgresMailboxManager test suite. --- .../james/mailbox/postgres/mail/PostgresMailboxManager.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxManager.java index f3bc9304f37..070c12333ae 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxManager.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxManager.java @@ -90,4 +90,8 @@ public EnumSet getSupportedMailboxCapabilities() { return MAILBOX_CAPABILITIES; } + @Override + public EnumSet getSupportedMessageCapabilities() { + return EnumSet.of(MessageCapabilities.UniqueID); + } } From 3014ce5c5dfd5ff5468e096f404a6491e6d02a22 Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Tue, 5 Dec 2023 16:08:15 +0100 Subject: [PATCH 106/341] JAMES-2586 Enable PostgresMailboxManager annotation tests --- .../java/org/apache/james/mailbox/MailboxManagerTest.java | 4 +++- .../james/mailbox/postgres/PostgresMailboxManagerTest.java | 6 ------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/mailbox/api/src/test/java/org/apache/james/mailbox/MailboxManagerTest.java b/mailbox/api/src/test/java/org/apache/james/mailbox/MailboxManagerTest.java index 881fcaaf972..9ec14ef15c2 100644 --- a/mailbox/api/src/test/java/org/apache/james/mailbox/MailboxManagerTest.java +++ b/mailbox/api/src/test/java/org/apache/james/mailbox/MailboxManagerTest.java @@ -651,7 +651,9 @@ void getAllAnnotationsShouldRetrieveStoredAnnotations() throws Exception { mailboxManager.updateAnnotations(inbox, session, annotations); - assertThat(mailboxManager.getAllAnnotations(inbox, session)).isEqualTo(annotations); + assertThat(mailboxManager.getAllAnnotations(inbox, session)) + .hasSize(annotations.size()) + .containsAnyElementsOf(annotations); } @Test diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerTest.java index d7fc4f355e1..537a124c969 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerTest.java @@ -38,11 +38,6 @@ class PostgresMailboxManagerTest extends MailboxManagerTest Date: Wed, 6 Dec 2023 07:25:45 +0100 Subject: [PATCH 107/341] JAMES-2586 Fully drop JPA within mailbox-postgresql --- mailbox/postgres/pom.xml | 31 ----------- .../main/resources/james-database.properties | 51 ------------------- ...gresRecomputeCurrentQuotasServiceTest.java | 2 - 3 files changed, 84 deletions(-) delete mode 100644 mailbox/postgres/src/main/resources/james-database.properties diff --git a/mailbox/postgres/pom.xml b/mailbox/postgres/pom.xml index e2f2d9bcd77..e3f348f5856 100644 --- a/mailbox/postgres/pom.xml +++ b/mailbox/postgres/pom.xml @@ -32,16 +32,6 @@ - - ${james.groupId} - apache-james-backends-jpa - - - ${james.groupId} - apache-james-backends-jpa - test-jar - test - ${james.groupId} apache-james-backends-postgres @@ -118,11 +108,6 @@ event-bus-in-vm test - - ${james.groupId} - james-server-data-jpa - test - ${james.groupId} james-server-data-postgres @@ -181,20 +166,4 @@ test - - - - - org.apache.maven.plugins - maven-surefire-plugin - - false - 1 - -Djava.library.path= - -javaagent:"${settings.localRepository}"/org/jacoco/org.jacoco.agent/${jacoco-maven-plugin.version}/org.jacoco.agent-${jacoco-maven-plugin.version}-runtime.jar=destfile=${basedir}/target/jacoco.exec - -Xms512m -Xmx1024m -Dopenjpa.Multithreaded=true - - - - diff --git a/mailbox/postgres/src/main/resources/james-database.properties b/mailbox/postgres/src/main/resources/james-database.properties deleted file mode 100644 index 852f8f29890..00000000000 --- a/mailbox/postgres/src/main/resources/james-database.properties +++ /dev/null @@ -1,51 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -# This template file can be used as example for James Server configuration -# DO NOT USE IT AS SUCH AND ADAPT IT TO YOUR NEEDS - -# See http://james.apache.org/server/3/config.html for usage - -# Use derby as default -database.driverClassName=org.apache.derby.jdbc.EmbeddedDriver -database.url=jdbc:derby:../var/store/derby;create=true -database.username=app -database.password=app - -# Supported adapters are: -# DB2, DERBY, H2, HSQL, INFORMIX, MYSQL, ORACLE, POSTGRESQL, SQL_SERVER, SYBASE -vendorAdapter.database=DERBY - -# Use streaming for Blobs -# This is only supported on a limited set of databases atm. You should check if its supported by your DB before enable -# it. -# -# See: -# http://openjpa.apache.org/builds/latest/docs/manual/ref_guide_mapping_jpa.html #7.11. LOB Streaming -# -openjpa.streaming=false - -# Validate the data source before using it -# datasource.testOnBorrow=true -# datasource.validationQueryTimeoutSec=2 -# This is different per database. See https://stackoverflow.com/questions/10684244/dbcp-validationquery-for-different-databases#10684260 -# datasource.validationQuery=select 1 - -# Attachment storage -# *WARNING*: Is not made to store large binary content (no more than 1 GB of data) -# Optional, Allowed values are: true, false, defaults to false -# attachmentStorage.enabled=false \ No newline at end of file diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/PostgresRecomputeCurrentQuotasServiceTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/PostgresRecomputeCurrentQuotasServiceTest.java index 3e95b0eee68..0d2ba967de2 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/PostgresRecomputeCurrentQuotasServiceTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/PostgresRecomputeCurrentQuotasServiceTest.java @@ -20,7 +20,6 @@ package org.apache.james.mailbox.postgres.mail.task; import org.apache.commons.configuration2.BaseHierarchicalConfiguration; -import org.apache.james.backends.jpa.JpaTestCluster; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.backends.postgres.quota.PostgresQuotaCurrentValueDAO; @@ -48,7 +47,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.RegisterExtension; -import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; class PostgresRecomputeCurrentQuotasServiceTest implements RecomputeCurrentQuotasServiceContract { From 63ae3d6caf8a849ca850e0f5b51e3475b62a7cbf Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 4 Dec 2023 12:05:42 +0700 Subject: [PATCH 108/341] JAMES-2586 [PGSQL] Implement correctly FetchType --- .../backends/postgres/PostgresCommons.java | 7 +- .../postgres/mail/PostgresMessageMapper.java | 44 +++--- .../mail/dao/PostgresMailboxMessageDAO.java | 50 +++--- .../dao/PostgresMailboxMessageDAOUtils.java | 37 ++--- .../PostgresMailboxMessageFetchStrategy.java | 147 ++++++++++++++++++ 5 files changed, 217 insertions(+), 68 deletions(-) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageFetchStrategy.java diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresCommons.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresCommons.java index ae4b8ebf5e9..5ffb1905258 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresCommons.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresCommons.java @@ -49,11 +49,10 @@ public interface DataTypes { DataType STRING_ARRAY = SQLDataType.CLOB.getArrayDataType(); } - public interface SimpleTableField { - Field of(Table table, Field field); - } - public static final SimpleTableField TABLE_FIELD = (table, field) -> DSL.field(table.getName() + "." + field.getName()); + public static Field tableField(Table table, Field field) { + return DSL.field(table.getName() + "." + field.getName(), field.getDataType()); + } public static final Function DATE_TO_LOCAL_DATE_TIME = date -> Optional.ofNullable(date) .map(value -> LocalDateTime.ofInstant(value.toInstant(), ZoneOffset.UTC)) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java index 1a6787d5c4b..101118676aa 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java @@ -128,39 +128,41 @@ public Flux listMessagesMetadata(Mailbox mailbox, @Override public Flux findInMailboxReactive(Mailbox mailbox, MessageRange messageRange, FetchType fetchType, int limitAsInt) { + Flux> fetchMessageWithoutFullContentPublisher = fetchMessageWithoutFullContent(mailbox, messageRange, fetchType, limitAsInt); + if (fetchType == FetchType.FULL) { + return fetchMessageWithoutFullContentPublisher + .flatMap(messageBuilderAndBlobId -> { + SimpleMailboxMessage.Builder messageBuilder = messageBuilderAndBlobId.getLeft(); + String blobIdAsString = messageBuilderAndBlobId.getRight(); + return retrieveFullContent(blobIdAsString) + .map(content -> messageBuilder.content(content).build()); + }) + .sort(Comparator.comparing(MailboxMessage::getUid)) + .map(message -> message); + } else { + return fetchMessageWithoutFullContentPublisher + .map(messageBuilderAndBlobId -> messageBuilderAndBlobId.getLeft().build()); + } + } + + private Flux> fetchMessageWithoutFullContent(Mailbox mailbox, MessageRange messageRange, FetchType fetchType, int limitAsInt) { return Mono.just(messageRange) .flatMapMany(range -> { Limit limit = Limit.from(limitAsInt); switch (messageRange.getType()) { case ALL: - return mailboxMessageDAO.findMessagesByMailboxId((PostgresMailboxId) mailbox.getMailboxId(), limit); + return mailboxMessageDAO.findMessagesByMailboxId((PostgresMailboxId) mailbox.getMailboxId(), limit, fetchType); case FROM: - return mailboxMessageDAO.findMessagesByMailboxIdAndAfterUID((PostgresMailboxId) mailbox.getMailboxId(), range.getUidFrom(), limit); + return mailboxMessageDAO.findMessagesByMailboxIdAndAfterUID((PostgresMailboxId) mailbox.getMailboxId(), range.getUidFrom(), limit, fetchType); case ONE: - return mailboxMessageDAO.findMessageByMailboxIdAndUid((PostgresMailboxId) mailbox.getMailboxId(), range.getUidFrom()) + return mailboxMessageDAO.findMessageByMailboxIdAndUid((PostgresMailboxId) mailbox.getMailboxId(), range.getUidFrom(), fetchType) .flatMapMany(Flux::just); case RANGE: - return mailboxMessageDAO.findMessagesByMailboxIdAndBetweenUIDs((PostgresMailboxId) mailbox.getMailboxId(), range.getUidFrom(), range.getUidTo(), limit); + return mailboxMessageDAO.findMessagesByMailboxIdAndBetweenUIDs((PostgresMailboxId) mailbox.getMailboxId(), range.getUidFrom(), range.getUidTo(), limit, fetchType); default: throw new RuntimeException("Unknown MessageRange range " + range.getType()); } - }).flatMap(messageBuilderAndBlobId -> { - SimpleMailboxMessage.Builder messageBuilder = messageBuilderAndBlobId.getLeft(); - String blobIdAsString = messageBuilderAndBlobId.getRight(); - switch (fetchType) { - case METADATA: - case ATTACHMENTS_METADATA: - case HEADERS: - return Mono.just(messageBuilder.build()); - case FULL: - return retrieveFullContent(blobIdAsString) - .map(content -> messageBuilder.content(content).build()); - default: - return Flux.error(new RuntimeException("Unknown FetchType " + fetchType)); - } - }) - .sort(Comparator.comparing(MailboxMessage::getUid)) - .map(message -> message); + }); } private Mono retrieveFullContent(String blobIdString) { diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java index cbcba2943e4..48f93a912e9 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java @@ -22,8 +22,8 @@ import static org.apache.james.backends.postgres.PostgresCommons.DATE_TO_LOCAL_DATE_TIME; import static org.apache.james.backends.postgres.PostgresCommons.IN_CLAUSE_MAX_SIZE; -import static org.apache.james.backends.postgres.PostgresCommons.TABLE_FIELD; import static org.apache.james.backends.postgres.PostgresCommons.UNNEST_FIELD; +import static org.apache.james.backends.postgres.PostgresCommons.tableField; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.BLOB_ID; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.INTERNAL_DATE; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.SIZE; @@ -42,9 +42,9 @@ import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.THREAD_ID; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.USER_FLAGS; import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.BOOLEAN_FLAGS_MAPPING; +import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.FETCH_TYPE_TO_FETCH_STRATEGY; import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.MESSAGE_METADATA_FIELDS_REQUIRE; import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.RECORD_TO_COMPOSED_MESSAGE_ID_WITH_META_DATA_FUNCTION; -import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.RECORD_TO_MAILBOX_MESSAGE_BUILDER_FUNCTION; import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.RECORD_TO_MESSAGE_METADATA_FUNCTION; import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.RECORD_TO_MESSAGE_UID_FUNCTION; @@ -66,6 +66,8 @@ import org.apache.james.mailbox.postgres.PostgresMailboxId; import org.apache.james.mailbox.postgres.PostgresMessageId; import org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable; +import org.apache.james.mailbox.store.mail.MessageMapper; +import org.apache.james.mailbox.store.mail.MessageMapper.FetchType; import org.apache.james.mailbox.store.mail.model.MailboxMessage; import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; import org.apache.james.util.streams.Limit; @@ -89,7 +91,7 @@ public class PostgresMailboxMessageDAO { private static final TableOnConditionStep MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP = TABLE_NAME.join(MessageTable.TABLE_NAME) - .on(TABLE_FIELD.of(TABLE_NAME, MESSAGE_ID).eq(TABLE_FIELD.of(MessageTable.TABLE_NAME, MessageTable.MESSAGE_ID))); + .on(tableField(TABLE_NAME, MESSAGE_ID).eq(tableField(MessageTable.TABLE_NAME, MessageTable.MESSAGE_ID))); public static final SortField DEFAULT_SORT_ORDER_BY = MESSAGE_UID.asc(); @@ -163,8 +165,9 @@ public Mono countTotalMessagesByMailboxId(PostgresMailboxId mailboxId) .where(MAILBOX_ID.eq(mailboxId.asUuid())))); } - public Flux> findMessagesByMailboxId(PostgresMailboxId mailboxId, Limit limit) { - Function> queryWithoutLimit = dslContext -> dslContext.select() + public Flux> findMessagesByMailboxId(PostgresMailboxId mailboxId, Limit limit, MessageMapper.FetchType fetchType) { + PostgresMailboxMessageFetchStrategy fetchStrategy = FETCH_TYPE_TO_FETCH_STRATEGY.apply(fetchType); + Function> queryWithoutLimit = dslContext -> dslContext.select(fetchStrategy.fetchFields()) .from(MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP) .where(MAILBOX_ID.eq(mailboxId.asUuid())) .orderBy(DEFAULT_SORT_ORDER_BY); @@ -172,11 +175,12 @@ public Flux> findMessagesByMailboxId( return postgresExecutor.executeRows(dslContext -> limit.getLimit() .map(limitValue -> Flux.from(queryWithoutLimit.andThen(step -> step.limit(limitValue)).apply(dslContext))) .orElse(Flux.from(queryWithoutLimit.apply(dslContext)))) - .map(record -> Pair.of(RECORD_TO_MAILBOX_MESSAGE_BUILDER_FUNCTION.apply(record), record.get(BLOB_ID))); + .map(record -> Pair.of(fetchStrategy.toMessageBuilder().apply(record), record.get(BLOB_ID))); } - public Flux> findMessagesByMailboxIdAndBetweenUIDs(PostgresMailboxId mailboxId, MessageUid from, MessageUid to, Limit limit) { - Function> queryWithoutLimit = dslContext -> dslContext.select() + public Flux> findMessagesByMailboxIdAndBetweenUIDs(PostgresMailboxId mailboxId, MessageUid from, MessageUid to, Limit limit, FetchType fetchType) { + PostgresMailboxMessageFetchStrategy fetchStrategy = FETCH_TYPE_TO_FETCH_STRATEGY.apply(fetchType); + Function> queryWithoutLimit = dslContext -> dslContext.select(fetchStrategy.fetchFields()) .from(MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP) .where(MAILBOX_ID.eq(mailboxId.asUuid())) .and(MESSAGE_UID.greaterOrEqual(from.asLong())) @@ -186,19 +190,21 @@ public Flux> findMessagesByMailboxIdA return postgresExecutor.executeRows(dslContext -> limit.getLimit() .map(limitValue -> Flux.from(queryWithoutLimit.andThen(step -> step.limit(limitValue)).apply(dslContext))) .orElse(Flux.from(queryWithoutLimit.apply(dslContext)))) - .map(record -> Pair.of(RECORD_TO_MAILBOX_MESSAGE_BUILDER_FUNCTION.apply(record), record.get(BLOB_ID))); + .map(record -> Pair.of(fetchStrategy.toMessageBuilder().apply(record), record.get(BLOB_ID))); } - public Mono> findMessageByMailboxIdAndUid(PostgresMailboxId mailboxId, MessageUid uid) { - return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select() + public Mono> findMessageByMailboxIdAndUid(PostgresMailboxId mailboxId, MessageUid uid, FetchType fetchType) { + PostgresMailboxMessageFetchStrategy fetchStrategy = FETCH_TYPE_TO_FETCH_STRATEGY.apply(fetchType); + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select(fetchStrategy.fetchFields()) .from(MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP) .where(MAILBOX_ID.eq(mailboxId.asUuid())) .and(MESSAGE_UID.eq(uid.asLong())))) - .map(record -> Pair.of(RECORD_TO_MAILBOX_MESSAGE_BUILDER_FUNCTION.apply(record), record.get(BLOB_ID))); + .map(record -> Pair.of(fetchStrategy.toMessageBuilder().apply(record), record.get(BLOB_ID))); } - public Flux> findMessagesByMailboxIdAndAfterUID(PostgresMailboxId mailboxId, MessageUid from, Limit limit) { - Function> queryWithoutLimit = dslContext -> dslContext.select() + public Flux> findMessagesByMailboxIdAndAfterUID(PostgresMailboxId mailboxId, MessageUid from, Limit limit, FetchType fetchType) { + PostgresMailboxMessageFetchStrategy fetchStrategy = FETCH_TYPE_TO_FETCH_STRATEGY.apply(fetchType); + Function> queryWithoutLimit = dslContext -> dslContext.select(fetchStrategy.fetchFields()) .from(MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP) .where(MAILBOX_ID.eq(mailboxId.asUuid())) .and(MESSAGE_UID.greaterOrEqual(from.asLong())) @@ -207,16 +213,18 @@ public Flux> findMessagesByMailboxIdA return postgresExecutor.executeRows(dslContext -> limit.getLimit() .map(limitValue -> Flux.from(queryWithoutLimit.andThen(step -> step.limit(limitValue)).apply(dslContext))) .orElse(Flux.from(queryWithoutLimit.apply(dslContext)))) - .map(record -> Pair.of(RECORD_TO_MAILBOX_MESSAGE_BUILDER_FUNCTION.apply(record), record.get(BLOB_ID))); + .map(record -> Pair.of(fetchStrategy.toMessageBuilder().apply(record), record.get(BLOB_ID))); } public Flux findMessagesByMailboxIdAndUIDs(PostgresMailboxId mailboxId, List uids) { - Function, Flux> queryPublisherFunction = uidsToFetch -> postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select() - .from(MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP) - .where(MAILBOX_ID.eq(mailboxId.asUuid())) - .and(MESSAGE_UID.in(uidsToFetch.stream().map(MessageUid::asLong).toArray(Long[]::new))) - .orderBy(DEFAULT_SORT_ORDER_BY))) - .map(RECORD_TO_MAILBOX_MESSAGE_BUILDER_FUNCTION); + PostgresMailboxMessageFetchStrategy fetchStrategy = PostgresMailboxMessageFetchStrategy.METADATA; + Function, Flux> queryPublisherFunction = + uidsToFetch -> postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(fetchStrategy.fetchFields()) + .from(MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(MESSAGE_UID.in(uidsToFetch.stream().map(MessageUid::asLong).toArray(Long[]::new))) + .orderBy(DEFAULT_SORT_ORDER_BY))) + .map(fetchStrategy.toMessageBuilder()); if (uids.size() <= IN_CLAUSE_MAX_SIZE) { return queryPublisherFunction.apply(uids); diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAOUtils.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAOUtils.java index f69021d3bb0..65404964a4b 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAOUtils.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAOUtils.java @@ -20,7 +20,6 @@ package org.apache.james.mailbox.postgres.mail.dao; import static org.apache.james.backends.postgres.PostgresCommons.LOCAL_DATE_TIME_DATE_FUNCTION; -import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.BODY_START_OCTET; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_DESCRIPTION; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_DISPOSITION_PARAMETERS; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_DISPOSITION_TYPE; @@ -30,7 +29,6 @@ import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_MD5; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_TRANSFER_ENCODING; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_TYPE_PARAMETERS; -import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.HEADER_CONTENT; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.INTERNAL_DATE; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.SIZE; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.IS_ANSWERED; @@ -49,9 +47,7 @@ import java.io.ByteArrayInputStream; import java.io.InputStream; -import java.time.LocalDateTime; import java.util.Arrays; -import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -69,9 +65,9 @@ import org.apache.james.mailbox.postgres.PostgresMailboxId; import org.apache.james.mailbox.postgres.PostgresMessageId; import org.apache.james.mailbox.postgres.mail.PostgresMessageModule; +import org.apache.james.mailbox.store.mail.MessageMapper; import org.apache.james.mailbox.store.mail.model.impl.Properties; import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; -import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; import org.jooq.Field; import org.jooq.Record; @@ -156,8 +152,8 @@ interface PostgresMailboxMessageDAOUtils { property.setContentTransferEncoding(record.get(CONTENT_TRANSFER_ENCODING)); property.setContentLocation(record.get(CONTENT_LOCATION)); property.setContentLanguage(Optional.ofNullable(record.get(CONTENT_LANGUAGE)).map(List::of).orElse(null)); - property.setContentDispositionParameters(record.get(CONTENT_DISPOSITION_PARAMETERS, LinkedHashMap.class)); - property.setContentTypeParameters(record.get(CONTENT_TYPE_PARAMETERS, LinkedHashMap.class)); + property.setContentDispositionParameters(record.get(CONTENT_DISPOSITION_PARAMETERS).data()); + property.setContentTypeParameters(record.get(CONTENT_TYPE_PARAMETERS).data()); return property.build(); }; @@ -173,19 +169,16 @@ public long size() { } }; - Function RECORD_TO_MAILBOX_MESSAGE_BUILDER_FUNCTION = record -> SimpleMailboxMessage.builder() - .messageId(PostgresMessageId.Factory.of(record.get(MESSAGE_ID))) - .mailboxId(PostgresMailboxId.of(record.get(MAILBOX_ID))) - .uid(MessageUid.of(record.get(MESSAGE_UID))) - .modseq(ModSeq.of(record.get(MOD_SEQ))) - .threadId(RECORD_TO_THREAD_ID_FUNCTION.apply(record)) - .internalDate(LOCAL_DATE_TIME_DATE_FUNCTION.apply(record.get(PostgresMessageModule.MessageTable.INTERNAL_DATE, LocalDateTime.class))) - .saveDate(LOCAL_DATE_TIME_DATE_FUNCTION.apply(record.get(SAVE_DATE, LocalDateTime.class))) - .flags(RECORD_TO_FLAGS_FUNCTION.apply(record)) - .size(record.get(PostgresMessageModule.MessageTable.SIZE)) - .bodyStartOctet(record.get(BODY_START_OCTET)) - .content(BYTE_TO_CONTENT_FUNCTION.apply(record.get(HEADER_CONTENT))) - .properties(RECORD_TO_PROPERTIES_FUNCTION.apply(record)); - - + Function FETCH_TYPE_TO_FETCH_STRATEGY = fetchType -> { + switch (fetchType) { + case METADATA: + case ATTACHMENTS_METADATA: + return PostgresMailboxMessageFetchStrategy.METADATA; + case HEADERS: + case FULL: + return PostgresMailboxMessageFetchStrategy.FULL; + default: + throw new RuntimeException("Unknown FetchType " + fetchType); + } + }; } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageFetchStrategy.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageFetchStrategy.java new file mode 100644 index 00000000000..4aef7b69ef9 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageFetchStrategy.java @@ -0,0 +1,147 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail.dao; + +import static org.apache.james.backends.postgres.PostgresCommons.LOCAL_DATE_TIME_DATE_FUNCTION; +import static org.apache.james.backends.postgres.PostgresCommons.tableField; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.BODY_START_OCTET; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.HEADER_CONTENT; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.MAILBOX_ID; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.MESSAGE_UID; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.MOD_SEQ; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.SAVE_DATE; +import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.BYTE_TO_CONTENT_FUNCTION; +import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.RECORD_TO_FLAGS_FUNCTION; +import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.RECORD_TO_PROPERTIES_FUNCTION; +import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.RECORD_TO_THREAD_ID_FUNCTION; + +import java.time.LocalDateTime; +import java.util.function.Function; + +import org.apache.commons.lang3.ArrayUtils; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.model.Content; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.mailbox.postgres.mail.PostgresMessageModule; +import org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable; +import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; +import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; +import org.jooq.Field; +import org.jooq.Record; + +public interface PostgresMailboxMessageFetchStrategy { + PostgresMailboxMessageFetchStrategy METADATA = new MetaData(); + PostgresMailboxMessageFetchStrategy FULL = new Full(); + + Field[] fetchFields(); + + Function toMessageBuilder(); + + static Function toMessageBuilderMetadata() { + return record -> SimpleMailboxMessage.builder() + .messageId(PostgresMessageId.Factory.of(record.get(MessageTable.MESSAGE_ID))) + .mailboxId(PostgresMailboxId.of(record.get(MAILBOX_ID))) + .uid(MessageUid.of(record.get(MESSAGE_UID))) + .modseq(ModSeq.of(record.get(MOD_SEQ))) + .threadId(RECORD_TO_THREAD_ID_FUNCTION.apply(record)) + .internalDate(LOCAL_DATE_TIME_DATE_FUNCTION.apply(record.get(PostgresMessageModule.MessageTable.INTERNAL_DATE, LocalDateTime.class))) + .saveDate(LOCAL_DATE_TIME_DATE_FUNCTION.apply(record.get(SAVE_DATE, LocalDateTime.class))) + .flags(RECORD_TO_FLAGS_FUNCTION.apply(record)) + .size(record.get(PostgresMessageModule.MessageTable.SIZE)) + .bodyStartOctet(record.get(BODY_START_OCTET)); + } + + static Field[] fetchFieldsMetadata() { + return new Field[]{ + tableField(MessageTable.TABLE_NAME, MessageTable.MESSAGE_ID).as(MessageTable.MESSAGE_ID), + tableField(MessageTable.TABLE_NAME, MessageTable.INTERNAL_DATE).as(MessageTable.INTERNAL_DATE), + tableField(MessageTable.TABLE_NAME, MessageTable.SIZE).as(MessageTable.SIZE), + MessageTable.BLOB_ID, + MessageTable.MIME_TYPE, + MessageTable.MIME_SUBTYPE, + MessageTable.BODY_START_OCTET, + MessageTable.TEXTUAL_LINE_COUNT, + MessageToMailboxTable.MAILBOX_ID, + MessageToMailboxTable.MESSAGE_UID, + MessageToMailboxTable.MOD_SEQ, + MessageToMailboxTable.THREAD_ID, + MessageToMailboxTable.IS_DELETED, + MessageToMailboxTable.IS_ANSWERED, + MessageToMailboxTable.IS_DRAFT, + MessageToMailboxTable.IS_FLAGGED, + MessageToMailboxTable.IS_RECENT, + MessageToMailboxTable.IS_SEEN, + MessageToMailboxTable.USER_FLAGS, + MessageToMailboxTable.SAVE_DATE}; + } + + class MetaData implements PostgresMailboxMessageFetchStrategy { + public static final Field[] FETCH_FIELDS = fetchFieldsMetadata(); + public static final Content EMPTY_CONTENT = BYTE_TO_CONTENT_FUNCTION.apply(new byte[0]); + public static final PropertyBuilder EMPTY_PROPERTY_BUILDER = new PropertyBuilder(); + + + @Override + public Field[] fetchFields() { + return FETCH_FIELDS; + } + + @Override + public Function toMessageBuilder() { + return record -> toMessageBuilderMetadata() + .apply(record) + .content(EMPTY_CONTENT) + .properties(EMPTY_PROPERTY_BUILDER); + } + } + + class Full implements PostgresMailboxMessageFetchStrategy { + + public static final Field[] FETCH_FIELDS = ArrayUtils.addAll(fetchFieldsMetadata(), + MessageTable.HEADER_CONTENT, + MessageTable.TEXTUAL_LINE_COUNT, + MessageTable.CONTENT_DESCRIPTION, + MessageTable.CONTENT_LOCATION, + MessageTable.CONTENT_TRANSFER_ENCODING, + MessageTable.CONTENT_DISPOSITION_TYPE, + MessageTable.CONTENT_ID, + MessageTable.CONTENT_MD5, + MessageTable.CONTENT_LANGUAGE, + MessageTable.CONTENT_TYPE_PARAMETERS, + MessageTable.CONTENT_DISPOSITION_PARAMETERS); + + @Override + public Field[] fetchFields() { + return FETCH_FIELDS; + } + + @Override + public Function toMessageBuilder() { + return record -> toMessageBuilderMetadata() + .apply(record) + .content(BYTE_TO_CONTENT_FUNCTION.apply(record.get(HEADER_CONTENT))) + .properties(RECORD_TO_PROPERTIES_FUNCTION.apply(record)); + } + } + +} From 9abbfe923d78983e43255650ecfd17e68eafb105 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 4 Dec 2023 13:49:59 +0700 Subject: [PATCH 109/341] JAMES-2586 [PGSQL] Optimize getMailboxCounter method - Use single query to database for fetch total + total unseen The query looks like: select count(*) as "total_count", count(*) filter (where is_seen = $1) as "unseen_count" from message_mailbox where mailbox_id = cast($2 as uuid) --- .../postgres/mail/PostgresMessageMapper.java | 13 ++++++------- .../mail/dao/PostgresMailboxMessageDAO.java | 19 ++++++++++++------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java index 101118676aa..6a10891a127 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java @@ -220,13 +220,12 @@ public MailboxCounters getMailboxCounters(Mailbox mailbox) { @Override public Mono getMailboxCountersReactive(Mailbox mailbox) { - return mailboxMessageDAO.countTotalMessagesByMailboxId((PostgresMailboxId) mailbox.getMailboxId()) - .flatMap(totalMessage -> mailboxMessageDAO.countUnseenMessagesByMailboxId((PostgresMailboxId) mailbox.getMailboxId()) - .map(unseenMessage -> MailboxCounters.builder() - .mailboxId(mailbox.getMailboxId()) - .count(totalMessage) - .unseen(unseenMessage) - .build())); + return mailboxMessageDAO.countTotalAndUnseenMessagesByMailboxId((PostgresMailboxId) mailbox.getMailboxId()) + .map(pair -> MailboxCounters.builder() + .mailboxId(mailbox.getMailboxId()) + .count(pair.getLeft()) + .unseen(pair.getRight()) + .build()); } @Override diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java index 48f93a912e9..b9aa1fc89f8 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java @@ -73,6 +73,7 @@ import org.apache.james.util.streams.Limit; import org.jooq.Condition; import org.jooq.DSLContext; +import org.jooq.Name; import org.jooq.Record; import org.jooq.Record1; import org.jooq.SelectFinalStep; @@ -152,19 +153,23 @@ public Flux deleteByMailboxIdAndMessageUids(PostgresMailboxId m } } - public Mono countUnseenMessagesByMailboxId(PostgresMailboxId mailboxId) { - return postgresExecutor.executeCount(dslContext -> Mono.from(dslContext.selectCount() - .from(TABLE_NAME) - .where(MAILBOX_ID.eq(mailboxId.asUuid())) - .and(IS_SEEN.eq(false)))); - } - public Mono countTotalMessagesByMailboxId(PostgresMailboxId mailboxId) { return postgresExecutor.executeCount(dslContext -> Mono.from(dslContext.selectCount() .from(TABLE_NAME) .where(MAILBOX_ID.eq(mailboxId.asUuid())))); } + public Mono> countTotalAndUnseenMessagesByMailboxId(PostgresMailboxId mailboxId) { + Name totalCount = DSL.name("total_count"); + Name unSeenCount = DSL.name("unseen_count"); + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select( + DSL.count().as(totalCount), + DSL.count().filterWhere(IS_SEEN.eq(false)).as(unSeenCount)) + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())))) + .map(record -> Pair.of(record.get(totalCount, Integer.class), record.get(unSeenCount, Integer.class))); + } + public Flux> findMessagesByMailboxId(PostgresMailboxId mailboxId, Limit limit, MessageMapper.FetchType fetchType) { PostgresMailboxMessageFetchStrategy fetchStrategy = FETCH_TYPE_TO_FETCH_STRATEGY.apply(fetchType); Function> queryWithoutLimit = dslContext -> dslContext.select(fetchStrategy.fetchFields()) From cfb93ae918bd9998d0156db356d9b2a53527e94f Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 4 Dec 2023 16:06:17 +0700 Subject: [PATCH 110/341] JAMES-2586 [PGSQL] Improve PostresMessageManager::getMetadata method --- .../postgres/mail/PostgresMailbox.java | 54 +++++++++++++++++++ .../postgres/mail/PostgresMailboxMapper.java | 18 +++++-- .../postgres/mail/PostgresMessageManager.java | 49 ++++++++++++----- .../postgres/mail/dao/PostgresMailboxDAO.java | 43 ++++++++------- .../mail/PostgresMailboxMapperTest.java | 42 +++++++++++++++ 5 files changed, 169 insertions(+), 37 deletions(-) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailbox.java diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailbox.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailbox.java new file mode 100644 index 00000000000..0485f5f49b9 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailbox.java @@ -0,0 +1,54 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.model.Mailbox; + +public class PostgresMailbox extends Mailbox { + private final ModSeq highestModSeq; + private final MessageUid lastUid; + + public PostgresMailbox(Mailbox mailbox, ModSeq highestModSeq, MessageUid lastUid) { + super(mailbox); + this.highestModSeq = highestModSeq; + this.lastUid = lastUid; + } + + + public ModSeq getHighestModSeq() { + return highestModSeq; + } + + public MessageUid getLastUid() { + return lastUid; + } + + @Override + public final boolean equals(Object o) { + return super.equals(o); + } + + @Override + public final int hashCode() { + return super.hashCode(); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java index f44a4fb0c54..a1da33a11e9 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java @@ -19,6 +19,8 @@ package org.apache.james.mailbox.postgres.mail; +import java.util.function.Function; + import javax.inject.Inject; import org.apache.james.core.Username; @@ -62,17 +64,20 @@ public Mono delete(Mailbox mailbox) { @Override public Mono findMailboxByPath(MailboxPath mailboxName) { - return postgresMailboxDAO.findMailboxByPath(mailboxName); + return postgresMailboxDAO.findMailboxByPath(mailboxName) + .map(Function.identity()); } @Override public Mono findMailboxById(MailboxId mailboxId) { - return postgresMailboxDAO.findMailboxById(mailboxId); + return postgresMailboxDAO.findMailboxById(mailboxId) + .map(Function.identity()); } @Override public Flux findMailboxWithPathLike(MailboxQuery.UserBound query) { - return postgresMailboxDAO.findMailboxWithPathLike(query); + return postgresMailboxDAO.findMailboxWithPathLike(query) + .map(Function.identity()); } @Override @@ -82,12 +87,14 @@ public Mono hasChildren(Mailbox mailbox, char delimiter) { @Override public Flux list() { - return postgresMailboxDAO.getAll(); + return postgresMailboxDAO.getAll() + .map(Function.identity()); } @Override public Flux findNonPersonalMailboxes(Username userName, MailboxACL.Right right) { - return postgresMailboxDAO.findNonPersonalMailboxes(userName, right); + return postgresMailboxDAO.findNonPersonalMailboxes(userName, right) + .map(Function.identity()); } @Override @@ -110,4 +117,5 @@ public Mono setACL(Mailbox mailbox, MailboxACL mailboxACL) { return ACLDiff.computeDiff(oldACL, updatedACL); }); } + } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageManager.java index 39b584529d0..c10700e36af 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageManager.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageManager.java @@ -21,17 +21,20 @@ import java.time.Clock; import java.util.EnumSet; +import java.util.List; +import java.util.Optional; import javax.mail.Flags; import org.apache.james.events.EventBus; import org.apache.james.mailbox.MailboxPathLocker; import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.exception.MailboxException; import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MailboxACL; +import org.apache.james.mailbox.model.MailboxCounters; import org.apache.james.mailbox.model.MessageId; -import org.apache.james.mailbox.model.UidValidity; import org.apache.james.mailbox.quota.QuotaManager; import org.apache.james.mailbox.quota.QuotaRootResolver; import org.apache.james.mailbox.store.BatchSizes; @@ -46,9 +49,8 @@ import org.apache.james.mailbox.store.mail.ThreadIdGuessingAlgorithm; import org.apache.james.mailbox.store.search.MessageSearchIndex; -import com.github.fge.lambdas.Throwing; - import reactor.core.publisher.Mono; +import reactor.util.function.Tuple2; public class PostgresMessageManager extends StoreMessageManager { @@ -80,21 +82,40 @@ public Flags getPermanentFlags(MailboxSession session) { } public Mono getMetaDataReactive(MailboxMetaData.RecentMode recentMode, MailboxSession mailboxSession, EnumSet items) throws MailboxException { - MailboxACL resolvedAcl = getResolvedAcl(mailboxSession); if (!storeRightManager.hasRight(mailbox, MailboxACL.Right.Read, mailboxSession)) { - return Mono.just(MailboxMetaData.sensibleInformationFree(resolvedAcl, getMailboxEntity().getUidValidity(), isWriteable(mailboxSession))); + return Mono.just(MailboxMetaData.sensibleInformationFree(getResolvedAcl(mailboxSession), getMailboxEntity().getUidValidity(), isWriteable(mailboxSession))); } + Flags permanentFlags = getPermanentFlags(mailboxSession); - UidValidity uidValidity = getMailboxEntity().getUidValidity(); MessageMapper messageMapper = mapperFactory.getMessageMapper(mailboxSession); - return messageMapper.executeReactive( - nextUid(messageMapper, items) - .flatMap(nextUid -> highestModSeq(messageMapper, items) - .flatMap(highestModSeq -> firstUnseen(messageMapper, items) - .flatMap(Throwing.function(firstUnseen -> recent(recentMode, mailboxSession) - .flatMap(recents -> mailboxCounters(messageMapper, items) - .map(counters -> new MailboxMetaData(recents, permanentFlags, uidValidity, nextUid, highestModSeq, counters.getCount(), - counters.getUnseen(), firstUnseen.orElse(null), isWriteable(mailboxSession), resolvedAcl)))))))); + Mono postgresMailboxMetaDataPublisher = Mono.just(mapperFactory.getMailboxMapper(mailboxSession)) + .flatMap(postgresMailboxMapper -> postgresMailboxMapper.findMailboxById(getMailboxEntity().getMailboxId()) + .map(mailbox -> (PostgresMailbox) mailbox)); + + Mono, List>> firstUnseenAndRecentPublisher = Mono.zip(firstUnseen(messageMapper, items), recent(recentMode, mailboxSession)); + + return messageMapper.executeReactive(Mono.zip(postgresMailboxMetaDataPublisher, mailboxCounters(messageMapper, items)) + .flatMap(metadataAndCounter -> { + PostgresMailbox metadata = metadataAndCounter.getT1(); + MailboxCounters counters = metadataAndCounter.getT2(); + return firstUnseenAndRecentPublisher.map(firstUnseenAndRecent -> new MailboxMetaData( + firstUnseenAndRecent.getT2(), + permanentFlags, + metadata.getUidValidity(), + nextUid(metadata), + metadata.getHighestModSeq(), + counters.getCount(), + counters.getUnseen(), + firstUnseenAndRecent.getT1().orElse(null), + isWriteable(mailboxSession), + metadata.getACL())); + })); + } + + private MessageUid nextUid(PostgresMailbox mailboxMetaData) { + return Optional.ofNullable(mailboxMetaData.getLastUid()) + .map(MessageUid::next) + .orElse(MessageUid.MIN_VALUE); } } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java index ac5279062ec..cedcfb12afb 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java @@ -54,6 +54,7 @@ import org.apache.james.mailbox.model.UidValidity; import org.apache.james.mailbox.model.search.MailboxQuery; import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.mail.PostgresMailbox; import org.apache.james.mailbox.store.MailboxExpressionBackwardCompatibility; import org.jooq.Record; import org.jooq.impl.DSL; @@ -85,6 +86,18 @@ public class PostgresMailboxDAO { .map(Optional::get) .collect(ImmutableMap.toImmutableMap(Map.Entry::getKey, Map.Entry::getValue))); + private static final Function RECORD_TO_MAILBOX_FUNCTION = record -> { + Mailbox mailbox = new Mailbox(new MailboxPath(record.get(MAILBOX_NAMESPACE), Username.of(record.get(USER_NAME)), record.get(MAILBOX_NAME)), + UidValidity.of(record.get(MAILBOX_UID_VALIDITY)), PostgresMailboxId.of(record.get(MAILBOX_ID))); + mailbox.setACL(HSTORE_TO_MAILBOX_ACL_FUNCTION.apply(Hstore.hstore(record.get(MAILBOX_ACL, LinkedHashMap.class)))); + return mailbox; + }; + + private static final Function RECORD_TO_POSTGRES_MAILBOX_FUNCTION = record -> new PostgresMailbox(RECORD_TO_MAILBOX_FUNCTION.apply(record), + Optional.ofNullable(record.get(MAILBOX_HIGHEST_MODSEQ)).map(ModSeq::of).orElse(ModSeq.first()), + Optional.ofNullable(record.get(MAILBOX_LAST_UID)).map(MessageUid::of).orElse(null)); + + private static Optional> deserializeMailboxACLEntry(String key, String value) { try { MailboxACL.EntryKey entryKey = MailboxACL.EntryKey.deserialize(key); @@ -140,14 +153,14 @@ public Mono upsertACL(MailboxId mailboxId, MailboxACL acl) { .map(record -> HSTORE_TO_MAILBOX_ACL_FUNCTION.apply(record.get(MAILBOX_ACL))); } - public Flux findNonPersonalMailboxes(Username userName, MailboxACL.Right right) { + public Flux findNonPersonalMailboxes(Username userName, MailboxACL.Right right) { String mailboxACLEntryByUser = String.format("mailbox_acl -> '%s'", userName.asString()); return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.selectFrom(TABLE_NAME) .where(MAILBOX_ACL.isNotNull(), DSL.field(mailboxACLEntryByUser).isNotNull(), DSL.field(mailboxACLEntryByUser).contains(Character.toString(right.asCharacter()))))) - .map(this::asMailbox); + .map(RECORD_TO_POSTGRES_MAILBOX_FUNCTION); } public Mono delete(MailboxId mailboxId) { @@ -155,29 +168,29 @@ public Mono delete(MailboxId mailboxId) { .where(MAILBOX_ID.eq(((PostgresMailboxId) mailboxId).asUuid())))); } - public Mono findMailboxByPath(MailboxPath mailboxPath) { + public Mono findMailboxByPath(MailboxPath mailboxPath) { return postgresExecutor.executeRow(dsl -> Mono.from(dsl.selectFrom(TABLE_NAME) .where(MAILBOX_NAME.eq(mailboxPath.getName()) .and(USER_NAME.eq(mailboxPath.getUser().asString())) .and(MAILBOX_NAMESPACE.eq(mailboxPath.getNamespace()))))) - .map(this::asMailbox); + .map(RECORD_TO_POSTGRES_MAILBOX_FUNCTION); } - public Mono findMailboxById(MailboxId id) { + public Mono findMailboxById(MailboxId id) { return postgresExecutor.executeRow(dsl -> Mono.from(dsl.selectFrom(TABLE_NAME) .where(MAILBOX_ID.eq(((PostgresMailboxId) id).asUuid())))) - .map(this::asMailbox) + .map(RECORD_TO_POSTGRES_MAILBOX_FUNCTION) .switchIfEmpty(Mono.error(new MailboxNotFoundException(id))); } - public Flux findMailboxWithPathLike(MailboxQuery.UserBound query) { + public Flux findMailboxWithPathLike(MailboxQuery.UserBound query) { String pathLike = MailboxExpressionBackwardCompatibility.getPathLike(query); return postgresExecutor.executeRows(dsl -> Flux.from(dsl.selectFrom(TABLE_NAME) .where(MAILBOX_NAME.like(pathLike) .and(USER_NAME.eq(query.getFixedUser().asString())) .and(MAILBOX_NAMESPACE.eq(query.getFixedNamespace()))))) - .map(this::asMailbox) + .map(RECORD_TO_POSTGRES_MAILBOX_FUNCTION) .filter(query::matches) .collectList() .flatMapIterable(Function.identity()); @@ -195,20 +208,13 @@ public Mono hasChildren(Mailbox mailbox, char delimiter) { .hasElements(); } - public Flux getAll() { + public Flux getAll() { return postgresExecutor.executeRows(dsl -> Flux.from(dsl.selectFrom(TABLE_NAME))) - .map(this::asMailbox); + .map(RECORD_TO_POSTGRES_MAILBOX_FUNCTION); } private UUID asUUID(MailboxId mailboxId) { - return ((PostgresMailboxId)mailboxId).asUuid(); - } - - private Mailbox asMailbox(Record record) { - Mailbox mailbox = new Mailbox(new MailboxPath(record.get(MAILBOX_NAMESPACE), Username.of(record.get(USER_NAME)), record.get(MAILBOX_NAME)), - UidValidity.of(record.get(MAILBOX_UID_VALIDITY)), PostgresMailboxId.of(record.get(MAILBOX_ID))); - mailbox.setACL(HSTORE_TO_MAILBOX_ACL_FUNCTION.apply(Hstore.hstore(record.get(MAILBOX_ACL, LinkedHashMap.class)))); - return mailbox; + return ((PostgresMailboxId) mailboxId).asUuid(); } public Mono findLastUidByMailboxId(MailboxId mailboxId) { @@ -255,4 +261,5 @@ public Mono> incrementAndGetLastUidAndModSeq(MailboxId .returning(MAILBOX_LAST_UID, MAILBOX_HIGHEST_MODSEQ))) .map(record -> Pair.of(MessageUid.of(record.get(MAILBOX_LAST_UID)), ModSeq.of(record.get(MAILBOX_HIGHEST_MODSEQ)))); } + } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperTest.java index 3b134b5bb9a..31a2ae282a5 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperTest.java @@ -19,12 +19,22 @@ package org.apache.james.mailbox.postgres.mail; +import static org.assertj.core.api.Assertions.assertThat; + import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.core.Username; +import org.apache.james.mailbox.MailboxSessionUtil; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.UidValidity; import org.apache.james.mailbox.postgres.PostgresMailboxId; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; import org.apache.james.mailbox.store.mail.MailboxMapper; import org.apache.james.mailbox.store.mail.model.MailboxMapperTest; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; public class PostgresMailboxMapperTest extends MailboxMapperTest { @@ -40,4 +50,36 @@ protected MailboxMapper createMailboxMapper() { protected MailboxId generateId() { return PostgresMailboxId.generate(); } + + @Test + void retrieveMailboxShouldReturnCorrectHighestModSeqAndLastUidWhenDefault() { + Mailbox mailbox = mailboxMapper.create(benwaInboxPath, UidValidity.of(43)).block(); + + PostgresMailbox metaData = (PostgresMailbox) mailboxMapper.findMailboxById(mailbox.getMailboxId()).block(); + + assertThat(metaData.getHighestModSeq()).isEqualTo(ModSeq.first()); + assertThat(metaData.getLastUid()).isEqualTo(null); + } + + @Test + void retrieveMailboxShouldReturnCorrectHighestModSeqAndLastUid() { + Username BENWA = Username.of("benwa"); + MailboxPath benwaInboxPath = MailboxPath.forUser(BENWA, "INBOX"); + + Mailbox mailbox = mailboxMapper.create(benwaInboxPath, UidValidity.of(43)).block(); + + // increase modSeq + ModSeq nextModSeq = new PostgresModSeqProvider.Factory(postgresExtension.getExecutorFactory()).create(MailboxSessionUtil.create(BENWA)) + .nextModSeqReactive(mailbox.getMailboxId()).block(); + + // increase lastUid + MessageUid nextUid = new PostgresUidProvider.Factory(postgresExtension.getExecutorFactory()).create(MailboxSessionUtil.create(BENWA)) + .nextUidReactive(mailbox.getMailboxId()).block(); + + PostgresMailbox metaData = (PostgresMailbox) mailboxMapper.findMailboxById(mailbox.getMailboxId()).block(); + + assertThat(metaData.getHighestModSeq()).isEqualTo(nextModSeq); + assertThat(metaData.getLastUid()).isEqualTo(nextUid); + } + } From 7b9f4d36ed998afa1786334067c7821800604b8d Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Tue, 5 Dec 2023 11:06:01 +0700 Subject: [PATCH 111/341] JAMES-2586 Fix missing guice binding for Postgres quota module --- .../mailbox/postgres/quota/PostgresCurrentQuotaManager.java | 1 - .../org/apache/james/modules/mailbox/PostgresQuotaModule.java | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/PostgresCurrentQuotaManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/PostgresCurrentQuotaManager.java index 9e44f7ab92e..e18faa6d8bd 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/PostgresCurrentQuotaManager.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/PostgresCurrentQuotaManager.java @@ -43,7 +43,6 @@ public class PostgresCurrentQuotaManager implements CurrentQuotaManager { private final PostgresQuotaCurrentValueDAO currentValueDao; @Inject - public PostgresCurrentQuotaManager(PostgresQuotaCurrentValueDAO currentValueDao) { this.currentValueDao = currentValueDao; } diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresQuotaModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresQuotaModule.java index 19894e74afc..8e7ea84e288 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresQuotaModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresQuotaModule.java @@ -20,6 +20,7 @@ package org.apache.james.modules.mailbox; import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.quota.PostgresQuotaCurrentValueDAO; import org.apache.james.events.EventListener; import org.apache.james.mailbox.postgres.quota.PostgresCurrentQuotaManager; import org.apache.james.mailbox.postgres.quota.PostgresPerUserMaxQuotaManager; @@ -48,6 +49,7 @@ protected void configure() { bind(DefaultUserQuotaRootResolver.class).in(Scopes.SINGLETON); bind(PostgresPerUserMaxQuotaManager.class).in(Scopes.SINGLETON); bind(StoreQuotaManager.class).in(Scopes.SINGLETON); + bind(PostgresQuotaCurrentValueDAO.class).in(Scopes.SINGLETON); bind(PostgresCurrentQuotaManager.class).in(Scopes.SINGLETON); bind(UserQuotaRootResolver.class).to(DefaultUserQuotaRootResolver.class); From 65e2f187c282190b9e7d84c55e3a3e1d5429237b Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Tue, 5 Dec 2023 11:07:54 +0700 Subject: [PATCH 112/341] JAMES-2586 Fixup - Postgres app - Use junit 5 (replace to junit 4) - It is a why make lack of test case when run the test (jpaGuiceServerShouldUpdateQuota) --- .../src/test/java/org/apache/james/PostgresJamesServerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java index 2e03f181cde..7f82a1963f1 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java @@ -34,8 +34,8 @@ import org.apache.james.utils.TestIMAPClient; import org.awaitility.Awaitility; import org.awaitility.core.ConditionFactory; -import org.junit.Test; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import com.google.common.base.Strings; From 404077b385d833e4fefb3c2ec3238cd19710de82 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Tue, 5 Dec 2023 11:12:16 +0700 Subject: [PATCH 113/341] =?UTF-8?q?JAMES-2586=20Postgres=20app=20=E2=80=93?= =?UTF-8?q?=20Remove=20server=20test=20for=20authentication=20database=20s?= =?UTF-8?q?ql=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - It was created for JPA, with Postgresql we don't need it. (instead of mark @Disabled for it) --- .../apache/james/PostgresJamesServerTest.java | 2 +- ...uthenticatedDatabaseSqlValidationTest.java | 42 ----------- ...seAuthenticaticationSqlValidationTest.java | 74 ------------------- ...tgresJamesServerWithSqlValidationTest.java | 30 -------- .../src/test/resources/usersrepository.xml | 28 +++++++ 5 files changed, 29 insertions(+), 147 deletions(-) delete mode 100644 server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest.java delete mode 100644 server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java delete mode 100644 server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithSqlValidationTest.java create mode 100644 server/apps/postgres-app/src/test/resources/usersrepository.xml diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java index 7f82a1963f1..c2157a7e9ed 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java @@ -74,7 +74,7 @@ void setUp() { } @Test - void jpaGuiceServerShouldUpdateQuota(GuiceJamesServer jamesServer) throws Exception { + void guiceServerShouldUpdateQuota(GuiceJamesServer jamesServer) throws Exception { jamesServer.getProbe(DataProbeImpl.class) .fluent() .addDomain(DOMAIN) diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest.java deleted file mode 100644 index 2e0fc42cd54..00000000000 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest.java +++ /dev/null @@ -1,42 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james; - -import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; - -import org.apache.james.backends.postgres.PostgresExtension; -import org.junit.jupiter.api.extension.RegisterExtension; - -class PostgresJamesServerWithAuthenticatedDatabaseSqlValidationTest extends PostgresJamesServerWithSqlValidationTest { - static PostgresExtension postgresExtension = PostgresExtension.empty(); - - @RegisterExtension - static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> - PostgresJamesConfiguration.builder() - .workingDirectory(tmpDir) - .configurationFromClasspath() - .usersRepository(DEFAULT) - .build()) - .server(configuration -> PostgresJamesServerMain.createServer(configuration) - .overrideWith(new TestJPAConfigurationModuleWithSqlValidation.WithDatabaseAuthentication(postgresExtension))) - .extension(postgresExtension) - .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) - .build(); -} diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java deleted file mode 100644 index 37d5491075b..00000000000 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest.java +++ /dev/null @@ -1,74 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james; - -import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; - -import org.apache.james.backends.postgres.PostgresExtension; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.extension.RegisterExtension; - -class PostgresJamesServerWithNoDatabaseAuthenticaticationSqlValidationTest extends PostgresJamesServerWithSqlValidationTest { - static PostgresExtension postgresExtension = PostgresExtension.empty(); - - @RegisterExtension - static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> - PostgresJamesConfiguration.builder() - .workingDirectory(tmpDir) - .configurationFromClasspath() - .usersRepository(DEFAULT) - .build()) - .server(configuration -> PostgresJamesServerMain.createServer(configuration) - .overrideWith(new TestJPAConfigurationModuleWithSqlValidation.NoDatabaseAuthentication(postgresExtension))) - .extension(postgresExtension) - .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) - .build(); - - @Override - @Disabled("PostgresExtension does not support non-authentication mode. SQL validation for JPA code with authentication should be enough.") - public void jpaGuiceServerShouldUpdateQuota(GuiceJamesServer jamesServer) { - - } - - @Override - @Disabled("PostgresExtension does not support non-authentication mode. SQL validation for JPA code with authentication should be enough.") - public void connectIMAPServerShouldSendShabangOnConnect(GuiceJamesServer jamesServer) { - } - - @Override - @Disabled("PostgresExtension does not support non-authentication mode. SQL validation for JPA code with authentication should be enough.") - public void connectOnSecondaryIMAPServerIMAPServerShouldSendShabangOnConnect(GuiceJamesServer jamesServer) { - } - - @Override - @Disabled("PostgresExtension does not support non-authentication mode. SQL validation for JPA code with authentication should be enough.") - public void connectPOP3ServerShouldSendShabangOnConnect(GuiceJamesServer jamesServer) { - } - - @Override - @Disabled("PostgresExtension does not support non-authentication mode. SQL validation for JPA code with authentication should be enough.") - public void connectSMTPServerShouldSendShabangOnConnect(GuiceJamesServer jamesServer) { - } - - @Override - @Disabled("PostgresExtension does not support non-authentication mode. SQL validation for JPA code with authentication should be enough.") - public void connectLMTPServerShouldSendShabangOnConnect(GuiceJamesServer jamesServer) { - } -} diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithSqlValidationTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithSqlValidationTest.java deleted file mode 100644 index 27643a4f16e..00000000000 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerWithSqlValidationTest.java +++ /dev/null @@ -1,30 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james; - -import org.junit.jupiter.api.Disabled; - -abstract class PostgresJamesServerWithSqlValidationTest extends PostgresJamesServerTest { - - @Override - @Disabled("Failing to create the domain: duplicate with test in JPAJamesServerTest") - void jpaGuiceServerShouldUpdateQuota(GuiceJamesServer jamesServer) { - } -} diff --git a/server/apps/postgres-app/src/test/resources/usersrepository.xml b/server/apps/postgres-app/src/test/resources/usersrepository.xml new file mode 100644 index 00000000000..a5390d7140d --- /dev/null +++ b/server/apps/postgres-app/src/test/resources/usersrepository.xml @@ -0,0 +1,28 @@ + + + + + + + PBKDF2-SHA512 + true + true + + From d8f3d78a1502b3f0545b4ba083de5ab88fcd4172 Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Thu, 7 Dec 2023 11:18:58 +0700 Subject: [PATCH 114/341] JAMES-2586 Rename JPAAttachmentContentLoader to PostgresAttachmentContentLoader --- ...ontentLoader.java => PostgresAttachmentContentLoader.java} | 4 ++-- .../mailbox/postgres/PostgresMailboxManagerProvider.java | 2 +- .../apache/james/modules/mailbox/PostgresMailboxModule.java | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/{JPAAttachmentContentLoader.java => PostgresAttachmentContentLoader.java} (88%) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/JPAAttachmentContentLoader.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresAttachmentContentLoader.java similarity index 88% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/JPAAttachmentContentLoader.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresAttachmentContentLoader.java index 02e4bb570d2..f78d3e35a94 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/JPAAttachmentContentLoader.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresAttachmentContentLoader.java @@ -26,9 +26,9 @@ import org.apache.james.mailbox.MailboxSession; import org.apache.james.mailbox.model.AttachmentMetadata; -public class JPAAttachmentContentLoader implements AttachmentContentLoader { +public class PostgresAttachmentContentLoader implements AttachmentContentLoader { @Override public InputStream load(AttachmentMetadata attachment, MailboxSession mailboxSession) { - throw new NotImplementedException("JPA doesn't support loading attachment separately from Message"); + throw new NotImplementedException("Postgresql doesn't support loading attachment separately from Message"); } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java index d7eca629f62..ac6a948d15f 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java @@ -70,7 +70,7 @@ public static PostgresMailboxManager provideMailboxManager(PostgresExtension pos LIMIT_ANNOTATIONS, LIMIT_ANNOTATION_SIZE); SessionProviderImpl sessionProvider = new SessionProviderImpl(noAuthenticator, noAuthorizator); QuotaComponents quotaComponents = QuotaComponents.disabled(sessionProvider, mf); - MessageSearchIndex index = new SimpleMessageSearchIndex(mf, mf, new DefaultTextExtractor(), new JPAAttachmentContentLoader()); + MessageSearchIndex index = new SimpleMessageSearchIndex(mf, mf, new DefaultTextExtractor(), new PostgresAttachmentContentLoader()); return new PostgresMailboxManager((PostgresMailboxSessionMapperFactory) mf, sessionProvider, messageParser, new PostgresMessageId.Factory(), diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java index 2d8d79ec7bd..8b64558d6ab 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java @@ -42,7 +42,7 @@ import org.apache.james.mailbox.indexer.ReIndexer; import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.MessageId; -import org.apache.james.mailbox.postgres.JPAAttachmentContentLoader; +import org.apache.james.mailbox.postgres.PostgresAttachmentContentLoader; import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; import org.apache.james.mailbox.postgres.PostgresMailboxId; import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; @@ -117,7 +117,7 @@ protected void configure() { bind(Authorizator.class).to(UserRepositoryAuthorizator.class); bind(MailboxId.Factory.class).to(PostgresMailboxId.Factory.class); bind(MailboxACLResolver.class).to(UnionMailboxACLResolver.class); - bind(AttachmentContentLoader.class).to(JPAAttachmentContentLoader.class); + bind(AttachmentContentLoader.class).to(PostgresAttachmentContentLoader.class); bind(ReIndexer.class).to(ReIndexerImpl.class); From ff4bc4d558d0439d1c9c7b041310b220e00c74ba Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Thu, 7 Dec 2023 13:04:34 +0700 Subject: [PATCH 115/341] JAMES-2586 Add a unit test for recreate RLS column should not fail Fixed by JAMES-2586 Small codestyle refactorings. --- .../postgres/PostgresTableManagerTest.java | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java index e0150d79dbd..9b9563d6429 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java @@ -27,7 +27,6 @@ import java.util.function.Supplier; import org.apache.commons.lang3.tuple.Pair; -import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.jooq.impl.DSL; import org.jooq.impl.SQLDataType; import org.junit.jupiter.api.Test; @@ -39,7 +38,7 @@ class PostgresTableManagerTest { @RegisterExtension - static PostgresExtension postgresExtension = PostgresExtension.empty(); + static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresModule.EMPTY_MODULE); Function tableManagerFactory = module -> new PostgresTableManager(postgresExtension.getPostgresExecutor(), module, true); @@ -342,6 +341,24 @@ void createTableShouldNotCreateRlsColumnWhenDisableRLS() { .doesNotContain(rlsColumn); } + @Test + void recreateRLSColumnWhenExistedShouldNotFail() { + String tableName = "tablename1"; + + PostgresTable rlsTable = PostgresTable.name(tableName) + .createTableStep((dsl, tbn) -> dsl.createTable(tbn) + .column("colum1", SQLDataType.UUID.notNull())) + .supportsRowLevelSecurity(); + + PostgresModule module = PostgresModule.table(rlsTable); + + PostgresTableManager testee = tableManagerFactory.apply(module); + testee.initializeTables().block(); + + assertThatCode(() -> testee.initializeTables().block()) + .doesNotThrowAnyException(); + } + private List> getColumnNameAndDataType(String tableName) { return postgresExtension.getConnection() .flatMapMany(connection -> Flux.from(Mono.from(connection.createStatement("SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_name = $1;") From abfaf2952371bae09d2e46ba4f8c6435bbe99117 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Thu, 7 Dec 2023 14:06:40 +0700 Subject: [PATCH 116/341] JAMES-2586 PostgresExecutor: better recognize prepared statement conflict Otherwise, we could retry on other fatal errors like: io.r2dbc.postgresql.ExceptionFactory$PostgresqlBadGrammarException: column "domain" of relation "mailbox" already exists. --- .../james/backends/postgres/utils/PostgresExecutor.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index b530405f092..686145dbeb5 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -121,6 +121,8 @@ public Mono dispose() { } private Predicate preparedStatementConflictException() { - return throwable -> throwable.getCause() instanceof R2dbcBadGrammarException; + return throwable -> throwable.getCause() instanceof R2dbcBadGrammarException + && throwable.getMessage().contains("prepared statement") + && throwable.getMessage().contains("already exists"); } } From 92a2b79b4a9906c9aabcff75938eda405210e353 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 11 Dec 2023 13:44:15 +0700 Subject: [PATCH 117/341] JAMES-2586 PostgresTableManager - Check the existence of RLS column/policy before alter the table --- .../backends/postgres/PostgresTableManager.java | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java index 38e51da1b75..8f935f69cfc 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java @@ -85,7 +85,7 @@ private Mono handleTableCreationException(PostgresTable table, Throwable e if (e instanceof DataAccessException && e.getMessage().contains(String.format("\"%s\" already exists", table.getName()))) { return Mono.empty(); } - LOGGER.error("Error while creating table {}", table.getName(), e); + LOGGER.error("Error while creating table: {}", table.getName(), e); return Mono.error(e); } @@ -105,10 +105,14 @@ public Mono alterTableEnableRLS(PostgresTable table) { } private String rowLevelSecurityAlterStatement(String tableName) { - return "SET app.current_domain = ''; ALTER TABLE " + tableName + " ADD DOMAIN varchar(255) not null DEFAULT current_setting('app.current_domain')::text;" + - "ALTER TABLE " + tableName + " ENABLE ROW LEVEL SECURITY; " + - "ALTER TABLE " + tableName + " FORCE ROW LEVEL SECURITY; " + - "CREATE POLICY DOMAIN_" + tableName + "_POLICY ON " + tableName + " USING (DOMAIN = current_setting('app.current_domain')::text);"; + String policyName = "domain_" + tableName + "_policy"; + return "set app.current_domain = ''; alter table " + tableName + " add column if not exists domain varchar(255) not null default current_setting('app.current_domain')::text ;" + + "do $$ \n" + + "begin \n" + + " if not exists( select policyname from pg_policies where policyname = '" + policyName + "') then \n" + + " execute 'alter table " + tableName + " enable row level security; alter table " + tableName + " force row level security; create policy " + policyName + " on " + tableName + " using (domain = current_setting(''app.current_domain'')::text)';\n" + + " end if;\n" + + "end $$;"; } public Mono truncate() { From fae71c70867dc4a85b44a234ff2d230909421824 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 11 Dec 2023 13:44:54 +0700 Subject: [PATCH 118/341] JAMES-2586 PostgresTableManager - Cleanup --- .../james/backends/postgres/quota/PostgresQuotaModule.java | 2 +- .../mailbox/postgres/user/PostgresSubscriptionModule.java | 2 +- server/apps/postgres-app/docker-compose.yml | 7 ++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaModule.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaModule.java index a3ffe8597ae..1810d327356 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaModule.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaModule.java @@ -65,7 +65,7 @@ interface PostgresQuotaLimitTable { Name PK_CONSTRAINT_NAME = DSL.name("quota_limit_pkey"); PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) - .createTableStep(((dsl, tableName) -> dsl.createTable(tableName) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) .column(QUOTA_SCOPE) .column(IDENTIFIER) .column(QUOTA_COMPONENT) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java index 68c8eca1d0c..c188c050c5f 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java @@ -39,7 +39,7 @@ public interface PostgresSubscriptionModule { Field USER = DSL.field("user_name", SQLDataType.VARCHAR(255).notNull()); Table TABLE_NAME = DSL.table("subscription"); PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) - .createTableStep(((dsl, tableName) -> dsl.createTable(tableName) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) .column(MAILBOX) .column(USER) .constraint(DSL.unique(MAILBOX, USER)))) diff --git a/server/apps/postgres-app/docker-compose.yml b/server/apps/postgres-app/docker-compose.yml index 2edf3cd44f3..d7496c60d41 100644 --- a/server/apps/postgres-app/docker-compose.yml +++ b/server/apps/postgres-app/docker-compose.yml @@ -16,8 +16,9 @@ services: hostname: james.local volumes: - $PWD/postgresql-42.5.4.jar:/root/libs/postgresql-42.5.4.jar - - $PWD/sample-configuration/james-database-postgres.properties:/root/conf/james-database.properties - - $PWD/src/test/resources/keystore:/root/conf/keystore + - ./sample-configuration/james-database-postgres.properties:/root/conf/james-database.properties + command: + - --generate-keystore ports: - "80:80" - "25:25" @@ -31,7 +32,7 @@ services: postgres: image: postgres:16.0 ports: - - 5432:5432 + - "5432:5432" environment: - POSTGRES_DB=james - POSTGRES_USER=james From 8fe4d3736954d633dad7137ae7f63e408cd9ff1f Mon Sep 17 00:00:00 2001 From: hung phan Date: Thu, 7 Dec 2023 17:46:52 +0700 Subject: [PATCH 119/341] JAMES-2586 PostgresRecipientRewriteTableDAO and PostgresRecipientRewriteTable --- .../main/resources/META-INF/persistence.xml | 1 - .../modules/data/PostgresDataModule.java | 2 +- ... PostgresRecipientRewriteTableModule.java} | 19 +- server/data/data-postgres/pom.xml | 2 - .../rrt/jpa/JPARecipientRewriteTable.java | 251 ------------------ .../rrt/jpa/model/JPARecipientRewrite.java | 147 ---------- .../PostgresRecipientRewriteTable.java | 88 ++++++ .../PostgresRecipientRewriteTableDAO.java | 89 +++++++ .../PostgresRecipientRewriteTableModule.java | 59 ++++ .../PostgresRecipientRewriteTableTest.java} | 29 +- .../PostgresStepdefs.java} | 35 +-- .../{jpa => postgres}/RewriteTablesTest.java | 4 +- .../src/test/resources/persistence.xml | 1 - 13 files changed, 286 insertions(+), 441 deletions(-) rename server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/{JPARecipientRewriteTableModule.java => PostgresRecipientRewriteTableModule.java} (71%) delete mode 100644 server/data/data-postgres/src/main/java/org/apache/james/rrt/jpa/JPARecipientRewriteTable.java delete mode 100644 server/data/data-postgres/src/main/java/org/apache/james/rrt/jpa/model/JPARecipientRewrite.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTable.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableDAO.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableModule.java rename server/data/data-postgres/src/test/java/org/apache/james/rrt/{jpa/JPARecipientRewriteTableTest.java => postgres/PostgresRecipientRewriteTableTest.java} (59%) rename server/data/data-postgres/src/test/java/org/apache/james/rrt/{jpa/JPAStepdefs.java => postgres/PostgresStepdefs.java} (57%) rename server/data/data-postgres/src/test/java/org/apache/james/rrt/{jpa => postgres}/RewriteTablesTest.java (96%) diff --git a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml index d074a13385a..489b1e81d78 100644 --- a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml +++ b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml @@ -26,7 +26,6 @@ org.apache.james.mailrepository.jpa.model.JPAUrl org.apache.james.mailrepository.jpa.model.JPAMail - org.apache.james.rrt.jpa.model.JPARecipientRewrite org.apache.james.sieve.postgres.model.JPASieveScript diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataModule.java index 39cec088895..905f3462496 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataModule.java @@ -28,7 +28,7 @@ public class PostgresDataModule extends AbstractModule { protected void configure() { install(new CoreDataModule()); install(new PostgresDomainListModule()); - install(new JPARecipientRewriteTableModule()); + install(new PostgresRecipientRewriteTableModule()); install(new JPAMailRepositoryModule()); } } diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPARecipientRewriteTableModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresRecipientRewriteTableModule.java similarity index 71% rename from server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPARecipientRewriteTableModule.java rename to server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresRecipientRewriteTableModule.java index f00af56754a..363c9879b8b 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPARecipientRewriteTableModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresRecipientRewriteTableModule.java @@ -16,37 +16,44 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ + package org.apache.james.modules.data; +import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.rrt.api.AliasReverseResolver; import org.apache.james.rrt.api.CanSendFrom; import org.apache.james.rrt.api.RecipientRewriteTable; -import org.apache.james.rrt.jpa.JPARecipientRewriteTable; import org.apache.james.rrt.lib.AliasReverseResolverImpl; import org.apache.james.rrt.lib.CanSendFromImpl; +import org.apache.james.rrt.postgres.PostgresRecipientRewriteTable; +import org.apache.james.rrt.postgres.PostgresRecipientRewriteTableDAO; import org.apache.james.server.core.configuration.ConfigurationProvider; import org.apache.james.utils.InitializationOperation; import org.apache.james.utils.InitilizationOperationBuilder; import com.google.inject.AbstractModule; import com.google.inject.Scopes; +import com.google.inject.multibindings.Multibinder; import com.google.inject.multibindings.ProvidesIntoSet; -public class JPARecipientRewriteTableModule extends AbstractModule { +public class PostgresRecipientRewriteTableModule extends AbstractModule { @Override public void configure() { - bind(JPARecipientRewriteTable.class).in(Scopes.SINGLETON); - bind(RecipientRewriteTable.class).to(JPARecipientRewriteTable.class); + bind(PostgresRecipientRewriteTable.class).in(Scopes.SINGLETON); + bind(PostgresRecipientRewriteTableDAO.class).in(Scopes.SINGLETON); + bind(RecipientRewriteTable.class).to(PostgresRecipientRewriteTable.class); bind(AliasReverseResolverImpl.class).in(Scopes.SINGLETON); bind(AliasReverseResolver.class).to(AliasReverseResolverImpl.class); bind(CanSendFromImpl.class).in(Scopes.SINGLETON); bind(CanSendFrom.class).to(CanSendFromImpl.class); + + Multibinder.newSetBinder(binder(), PostgresModule.class).addBinding().toInstance(org.apache.james.rrt.postgres.PostgresRecipientRewriteTableModule.MODULE); } @ProvidesIntoSet - InitializationOperation configureRRT(ConfigurationProvider configurationProvider, JPARecipientRewriteTable recipientRewriteTable) { + InitializationOperation configureRecipientRewriteTable(ConfigurationProvider configurationProvider, PostgresRecipientRewriteTable recipientRewriteTable) { return InitilizationOperationBuilder - .forClass(JPARecipientRewriteTable.class) + .forClass(PostgresRecipientRewriteTable.class) .init(() -> recipientRewriteTable.configure(configurationProvider.getConfiguration("recipientrewritetable"))); } } diff --git a/server/data/data-postgres/pom.xml b/server/data/data-postgres/pom.xml index f5a2a5226e3..88b2b88b9c8 100644 --- a/server/data/data-postgres/pom.xml +++ b/server/data/data-postgres/pom.xml @@ -156,7 +156,6 @@ ${apache.openjpa.version} org/apache/james/sieve/postgres/model/JPASieveScript.class, - org/apache/james/rrt/jpa/model/JPARecipientRewrite.class, org/apache/james/mailrepository/jpa/model/JPAUrl.class, org/apache/james/mailrepository/jpa/model/JPAMail.class true @@ -169,7 +168,6 @@ metaDataFactory jpa(Types=org.apache.james.sieve.postgres.model.JPASieveScript; - org.apache.james.rrt.jpa.model.JPARecipientRewrite; org.apache.james.mailrepository.jpa.model.JPAUrl; org.apache.james.mailrepository.jpa.model.JPAMail) diff --git a/server/data/data-postgres/src/main/java/org/apache/james/rrt/jpa/JPARecipientRewriteTable.java b/server/data/data-postgres/src/main/java/org/apache/james/rrt/jpa/JPARecipientRewriteTable.java deleted file mode 100644 index 1d33448a54e..00000000000 --- a/server/data/data-postgres/src/main/java/org/apache/james/rrt/jpa/JPARecipientRewriteTable.java +++ /dev/null @@ -1,251 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.rrt.jpa; - -import static org.apache.james.rrt.jpa.model.JPARecipientRewrite.DELETE_MAPPING_QUERY; -import static org.apache.james.rrt.jpa.model.JPARecipientRewrite.SELECT_ALL_MAPPINGS_QUERY; -import static org.apache.james.rrt.jpa.model.JPARecipientRewrite.SELECT_SOURCES_BY_MAPPING_QUERY; -import static org.apache.james.rrt.jpa.model.JPARecipientRewrite.SELECT_USER_DOMAIN_MAPPING_QUERY; -import static org.apache.james.rrt.jpa.model.JPARecipientRewrite.UPDATE_MAPPING_QUERY; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Stream; - -import javax.inject.Inject; -import javax.persistence.EntityManager; -import javax.persistence.EntityManagerFactory; -import javax.persistence.EntityTransaction; -import javax.persistence.PersistenceException; -import javax.persistence.PersistenceUnit; - -import org.apache.james.backends.jpa.EntityManagerUtils; -import org.apache.james.core.Domain; -import org.apache.james.rrt.api.RecipientRewriteTableException; -import org.apache.james.rrt.jpa.model.JPARecipientRewrite; -import org.apache.james.rrt.lib.AbstractRecipientRewriteTable; -import org.apache.james.rrt.lib.Mapping; -import org.apache.james.rrt.lib.MappingSource; -import org.apache.james.rrt.lib.Mappings; -import org.apache.james.rrt.lib.MappingsImpl; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.google.common.base.Preconditions; - -/** - * Class responsible to implement the Virtual User Table in database with JPA - * access. - */ -public class JPARecipientRewriteTable extends AbstractRecipientRewriteTable { - private static final Logger LOGGER = LoggerFactory.getLogger(JPARecipientRewriteTable.class); - - /** - * The entity manager to access the database. - */ - private EntityManagerFactory entityManagerFactory; - - /** - * Set the entity manager to use. - */ - @Inject - @PersistenceUnit(unitName = "James") - public void setEntityManagerFactory(EntityManagerFactory entityManagerFactory) { - this.entityManagerFactory = entityManagerFactory; - } - - @Override - public void addMapping(MappingSource source, Mapping mapping) throws RecipientRewriteTableException { - Mappings map = getStoredMappings(source); - if (!map.isEmpty()) { - Mappings updatedMappings = MappingsImpl.from(map).add(mapping).build(); - doUpdateMapping(source, updatedMappings.serialize()); - } else { - doAddMapping(source, mapping.asString()); - } - } - - @Override - protected Mappings mapAddress(String user, Domain domain) throws RecipientRewriteTableException { - Mappings userDomainMapping = getStoredMappings(MappingSource.fromUser(user, domain)); - if (userDomainMapping != null && !userDomainMapping.isEmpty()) { - return userDomainMapping; - } - Mappings domainMapping = getStoredMappings(MappingSource.fromDomain(domain)); - if (domainMapping != null && !domainMapping.isEmpty()) { - return domainMapping; - } - return MappingsImpl.empty(); - } - - @Override - public Mappings getStoredMappings(MappingSource source) throws RecipientRewriteTableException { - EntityManager entityManager = entityManagerFactory.createEntityManager(); - try { - @SuppressWarnings("unchecked") - List virtualUsers = entityManager.createNamedQuery(SELECT_USER_DOMAIN_MAPPING_QUERY) - .setParameter("user", source.getFixedUser()) - .setParameter("domain", source.getFixedDomain()) - .getResultList(); - if (virtualUsers.size() > 0) { - return MappingsImpl.fromRawString(virtualUsers.get(0).getTargetAddress()); - } - return MappingsImpl.empty(); - } catch (PersistenceException e) { - LOGGER.debug("Failed to get user domain mappings", e); - throw new RecipientRewriteTableException("Error while retrieve mappings", e); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - @Override - public Map getAllMappings() throws RecipientRewriteTableException { - EntityManager entityManager = entityManagerFactory.createEntityManager(); - Map mapping = new HashMap<>(); - try { - @SuppressWarnings("unchecked") - List virtualUsers = entityManager.createNamedQuery(SELECT_ALL_MAPPINGS_QUERY).getResultList(); - for (JPARecipientRewrite virtualUser : virtualUsers) { - mapping.put(MappingSource.fromUser(virtualUser.getUser(), virtualUser.getDomain()), MappingsImpl.fromRawString(virtualUser.getTargetAddress())); - } - return mapping; - } catch (PersistenceException e) { - LOGGER.debug("Failed to get all mappings", e); - throw new RecipientRewriteTableException("Error while retrieve mappings", e); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - @Override - public Stream listSources(Mapping mapping) throws RecipientRewriteTableException { - Preconditions.checkArgument(listSourcesSupportedType.contains(mapping.getType()), - "Not supported mapping of type %s", mapping.getType()); - - EntityManager entityManager = entityManagerFactory.createEntityManager(); - try { - return entityManager.createNamedQuery(SELECT_SOURCES_BY_MAPPING_QUERY, JPARecipientRewrite.class) - .setParameter("targetAddress", mapping.asString()) - .getResultList() - .stream() - .map(user -> MappingSource.fromUser(user.getUser(), user.getDomain())); - } catch (PersistenceException e) { - String error = "Unable to list sources by mapping"; - LOGGER.debug(error, e); - throw new RecipientRewriteTableException(error, e); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - @Override - public void removeMapping(MappingSource source, Mapping mapping) throws RecipientRewriteTableException { - Mappings map = getStoredMappings(source); - if (map.size() > 1) { - Mappings updatedMappings = map.remove(mapping); - doUpdateMapping(source, updatedMappings.serialize()); - } else { - doRemoveMapping(source, mapping.asString()); - } - } - - /** - * Update the mapping for the given user and domain - * - * @return true if update was successfully - */ - private boolean doUpdateMapping(MappingSource source, String mapping) throws RecipientRewriteTableException { - EntityManager entityManager = entityManagerFactory.createEntityManager(); - final EntityTransaction transaction = entityManager.getTransaction(); - try { - transaction.begin(); - int updated = entityManager - .createNamedQuery(UPDATE_MAPPING_QUERY) - .setParameter("targetAddress", mapping) - .setParameter("user", source.getFixedUser()) - .setParameter("domain", source.getFixedDomain()) - .executeUpdate(); - transaction.commit(); - if (updated > 0) { - return true; - } - } catch (PersistenceException e) { - LOGGER.debug("Failed to update mapping", e); - if (transaction.isActive()) { - transaction.rollback(); - } - throw new RecipientRewriteTableException("Unable to update mapping", e); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - return false; - } - - /** - * Remove a mapping for the given user and domain - */ - private void doRemoveMapping(MappingSource source, String mapping) throws RecipientRewriteTableException { - EntityManager entityManager = entityManagerFactory.createEntityManager(); - final EntityTransaction transaction = entityManager.getTransaction(); - try { - transaction.begin(); - entityManager.createNamedQuery(DELETE_MAPPING_QUERY) - .setParameter("user", source.getFixedUser()) - .setParameter("domain", source.getFixedDomain()) - .setParameter("targetAddress", mapping) - .executeUpdate(); - transaction.commit(); - - } catch (PersistenceException e) { - LOGGER.debug("Failed to remove mapping", e); - if (transaction.isActive()) { - transaction.rollback(); - } - throw new RecipientRewriteTableException("Unable to remove mapping", e); - - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - /** - * Add mapping for given user and domain - */ - private void doAddMapping(MappingSource source, String mapping) throws RecipientRewriteTableException { - EntityManager entityManager = entityManagerFactory.createEntityManager(); - final EntityTransaction transaction = entityManager.getTransaction(); - try { - transaction.begin(); - JPARecipientRewrite jpaRecipientRewrite = new JPARecipientRewrite(source.getFixedUser(), Domain.of(source.getFixedDomain()), mapping); - entityManager.persist(jpaRecipientRewrite); - transaction.commit(); - } catch (PersistenceException e) { - LOGGER.debug("Failed to save virtual user", e); - if (transaction.isActive()) { - transaction.rollback(); - } - throw new RecipientRewriteTableException("Unable to add mapping", e); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - -} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/rrt/jpa/model/JPARecipientRewrite.java b/server/data/data-postgres/src/main/java/org/apache/james/rrt/jpa/model/JPARecipientRewrite.java deleted file mode 100644 index 47402762c02..00000000000 --- a/server/data/data-postgres/src/main/java/org/apache/james/rrt/jpa/model/JPARecipientRewrite.java +++ /dev/null @@ -1,147 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.rrt.jpa.model; - -import static org.apache.james.rrt.jpa.model.JPARecipientRewrite.DELETE_MAPPING_QUERY; -import static org.apache.james.rrt.jpa.model.JPARecipientRewrite.SELECT_ALL_MAPPINGS_QUERY; -import static org.apache.james.rrt.jpa.model.JPARecipientRewrite.SELECT_SOURCES_BY_MAPPING_QUERY; -import static org.apache.james.rrt.jpa.model.JPARecipientRewrite.SELECT_USER_DOMAIN_MAPPING_QUERY; -import static org.apache.james.rrt.jpa.model.JPARecipientRewrite.UPDATE_MAPPING_QUERY; - -import java.io.Serializable; - -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.Id; -import javax.persistence.IdClass; -import javax.persistence.NamedQueries; -import javax.persistence.NamedQuery; -import javax.persistence.Table; - -import org.apache.james.core.Domain; - -import com.google.common.base.Objects; - -/** - * RecipientRewriteTable class for the James Virtual User Table to be used for JPA - * persistence. - */ -@Entity(name = "JamesRecipientRewrite") -@Table(name = JPARecipientRewrite.JAMES_RECIPIENT_REWRITE) -@NamedQueries({ - @NamedQuery(name = SELECT_USER_DOMAIN_MAPPING_QUERY, query = "SELECT rrt FROM JamesRecipientRewrite rrt WHERE rrt.user=:user AND rrt.domain=:domain"), - @NamedQuery(name = SELECT_ALL_MAPPINGS_QUERY, query = "SELECT rrt FROM JamesRecipientRewrite rrt"), - @NamedQuery(name = DELETE_MAPPING_QUERY, query = "DELETE FROM JamesRecipientRewrite rrt WHERE rrt.user=:user AND rrt.domain=:domain AND rrt.targetAddress=:targetAddress"), - @NamedQuery(name = UPDATE_MAPPING_QUERY, query = "UPDATE JamesRecipientRewrite rrt SET rrt.targetAddress=:targetAddress WHERE rrt.user=:user AND rrt.domain=:domain"), - @NamedQuery(name = SELECT_SOURCES_BY_MAPPING_QUERY, query = "SELECT rrt FROM JamesRecipientRewrite rrt WHERE rrt.targetAddress=:targetAddress")}) -@IdClass(JPARecipientRewrite.RecipientRewriteTableId.class) -public class JPARecipientRewrite { - public static final String SELECT_USER_DOMAIN_MAPPING_QUERY = "selectUserDomainMapping"; - public static final String SELECT_ALL_MAPPINGS_QUERY = "selectAllMappings"; - public static final String DELETE_MAPPING_QUERY = "deleteMapping"; - public static final String UPDATE_MAPPING_QUERY = "updateMapping"; - public static final String SELECT_SOURCES_BY_MAPPING_QUERY = "selectSourcesByMapping"; - - public static final String JAMES_RECIPIENT_REWRITE = "JAMES_RECIPIENT_REWRITE"; - - public static class RecipientRewriteTableId implements Serializable { - - private static final long serialVersionUID = 1L; - - private String user; - - private String domain; - - public RecipientRewriteTableId() { - } - - @Override - public int hashCode() { - return Objects.hashCode(user, domain); - } - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null || getClass() != obj.getClass()) { - return false; - } - final RecipientRewriteTableId other = (RecipientRewriteTableId) obj; - return Objects.equal(this.user, other.user) && Objects.equal(this.domain, other.domain); - } - } - - /** - * The name of the user. - */ - @Id - @Column(name = "USER_NAME", nullable = false, length = 100) - private String user = ""; - - /** - * The name of the domain. Column name is chosen to be compatible with the - * JDBCRecipientRewriteTableList. - */ - @Id - @Column(name = "DOMAIN_NAME", nullable = false, length = 100) - private String domain = ""; - - /** - * The target address. column name is chosen to be compatible with the - * JDBCRecipientRewriteTableList. - */ - @Column(name = "TARGET_ADDRESS", nullable = false, length = 100) - private String targetAddress = ""; - - /** - * Default no-args constructor for JPA class enhancement. - * The constructor need to be public or protected to be used by JPA. - * See: http://docs.oracle.com/javaee/6/tutorial/doc/bnbqa.html - * Do not us this constructor, it is for JPA only. - */ - protected JPARecipientRewrite() { - } - - /** - * Use this simple constructor to create a new RecipientRewriteTable. - * - * @param user - * , domain and their associated targetAddress - */ - public JPARecipientRewrite(String user, Domain domain, String targetAddress) { - this.user = user; - this.domain = domain.asString(); - this.targetAddress = targetAddress; - } - - public String getUser() { - return user; - } - - public String getDomain() { - return domain; - } - - public String getTargetAddress() { - return targetAddress; - } - -} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTable.java b/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTable.java new file mode 100644 index 00000000000..862e19de407 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTable.java @@ -0,0 +1,88 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.rrt.postgres; + +import java.util.Map; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import javax.inject.Inject; + +import org.apache.commons.lang3.tuple.Pair; +import org.apache.james.core.Domain; +import org.apache.james.rrt.lib.AbstractRecipientRewriteTable; +import org.apache.james.rrt.lib.Mapping; +import org.apache.james.rrt.lib.MappingSource; +import org.apache.james.rrt.lib.Mappings; +import org.apache.james.rrt.lib.MappingsImpl; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableMap; + +public class PostgresRecipientRewriteTable extends AbstractRecipientRewriteTable { + private PostgresRecipientRewriteTableDAO postgresRecipientRewriteTableDAO; + + @Inject + public PostgresRecipientRewriteTable(PostgresRecipientRewriteTableDAO postgresRecipientRewriteTableDAO) { + this.postgresRecipientRewriteTableDAO = postgresRecipientRewriteTableDAO; + } + + @Override + public void addMapping(MappingSource source, Mapping mapping) { + postgresRecipientRewriteTableDAO.addMapping(source, mapping).block(); + } + + @Override + public void removeMapping(MappingSource source, Mapping mapping) { + postgresRecipientRewriteTableDAO.removeMapping(source, mapping).block(); + } + + @Override + public Mappings getStoredMappings(MappingSource source) { + return postgresRecipientRewriteTableDAO.getMappings(source).block(); + } + + @Override + public Map getAllMappings() { + return postgresRecipientRewriteTableDAO.getAllMappings() + .collect(ImmutableMap.toImmutableMap( + Pair::getLeft, + pair -> MappingsImpl.fromMappings(pair.getRight()), + Mappings::union)) + .block(); + } + + @Override + protected Mappings mapAddress(String user, Domain domain) { + return postgresRecipientRewriteTableDAO.getMappings(MappingSource.fromUser(user, domain)) + .filter(Predicate.not(Mappings::isEmpty)) + .blockOptional() + .orElse(postgresRecipientRewriteTableDAO.getMappings(MappingSource.fromDomain(domain)).block()); + } + + @Override + public Stream listSources(Mapping mapping) { + Preconditions.checkArgument(listSourcesSupportedType.contains(mapping.getType()), + "Not supported mapping of type %s", mapping.getType()); + + return postgresRecipientRewriteTableDAO.getSources(mapping).toStream(); + } + +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableDAO.java new file mode 100644 index 00000000000..c5bbf9d1c30 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableDAO.java @@ -0,0 +1,89 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.rrt.postgres; + +import static org.apache.james.rrt.postgres.PostgresRecipientRewriteTableModule.PostgresRecipientRewriteTableTable.DOMAIN_NAME; +import static org.apache.james.rrt.postgres.PostgresRecipientRewriteTableModule.PostgresRecipientRewriteTableTable.PK_CONSTRAINT_NAME; +import static org.apache.james.rrt.postgres.PostgresRecipientRewriteTableModule.PostgresRecipientRewriteTableTable.TABLE_NAME; +import static org.apache.james.rrt.postgres.PostgresRecipientRewriteTableModule.PostgresRecipientRewriteTableTable.TARGET_ADDRESS; +import static org.apache.james.rrt.postgres.PostgresRecipientRewriteTableModule.PostgresRecipientRewriteTableTable.USERNAME; + +import javax.inject.Inject; + +import org.apache.commons.lang3.tuple.Pair; +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.rrt.lib.Mapping; +import org.apache.james.rrt.lib.MappingSource; +import org.apache.james.rrt.lib.Mappings; +import org.apache.james.rrt.lib.MappingsImpl; + +import com.google.common.collect.ImmutableList; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresRecipientRewriteTableDAO { + private final PostgresExecutor postgresExecutor; + + @Inject + public PostgresRecipientRewriteTableDAO(PostgresExecutor postgresExecutor) { + this.postgresExecutor = postgresExecutor; + } + + public Mono addMapping(MappingSource source, Mapping mapping) { + return postgresExecutor.executeVoid(dslContext -> + Mono.from(dslContext.insertInto(TABLE_NAME, USERNAME, DOMAIN_NAME, TARGET_ADDRESS) + .values(source.getFixedUser(), + source.getFixedDomain(), + mapping.asString()) + .onConflictOnConstraint(PK_CONSTRAINT_NAME) + .doUpdate() + .set(TARGET_ADDRESS, mapping.asString()))); + } + + public Mono removeMapping(MappingSource source, Mapping mapping) { + return postgresExecutor.executeVoid(dsl -> Mono.from(dsl.deleteFrom(TABLE_NAME) + .where(USERNAME.eq(source.getFixedUser())) + .and(DOMAIN_NAME.eq(source.getFixedDomain())) + .and(TARGET_ADDRESS.eq(mapping.asString())))); + } + + public Mono getMappings(MappingSource source) { + return postgresExecutor.executeRows(dsl -> Flux.from(dsl.selectFrom(TABLE_NAME) + .where(USERNAME.eq(source.getFixedUser())) + .and(DOMAIN_NAME.eq(source.getFixedDomain())))) + .map(record -> record.get(TARGET_ADDRESS)) + .collect(ImmutableList.toImmutableList()) + .map(MappingsImpl::fromCollection); + } + + public Flux> getAllMappings() { + return postgresExecutor.executeRows(dsl -> Flux.from(dsl.selectFrom(TABLE_NAME))) + .map(record -> Pair.of( + MappingSource.fromUser(record.get(USERNAME), record.get(DOMAIN_NAME)), + Mapping.of(record.get(TARGET_ADDRESS)))); + } + + public Flux getSources(Mapping mapping) { + return postgresExecutor.executeRows(dsl -> Flux.from(dsl.selectFrom(TABLE_NAME) + .where(TARGET_ADDRESS.eq(mapping.asString())))) + .map(record -> MappingSource.fromUser(record.get(USERNAME), record.get(DOMAIN_NAME))); + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableModule.java b/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableModule.java new file mode 100644 index 00000000000..7574483439a --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableModule.java @@ -0,0 +1,59 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.rrt.postgres; + +import org.apache.james.backends.postgres.PostgresIndex; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.Name; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresRecipientRewriteTableModule { + interface PostgresRecipientRewriteTableTable { + Table TABLE_NAME = DSL.table("rrt"); + + Field USERNAME = DSL.field("username", SQLDataType.VARCHAR(255).notNull()); + Field DOMAIN_NAME = DSL.field("domain_name", SQLDataType.VARCHAR(255).notNull()); + Field TARGET_ADDRESS = DSL.field("target_address", SQLDataType.VARCHAR(255).notNull()); + + Name PK_CONSTRAINT_NAME = DSL.name("rrt_pkey"); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(USERNAME) + .column(DOMAIN_NAME) + .column(TARGET_ADDRESS) + .constraint(DSL.constraint(PK_CONSTRAINT_NAME).primaryKey(USERNAME, DOMAIN_NAME, TARGET_ADDRESS)))) + .supportsRowLevelSecurity(); + + PostgresIndex INDEX = PostgresIndex.name("idx_rrt_target_address") + .createIndexStep((dslContext, indexName) -> dslContext.createIndex(indexName) + .on(TABLE_NAME, TARGET_ADDRESS)); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(PostgresRecipientRewriteTableTable.TABLE) + .addIndex(PostgresRecipientRewriteTableTable.INDEX) + .build(); +} diff --git a/server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/JPARecipientRewriteTableTest.java b/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableTest.java similarity index 59% rename from server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/JPARecipientRewriteTableTest.java rename to server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableTest.java index 308f448d694..757778dd7b0 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/JPARecipientRewriteTableTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableTest.java @@ -16,25 +16,27 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.rrt.jpa; -import static org.mockito.Mockito.mock; +package org.apache.james.rrt.postgres; -import org.apache.james.backends.jpa.JpaTestCluster; -import org.apache.james.domainlist.api.DomainList; -import org.apache.james.rrt.jpa.model.JPARecipientRewrite; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.domainlist.api.mock.SimpleDomainList; import org.apache.james.rrt.lib.AbstractRecipientRewriteTable; import org.apache.james.rrt.lib.RecipientRewriteTableContract; +import org.apache.james.user.postgres.PostgresUserModule; import org.apache.james.user.postgres.PostgresUsersDAO; import org.apache.james.user.postgres.PostgresUsersRepository; +import org.apache.james.user.postgres.PostgresUsersRepositoryConfiguration; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; -class JPARecipientRewriteTableTest implements RecipientRewriteTableContract { +public class PostgresRecipientRewriteTableTest implements RecipientRewriteTableContract { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresModule.aggregateModules(PostgresRecipientRewriteTableModule.MODULE, PostgresUserModule.MODULE)); - static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPARecipientRewrite.class); - - AbstractRecipientRewriteTable recipientRewriteTable; + private PostgresRecipientRewriteTable postgresRecipientRewriteTable; @BeforeEach void setup() throws Exception { @@ -48,14 +50,13 @@ void teardown() throws Exception { @Override public void createRecipientRewriteTable() { - JPARecipientRewriteTable localVirtualUserTable = new JPARecipientRewriteTable(); - localVirtualUserTable.setEntityManagerFactory(JPA_TEST_CLUSTER.getEntityManagerFactory()); - localVirtualUserTable.setUsersRepository(new PostgresUsersRepository(mock(DomainList.class), mock(PostgresUsersDAO.class))); - recipientRewriteTable = localVirtualUserTable; + postgresRecipientRewriteTable = new PostgresRecipientRewriteTable(new PostgresRecipientRewriteTableDAO(postgresExtension.getPostgresExecutor())); + postgresRecipientRewriteTable.setUsersRepository(new PostgresUsersRepository(new SimpleDomainList(), + new PostgresUsersDAO(postgresExtension.getPostgresExecutor(), PostgresUsersRepositoryConfiguration.DEFAULT))); } @Override public AbstractRecipientRewriteTable virtualUserTable() { - return recipientRewriteTable; + return postgresRecipientRewriteTable; } } diff --git a/server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/JPAStepdefs.java b/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/PostgresStepdefs.java similarity index 57% rename from server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/JPAStepdefs.java rename to server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/PostgresStepdefs.java index 6ff90584029..dc89ddf929e 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/JPAStepdefs.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/PostgresStepdefs.java @@ -16,48 +16,51 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.rrt.jpa; +package org.apache.james.rrt.postgres; -import static org.mockito.Mockito.mock; - -import org.apache.james.backends.jpa.JpaTestCluster; -import org.apache.james.rrt.jpa.model.JPARecipientRewrite; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.domainlist.api.DomainListException; import org.apache.james.rrt.lib.AbstractRecipientRewriteTable; import org.apache.james.rrt.lib.RecipientRewriteTableFixture; import org.apache.james.rrt.lib.RewriteTablesStepdefs; +import org.apache.james.user.postgres.PostgresUserModule; import org.apache.james.user.postgres.PostgresUsersDAO; import org.apache.james.user.postgres.PostgresUsersRepository; +import org.apache.james.user.postgres.PostgresUsersRepositoryConfiguration; import com.github.fge.lambdas.Throwing; import cucumber.api.java.After; import cucumber.api.java.Before; -public class JPAStepdefs { - - private static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPARecipientRewrite.class); +public class PostgresStepdefs { + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresModule.aggregateModules(PostgresRecipientRewriteTableModule.MODULE, PostgresUserModule.MODULE)); private final RewriteTablesStepdefs mainStepdefs; - public JPAStepdefs(RewriteTablesStepdefs mainStepdefs) { + public PostgresStepdefs(RewriteTablesStepdefs mainStepdefs) { this.mainStepdefs = mainStepdefs; } @Before public void setup() throws Throwable { + postgresExtension.beforeAll(null); + postgresExtension.beforeEach(null); mainStepdefs.setUp(Throwing.supplier(this::getRecipientRewriteTable).sneakyThrow()); } @After public void tearDown() { - JPA_TEST_CLUSTER.clear(JPARecipientRewrite.JAMES_RECIPIENT_REWRITE); + postgresExtension.afterEach(null); + postgresExtension.afterAll(null); } - private AbstractRecipientRewriteTable getRecipientRewriteTable() throws Exception { - JPARecipientRewriteTable localVirtualUserTable = new JPARecipientRewriteTable(); - localVirtualUserTable.setEntityManagerFactory(JPA_TEST_CLUSTER.getEntityManagerFactory()); - localVirtualUserTable.setUsersRepository(new PostgresUsersRepository(RecipientRewriteTableFixture.domainListForCucumberTests(), mock(PostgresUsersDAO.class))); - localVirtualUserTable.setDomainList(RecipientRewriteTableFixture.domainListForCucumberTests()); - return localVirtualUserTable; + private AbstractRecipientRewriteTable getRecipientRewriteTable() throws DomainListException { + PostgresRecipientRewriteTable postgresRecipientRewriteTable = new PostgresRecipientRewriteTable(new PostgresRecipientRewriteTableDAO(postgresExtension.getPostgresExecutor())); + postgresRecipientRewriteTable.setUsersRepository(new PostgresUsersRepository(RecipientRewriteTableFixture.domainListForCucumberTests(), + new PostgresUsersDAO(postgresExtension.getPostgresExecutor(), PostgresUsersRepositoryConfiguration.DEFAULT))); + postgresRecipientRewriteTable.setDomainList(RecipientRewriteTableFixture.domainListForCucumberTests()); + return postgresRecipientRewriteTable; } } diff --git a/server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/RewriteTablesTest.java b/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/RewriteTablesTest.java similarity index 96% rename from server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/RewriteTablesTest.java rename to server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/RewriteTablesTest.java index 7cb0a007f01..4d0077187cc 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/rrt/jpa/RewriteTablesTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/RewriteTablesTest.java @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * * under the License. * ****************************************************************/ -package org.apache.james.rrt.jpa; +package org.apache.james.rrt.postgres; import org.junit.runner.RunWith; @@ -26,7 +26,7 @@ @RunWith(Cucumber.class) @CucumberOptions( features = { "classpath:cucumber/" }, - glue = { "org.apache.james.rrt.lib", "org.apache.james.rrt.jpa" } + glue = { "org.apache.james.rrt.lib", "org.apache.james.rrt.postgres" } ) public class RewriteTablesTest { } diff --git a/server/data/data-postgres/src/test/resources/persistence.xml b/server/data/data-postgres/src/test/resources/persistence.xml index 4a6b7c3c5b4..6ac35df9a45 100644 --- a/server/data/data-postgres/src/test/resources/persistence.xml +++ b/server/data/data-postgres/src/test/resources/persistence.xml @@ -26,7 +26,6 @@ org.apache.openjpa.persistence.PersistenceProviderImpl osgi:service/javax.sql.DataSource/(osgi.jndi.service.name=jdbc/james) - org.apache.james.rrt.jpa.model.JPARecipientRewrite org.apache.james.mailrepository.jpa.model.JPAUrl org.apache.james.mailrepository.jpa.model.JPAMail org.apache.james.sieve.postgres.model.JPASieveScript From 57bde18bdcb9e1de47596c363959474b57122bd2 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 11 Dec 2023 11:38:17 +0700 Subject: [PATCH 120/341] JAMES-2586 Fixup compile error after merge master --- .../backends/cassandra/quota/CassandraQuotaLimitDaoTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backends-common/cassandra/src/test/java/org/apache/james/backends/cassandra/quota/CassandraQuotaLimitDaoTest.java b/backends-common/cassandra/src/test/java/org/apache/james/backends/cassandra/quota/CassandraQuotaLimitDaoTest.java index 92127c412a6..7fa6f47a462 100644 --- a/backends-common/cassandra/src/test/java/org/apache/james/backends/cassandra/quota/CassandraQuotaLimitDaoTest.java +++ b/backends-common/cassandra/src/test/java/org/apache/james/backends/cassandra/quota/CassandraQuotaLimitDaoTest.java @@ -70,7 +70,7 @@ void setQuotaLimitWithEmptyQuotaLimitValueShouldNotThrowNullPointerException() { QuotaLimit emptyQuotaLimitValue = QuotaLimit.builder().quotaComponent(QuotaComponent.MAILBOX).quotaScope(QuotaScope.DOMAIN).identifier("A").quotaType(QuotaType.COUNT).build(); cassandraQuotaLimitDao.setQuotaLimit(emptyQuotaLimitValue).block(); - assertThat(cassandraQuotaLimitDao.getQuotaLimit(CassandraQuotaLimitDao.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.DOMAIN, "A", QuotaType.COUNT)).block()) + assertThat(cassandraQuotaLimitDao.getQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.DOMAIN, "A", QuotaType.COUNT)).block()) .isEqualTo(emptyQuotaLimitValue); } From ebeeb1dca98cda09b99a855c82f9d11a1c958089 Mon Sep 17 00:00:00 2001 From: hungphan227 <45198168+hungphan227@users.noreply.github.com> Date: Wed, 13 Dec 2023 16:24:11 +0700 Subject: [PATCH 121/341] JAMES-2586 PostgresDelegationStore (#1851) --- .../apache/james/PostgresJamesServerMain.java | 3 +- .../data/PostgresDelegationStoreModule.java | 62 ++++++++++++ .../data/PostgresUsersRepositoryModule.java | 22 ----- .../postgres/PostgresDelegationStore.java | 89 +++++++++++++++++ .../user/postgres/PostgresUserModule.java | 8 +- .../james/user/postgres/PostgresUsersDAO.java | 98 +++++++++++++++++++ .../postgres/PostgresDelegationStoreTest.java | 67 +++++++++++++ 7 files changed, 324 insertions(+), 25 deletions(-) create mode 100644 server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDelegationStoreModule.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresDelegationStore.java create mode 100644 server/data/data-postgres/src/test/java/org/apache/james/user/postgres/PostgresDelegationStoreTest.java diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index 1191382350f..24cfa7d1cd3 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -24,6 +24,7 @@ import org.apache.james.modules.MailetProcessingModule; import org.apache.james.modules.RunArgumentsModule; import org.apache.james.modules.data.PostgresDataModule; +import org.apache.james.modules.data.PostgresDelegationStoreModule; import org.apache.james.modules.data.PostgresUsersRepositoryModule; import org.apache.james.modules.data.SievePostgresRepositoryModules; import org.apache.james.modules.mailbox.DefaultEventModule; @@ -79,7 +80,7 @@ public class PostgresJamesServerMain implements JamesServerMain { private static final Module POSTGRES_SERVER_MODULE = Modules.combine( new ActiveMQQueueModule(), - new NaiveDelegationStoreModule(), + new PostgresDelegationStoreModule(), new DefaultProcessorsConfigurationProviderModule(), new PostgresMailboxModule(), new PostgresDataModule(), diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDelegationStoreModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDelegationStoreModule.java new file mode 100644 index 00000000000..f6e5521ead7 --- /dev/null +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDelegationStoreModule.java @@ -0,0 +1,62 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.data; + +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.server.core.configuration.ConfigurationProvider; +import org.apache.james.user.api.DelegationStore; +import org.apache.james.user.api.DelegationUsernameChangeTaskStep; +import org.apache.james.user.api.UsernameChangeTaskStep; +import org.apache.james.user.lib.UsersDAO; +import org.apache.james.user.postgres.PostgresDelegationStore; +import org.apache.james.user.postgres.PostgresUserModule; +import org.apache.james.user.postgres.PostgresUsersDAO; +import org.apache.james.user.postgres.PostgresUsersRepositoryConfiguration; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import com.google.inject.Scopes; +import com.google.inject.Singleton; +import com.google.inject.multibindings.Multibinder; + +public class PostgresDelegationStoreModule extends AbstractModule { + @Override + public void configure() { + bind(DelegationStore.class).to(PostgresDelegationStore.class); + bind(PostgresDelegationStore.UserExistencePredicate.class).to(PostgresDelegationStore.UserExistencePredicateImplementation.class); + + Multibinder.newSetBinder(binder(), UsernameChangeTaskStep.class) + .addBinding().to(DelegationUsernameChangeTaskStep.class); + + bind(PostgresUsersDAO.class).in(Scopes.SINGLETON); + bind(UsersDAO.class).to(PostgresUsersDAO.class); + + Multibinder postgresDataDefinitions = Multibinder.newSetBinder(binder(), PostgresModule.class); + postgresDataDefinitions.addBinding().toInstance(PostgresUserModule.MODULE); + } + + @Provides + @Singleton + public PostgresUsersRepositoryConfiguration provideConfiguration(ConfigurationProvider configurationProvider) throws ConfigurationException { + return PostgresUsersRepositoryConfiguration.from( + configurationProvider.getConfiguration("usersrepository")); + } +} diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresUsersRepositoryModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresUsersRepositoryModule.java index 575f7621f0d..ff30223bb8c 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresUsersRepositoryModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresUsersRepositoryModule.java @@ -19,23 +19,14 @@ package org.apache.james.modules.data; -import org.apache.commons.configuration2.ex.ConfigurationException; -import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.server.core.configuration.ConfigurationProvider; import org.apache.james.user.api.UsersRepository; -import org.apache.james.user.lib.UsersDAO; -import org.apache.james.user.postgres.PostgresUserModule; -import org.apache.james.user.postgres.PostgresUsersDAO; import org.apache.james.user.postgres.PostgresUsersRepository; -import org.apache.james.user.postgres.PostgresUsersRepositoryConfiguration; import org.apache.james.utils.InitializationOperation; import org.apache.james.utils.InitilizationOperationBuilder; import com.google.inject.AbstractModule; -import com.google.inject.Provides; import com.google.inject.Scopes; -import com.google.inject.Singleton; -import com.google.inject.multibindings.Multibinder; import com.google.inject.multibindings.ProvidesIntoSet; public class PostgresUsersRepositoryModule extends AbstractModule { @@ -43,19 +34,6 @@ public class PostgresUsersRepositoryModule extends AbstractModule { public void configure() { bind(PostgresUsersRepository.class).in(Scopes.SINGLETON); bind(UsersRepository.class).to(PostgresUsersRepository.class); - - bind(PostgresUsersDAO.class).in(Scopes.SINGLETON); - bind(UsersDAO.class).to(PostgresUsersDAO.class); - - Multibinder postgresDataDefinitions = Multibinder.newSetBinder(binder(), PostgresModule.class); - postgresDataDefinitions.addBinding().toInstance(PostgresUserModule.MODULE); - } - - @Provides - @Singleton - public PostgresUsersRepositoryConfiguration provideConfiguration(ConfigurationProvider configurationProvider) throws ConfigurationException { - return PostgresUsersRepositoryConfiguration.from( - configurationProvider.getConfiguration("usersrepository")); } @ProvidesIntoSet diff --git a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresDelegationStore.java b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresDelegationStore.java new file mode 100644 index 00000000000..4f04f450752 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresDelegationStore.java @@ -0,0 +1,89 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.user.postgres; + +import javax.inject.Inject; + +import org.apache.james.core.Username; +import org.apache.james.user.api.DelegationStore; +import org.apache.james.user.api.UsersRepository; +import org.reactivestreams.Publisher; + +import reactor.core.publisher.Mono; + +public class PostgresDelegationStore implements DelegationStore { + public interface UserExistencePredicate { + Mono exists(Username username); + } + + public static class UserExistencePredicateImplementation implements UserExistencePredicate { + private final UsersRepository usersRepository; + + @Inject + UserExistencePredicateImplementation(UsersRepository usersRepository) { + this.usersRepository = usersRepository; + } + + @Override + public Mono exists(Username username) { + return Mono.from(usersRepository.containsReactive(username)); + } + } + + private PostgresUsersDAO postgresUsersDAO; + private final UserExistencePredicate userExistencePredicate; + + @Inject + public PostgresDelegationStore(PostgresUsersDAO postgresUsersDAO, UserExistencePredicate userExistencePredicate) { + this.postgresUsersDAO = postgresUsersDAO; + this.userExistencePredicate = userExistencePredicate; + } + + @Override + public Publisher authorizedUsers(Username baseUser) { + return postgresUsersDAO.getAuthorizedUsers(baseUser); + } + + @Override + public Publisher clear(Username baseUser) { + return postgresUsersDAO.removeAllAuthorizedUsers(baseUser); + } + + @Override + public Publisher addAuthorizedUser(Username baseUser, Username userWithAccess) { + return userExistencePredicate.exists(userWithAccess) + .flatMap(targetUserExists -> postgresUsersDAO.addAuthorizedUser(baseUser, userWithAccess, targetUserExists)); + } + + @Override + public Publisher removeAuthorizedUser(Username baseUser, Username userWithAccess) { + return postgresUsersDAO.removeAuthorizedUser(baseUser, userWithAccess); + } + + @Override + public Publisher delegatedUsers(Username baseUser) { + return postgresUsersDAO.getDelegatedToUsers(baseUser); + } + + @Override + public Publisher removeDelegatedUser(Username baseUser, Username delegatedToUser) { + return postgresUsersDAO.removeDelegatedToUser(baseUser, delegatedToUser); + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUserModule.java b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUserModule.java index 6aae9183f82..e5bc618d31d 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUserModule.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUserModule.java @@ -32,14 +32,18 @@ interface PostgresUserTable { Table TABLE_NAME = DSL.table("users"); Field USERNAME = DSL.field("username", SQLDataType.VARCHAR(255).notNull()); - Field HASHED_PASSWORD = DSL.field("hashed_password", SQLDataType.VARCHAR.notNull()); - Field ALGORITHM = DSL.field("algorithm", SQLDataType.VARCHAR(100).notNull()); + Field HASHED_PASSWORD = DSL.field("hashed_password", SQLDataType.VARCHAR); + Field ALGORITHM = DSL.field("algorithm", SQLDataType.VARCHAR(100)); + Field AUTHORIZED_USERS = DSL.field("authorized_users", SQLDataType.VARCHAR.getArrayDataType()); + Field DELEGATED_USERS = DSL.field("delegated_users", SQLDataType.VARCHAR.getArrayDataType()); PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) .column(USERNAME) .column(HASHED_PASSWORD) .column(ALGORITHM) + .column(AUTHORIZED_USERS) + .column(DELEGATED_USERS) .constraint(DSL.primaryKey(USERNAME)))) .disableRowLevelSecurity(); } diff --git a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java index d8447e527fb..d0467bf847f 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java @@ -22,7 +22,10 @@ import static org.apache.james.backends.postgres.utils.PostgresExecutor.DEFAULT_INJECT; import static org.apache.james.backends.postgres.utils.PostgresUtils.UNIQUE_CONSTRAINT_VIOLATION_PREDICATE; import static org.apache.james.user.postgres.PostgresUserModule.PostgresUserTable.ALGORITHM; +import static org.apache.james.user.postgres.PostgresUserModule.PostgresUserTable.AUTHORIZED_USERS; +import static org.apache.james.user.postgres.PostgresUserModule.PostgresUserTable.DELEGATED_USERS; import static org.apache.james.user.postgres.PostgresUserModule.PostgresUserTable.HASHED_PASSWORD; +import static org.apache.james.user.postgres.PostgresUserModule.PostgresUserTable.TABLE; import static org.apache.james.user.postgres.PostgresUserModule.PostgresUserTable.TABLE_NAME; import static org.apache.james.user.postgres.PostgresUserModule.PostgresUserTable.USERNAME; import static org.jooq.impl.DSL.count; @@ -41,8 +44,14 @@ import org.apache.james.user.lib.UsersDAO; import org.apache.james.user.lib.model.Algorithm; import org.apache.james.user.lib.model.DefaultUser; +import org.jooq.DSLContext; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.UpdateConditionStep; +import org.jooq.impl.DSL; import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -141,4 +150,93 @@ public void addUser(Username username, String password) { e -> new AlreadyExistInUsersRepositoryException("User with username " + username + " already exist!")) .block(); } + + public Mono addAuthorizedUser(Username baseUser, Username userWithAccess, boolean targetUserExists) { + return addUserToList(AUTHORIZED_USERS, baseUser, userWithAccess) + .then(addDelegatedUser(baseUser, userWithAccess, targetUserExists)); + } + + private Mono addDelegatedUser(Username baseUser, Username userWithAccess, boolean targetUserExists) { + if (targetUserExists) { + return addUserToList(DELEGATED_USERS, userWithAccess, baseUser); + } else { + return Mono.empty(); + } + } + + private Mono addUserToList(Field field, Username baseUser, Username targetUser) { + String fullAuthorizedUsersColumnName = TABLE.getName() + "." + field.getName(); + return postgresExecutor.executeVoid(dslContext -> + Mono.from(dslContext.insertInto(TABLE_NAME) + .set(USERNAME, baseUser.asString()) + .set(field, DSL.array(targetUser.asString())) + .onConflict(USERNAME) + .doUpdate() + .set(DSL.field(field.getName()), + (Object) DSL.field("array_append(coalesce(" + fullAuthorizedUsersColumnName + ", array[]::varchar[]), ?)", + targetUser.asString())) + .where(DSL.field(fullAuthorizedUsersColumnName).isNull() + .or(DSL.field(fullAuthorizedUsersColumnName).notContains(new String[]{targetUser.asString()}))))); + } + + public Mono removeAuthorizedUser(Username baseUser, Username userWithAccess) { + return removeUserInAuthorizedList(baseUser, userWithAccess) + .then(removeUserInDelegatedList(userWithAccess, baseUser)); + } + + public Mono removeDelegatedToUser(Username baseUser, Username delegatedToUser) { + return removeUserInDelegatedList(baseUser, delegatedToUser) + .then(removeUserInAuthorizedList(delegatedToUser, baseUser)); + } + + private Mono removeUserInAuthorizedList(Username baseUser, Username targetUser) { + return removeUserFromList(AUTHORIZED_USERS, baseUser, targetUser); + } + + private Mono removeUserInDelegatedList(Username baseUser, Username targetUser) { + return removeUserFromList(DELEGATED_USERS, baseUser, targetUser); + } + + private Mono removeUserFromList(Field field, Username baseUser, Username targetUser) { + return postgresExecutor.executeVoid(dslContext -> + Mono.from(createQueryRemoveUserFromList(dslContext, field, baseUser, targetUser))); + } + + private UpdateConditionStep createQueryRemoveUserFromList(DSLContext dslContext, Field field, Username baseUser, Username targetUser) { + return dslContext.update(TABLE_NAME) + .set(DSL.field(field.getName()), + (Object) DSL.field("array_remove(" + field.getName() + ", ?)", + targetUser.asString())) + .where(USERNAME.eq(baseUser.asString())) + .and(DSL.field(field.getName()).isNotNull()); + } + + public Mono removeAllAuthorizedUsers(Username baseUser) { + return getAuthorizedUsers(baseUser) + .collect(ImmutableList.toImmutableList()) + .flatMap(usernames -> postgresExecutor.executeVoid(dslContext -> + Mono.from(dslContext.batch(usernames.stream() + .map(username -> createQueryRemoveUserFromList(dslContext, DELEGATED_USERS, username, baseUser)) + .collect(ImmutableList.toImmutableList()))))) + .then(postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.update(TABLE_NAME) + .setNull(AUTHORIZED_USERS) + .where(USERNAME.eq(baseUser.asString()))))); + } + + public Flux getAuthorizedUsers(Username name) { + return getUsersFromList(AUTHORIZED_USERS, name); + } + + public Flux getDelegatedToUsers(Username name) { + return getUsersFromList(DELEGATED_USERS, name); + } + + public Flux getUsersFromList(Field field, Username name) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select(field) + .from(TABLE_NAME) + .where(USERNAME.eq(name.asString())))) + .flatMapMany(record -> Optional.ofNullable(record.get(field)) + .map(Flux::fromArray).orElse(Flux.empty())) + .map(Username::of); + } } diff --git a/server/data/data-postgres/src/test/java/org/apache/james/user/postgres/PostgresDelegationStoreTest.java b/server/data/data-postgres/src/test/java/org/apache/james/user/postgres/PostgresDelegationStoreTest.java new file mode 100644 index 00000000000..cae65185a65 --- /dev/null +++ b/server/data/data-postgres/src/test/java/org/apache/james/user/postgres/PostgresDelegationStoreTest.java @@ -0,0 +1,67 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.user.postgres; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.core.Username; +import org.apache.james.user.api.DelegationStore; +import org.apache.james.user.api.DelegationStoreContract; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import reactor.core.publisher.Mono; + +public class PostgresDelegationStoreTest implements DelegationStoreContract { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresUserModule.MODULE); + + private PostgresUsersDAO postgresUsersDAO; + private PostgresDelegationStore postgresDelegationStore; + + @BeforeEach + void beforeEach() { + postgresUsersDAO = new PostgresUsersDAO(postgresExtension.getPostgresExecutor(), PostgresUsersRepositoryConfiguration.DEFAULT); + postgresDelegationStore = new PostgresDelegationStore(postgresUsersDAO, any -> Mono.just(true)); + } + + @Override + public DelegationStore testee() { + return postgresDelegationStore; + } + + @Override + public void addUser(Username username) { + postgresUsersDAO.addUser(username, "password"); + } + + @Test + void virtualUsersShouldNotBeListed() { + postgresDelegationStore = new PostgresDelegationStore(postgresUsersDAO, any -> Mono.just(false)); + addUser(BOB); + + Mono.from(testee().addAuthorizedUser(ALICE).forUser(BOB)).block(); + + assertThat(postgresUsersDAO.listReactive().collectList().block()) + .containsOnly(BOB); + } +} From ef878eacbd4231eae6eb988377d7a62ace3397be Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Tue, 12 Dec 2023 11:22:18 +0100 Subject: [PATCH 122/341] JAMES-2586 Remove JPAHealthCheck.java --- .../james/jpa/healthcheck/JPAHealthCheck.java | 64 ------------------- .../jpa/healthcheck/JPAHealthCheckTest.java | 62 ------------------ 2 files changed, 126 deletions(-) delete mode 100644 server/data/data-postgres/src/main/java/org/apache/james/jpa/healthcheck/JPAHealthCheck.java delete mode 100644 server/data/data-postgres/src/test/java/org/apache/james/jpa/healthcheck/JPAHealthCheckTest.java diff --git a/server/data/data-postgres/src/main/java/org/apache/james/jpa/healthcheck/JPAHealthCheck.java b/server/data/data-postgres/src/main/java/org/apache/james/jpa/healthcheck/JPAHealthCheck.java deleted file mode 100644 index 7dbea33e7f3..00000000000 --- a/server/data/data-postgres/src/main/java/org/apache/james/jpa/healthcheck/JPAHealthCheck.java +++ /dev/null @@ -1,64 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.jpa.healthcheck; - -import static org.apache.james.core.healthcheck.Result.healthy; -import static org.apache.james.core.healthcheck.Result.unhealthy; - -import javax.inject.Inject; -import javax.persistence.EntityManagerFactory; - -import org.apache.james.backends.jpa.EntityManagerUtils; -import org.apache.james.core.healthcheck.ComponentName; -import org.apache.james.core.healthcheck.HealthCheck; -import org.apache.james.core.healthcheck.Result; - -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; - -public class JPAHealthCheck implements HealthCheck { - - private final EntityManagerFactory entityManagerFactory; - - @Inject - public JPAHealthCheck(EntityManagerFactory entityManagerFactory) { - this.entityManagerFactory = entityManagerFactory; - } - - @Override - public ComponentName componentName() { - return new ComponentName("JPA Backend"); - } - - @Override - public Mono check() { - return Mono.usingWhen(Mono.fromCallable(entityManagerFactory::createEntityManager).subscribeOn(Schedulers.boundedElastic()), - entityManager -> { - if (entityManager.isOpen()) { - return Mono.just(healthy(componentName())); - } else { - return Mono.just(unhealthy(componentName(), "entityManager is not open")); - } - }, - entityManager -> Mono.fromRunnable(() -> EntityManagerUtils.safelyClose(entityManager)).subscribeOn(Schedulers.boundedElastic())) - .onErrorResume(IllegalStateException.class, - e -> Mono.just(unhealthy(componentName(), "EntityManagerFactory or EntityManager thrown an IllegalStateException, the connection is unhealthy", e))) - .onErrorResume(e -> Mono.just(unhealthy(componentName(), "Unexpected exception upon checking JPA driver", e))); - } -} diff --git a/server/data/data-postgres/src/test/java/org/apache/james/jpa/healthcheck/JPAHealthCheckTest.java b/server/data/data-postgres/src/test/java/org/apache/james/jpa/healthcheck/JPAHealthCheckTest.java deleted file mode 100644 index 20ed1bbaa22..00000000000 --- a/server/data/data-postgres/src/test/java/org/apache/james/jpa/healthcheck/JPAHealthCheckTest.java +++ /dev/null @@ -1,62 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.jpa.healthcheck; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.fail; - -import org.apache.james.backends.jpa.JpaTestCluster; -import org.apache.james.core.healthcheck.Result; -import org.apache.james.core.healthcheck.ResultStatus; -import org.apache.james.mailrepository.jpa.model.JPAUrl; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -class JPAHealthCheckTest { - JPAHealthCheck jpaHealthCheck; - JpaTestCluster jpaTestCluster; - - @BeforeEach - void setUp() { - jpaTestCluster = JpaTestCluster.create(JPAUrl.class); - jpaHealthCheck = new JPAHealthCheck(jpaTestCluster.getEntityManagerFactory()); - } - - @Test - void testWhenActive() { - Result result = jpaHealthCheck.check().block(); - ResultStatus healthy = ResultStatus.HEALTHY; - assertThat(result.getStatus()).as("Result %s status should be %s", result.getStatus(), healthy) - .isEqualTo(healthy); - } - - @Test - void testWhenInactive() { - jpaTestCluster.getEntityManagerFactory().close(); - Result result = Result.healthy(jpaHealthCheck.componentName()); - try { - result = jpaHealthCheck.check().block(); - } catch (IllegalStateException e) { - fail("The exception of the EMF was not handled property.ª"); - } - ResultStatus unhealthy = ResultStatus.UNHEALTHY; - assertThat(result.getStatus()).as("Result %s status should be %s", result.getStatus(), unhealthy) - .isEqualTo(unhealthy); - } -} From b685cd48c0b43cf8452af7fffbe8857628def86b Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Tue, 12 Dec 2023 11:22:37 +0100 Subject: [PATCH 123/341] JAMES-2586 Implement PostgresMailRepositoryUrlStore --- .../modules/data/PostgresDataModule.java | 2 +- ...java => PostgresMailRepositoryModule.java} | 14 ++-- .../PostgresMailRepositoryModule.java | 46 +++++++++++++ .../PostgresMailRepositoryUrlStore.java | 66 +++++++++++++++++++ ...tgresMailRepositoryUrlStoreExtension.java} | 36 ++++++++-- .../PostgresMailRepositoryUrlStoreTest.java} | 6 +- 6 files changed, 153 insertions(+), 17 deletions(-) rename server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/{JPAMailRepositoryModule.java => PostgresMailRepositoryModule.java} (79%) create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryModule.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryUrlStore.java rename server/data/data-postgres/src/test/java/org/apache/james/mailrepository/{jpa/JPAMailRepositoryUrlStoreExtension.java => postgres/PostgresMailRepositoryUrlStoreExtension.java} (60%) rename server/data/data-postgres/src/test/java/org/apache/james/mailrepository/{jpa/JPAMailRepositoryUrlStoreTest.java => postgres/PostgresMailRepositoryUrlStoreTest.java} (86%) diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataModule.java index 905f3462496..e6860792b62 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataModule.java @@ -29,6 +29,6 @@ protected void configure() { install(new CoreDataModule()); install(new PostgresDomainListModule()); install(new PostgresRecipientRewriteTableModule()); - install(new JPAMailRepositoryModule()); + install(new PostgresMailRepositoryModule()); } } diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAMailRepositoryModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresMailRepositoryModule.java similarity index 79% rename from server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAMailRepositoryModule.java rename to server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresMailRepositoryModule.java index bb6a0ffedb7..b0c33698153 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAMailRepositoryModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresMailRepositoryModule.java @@ -20,26 +20,26 @@ package org.apache.james.modules.data; import org.apache.commons.configuration2.BaseHierarchicalConfiguration; +import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.mailrepository.api.MailRepositoryFactory; import org.apache.james.mailrepository.api.MailRepositoryUrlStore; import org.apache.james.mailrepository.api.Protocol; import org.apache.james.mailrepository.jpa.JPAMailRepository; import org.apache.james.mailrepository.jpa.JPAMailRepositoryFactory; -import org.apache.james.mailrepository.jpa.JPAMailRepositoryUrlStore; import org.apache.james.mailrepository.memory.MailRepositoryStoreConfiguration; +import org.apache.james.mailrepository.postgres.PostgresMailRepositoryUrlStore; import com.google.common.collect.ImmutableList; import com.google.inject.AbstractModule; import com.google.inject.Scopes; import com.google.inject.multibindings.Multibinder; -public class JPAMailRepositoryModule extends AbstractModule { - +public class PostgresMailRepositoryModule extends AbstractModule { @Override protected void configure() { - bind(JPAMailRepositoryUrlStore.class).in(Scopes.SINGLETON); + bind(PostgresMailRepositoryUrlStore.class).in(Scopes.SINGLETON); - bind(MailRepositoryUrlStore.class).to(JPAMailRepositoryUrlStore.class); + bind(MailRepositoryUrlStore.class).to(PostgresMailRepositoryUrlStore.class); bind(MailRepositoryStoreConfiguration.Item.class) .toProvider(() -> new MailRepositoryStoreConfiguration.Item( @@ -48,6 +48,8 @@ protected void configure() { new BaseHierarchicalConfiguration())); Multibinder.newSetBinder(binder(), MailRepositoryFactory.class) - .addBinding().to(JPAMailRepositoryFactory.class); + .addBinding().to(JPAMailRepositoryFactory.class); + Multibinder.newSetBinder(binder(), PostgresModule.class) + .addBinding().toInstance(org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.MODULE); } } diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryModule.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryModule.java new file mode 100644 index 00000000000..2bfafd5284c --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryModule.java @@ -0,0 +1,46 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailrepository.postgres; + +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresMailRepositoryModule { + interface PostgresMailRepositoryUrlTable { + Table TABLE_NAME = DSL.table("mail_repository_url"); + + Field URL = DSL.field("url", SQLDataType.VARCHAR(255).notNull()); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(URL) + .primaryKey(URL))) + .disableRowLevelSecurity(); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(PostgresMailRepositoryUrlTable.TABLE) + .build(); +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryUrlStore.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryUrlStore.java new file mode 100644 index 00000000000..c032db14a19 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryUrlStore.java @@ -0,0 +1,66 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailrepository.postgres; + +import static org.apache.james.backends.postgres.utils.PostgresExecutor.DEFAULT_INJECT; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryUrlTable.TABLE_NAME; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryUrlTable.URL; + +import java.util.stream.Stream; + +import javax.inject.Inject; +import javax.inject.Named; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.backends.postgres.utils.PostgresUtils; +import org.apache.james.mailrepository.api.MailRepositoryUrl; +import org.apache.james.mailrepository.api.MailRepositoryUrlStore; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresMailRepositoryUrlStore implements MailRepositoryUrlStore { + private final PostgresExecutor postgresExecutor; + + @Inject + public PostgresMailRepositoryUrlStore(@Named(DEFAULT_INJECT) PostgresExecutor postgresExecutor) { + this.postgresExecutor = postgresExecutor; + } + + @Override + public void add(MailRepositoryUrl url) { + postgresExecutor.executeVoid(context -> Mono.from(context.insertInto(TABLE_NAME, URL) + .values(url.asString()))) + .onErrorResume(PostgresUtils.UNIQUE_CONSTRAINT_VIOLATION_PREDICATE, e -> Mono.empty()) + .block(); + } + + @Override + public Stream listDistinct() { + return postgresExecutor.executeRows(context -> Flux.from(context.selectFrom(TABLE_NAME))) + .map(record -> MailRepositoryUrl.from(record.get(URL))) + .toStream(); + } + + @Override + public boolean contains(MailRepositoryUrl url) { + return listDistinct().anyMatch(url::equals); + } +} diff --git a/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryUrlStoreExtension.java b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryUrlStoreExtension.java similarity index 60% rename from server/data/data-postgres/src/test/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryUrlStoreExtension.java rename to server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryUrlStoreExtension.java index c8af2008d1a..5f56caf099b 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryUrlStoreExtension.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryUrlStoreExtension.java @@ -17,23 +17,45 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailrepository.jpa; +package org.apache.james.mailrepository.postgres; -import org.apache.james.backends.jpa.JpaTestCluster; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.mailrepository.api.MailRepositoryUrlStore; -import org.apache.james.mailrepository.jpa.model.JPAUrl; +import org.junit.jupiter.api.extension.AfterAllCallback; import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.ParameterContext; import org.junit.jupiter.api.extension.ParameterResolutionException; import org.junit.jupiter.api.extension.ParameterResolver; -public class JPAMailRepositoryUrlStoreExtension implements ParameterResolver, AfterEachCallback { - private static final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAUrl.class); +public class PostgresMailRepositoryUrlStoreExtension implements ParameterResolver, AfterEachCallback, AfterAllCallback, BeforeEachCallback, BeforeAllCallback { + private final PostgresExtension postgresExtension; + + public PostgresMailRepositoryUrlStoreExtension() { + postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresModule.aggregateModules(PostgresMailRepositoryModule.MODULE)); + } @Override public void afterEach(ExtensionContext context) { - JPA_TEST_CLUSTER.clear("JAMES_MAIL_REPOS"); + postgresExtension.afterEach(context); + } + + @Override + public void afterAll(ExtensionContext extensionContext) throws Exception { + postgresExtension.afterAll(extensionContext); + } + + @Override + public void beforeAll(ExtensionContext extensionContext) throws Exception { + postgresExtension.beforeAll(extensionContext); + } + + @Override + public void beforeEach(ExtensionContext extensionContext) throws Exception { + postgresExtension.beforeEach(extensionContext); } @Override @@ -43,6 +65,6 @@ public boolean supportsParameter(ParameterContext parameterContext, ExtensionCon @Override public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { - return new JPAMailRepositoryUrlStore(JPA_TEST_CLUSTER.getEntityManagerFactory()); + return new PostgresMailRepositoryUrlStore(postgresExtension.getPostgresExecutor()); } } diff --git a/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryUrlStoreTest.java b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryUrlStoreTest.java similarity index 86% rename from server/data/data-postgres/src/test/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryUrlStoreTest.java rename to server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryUrlStoreTest.java index ed8b69316a1..ea4f034aa16 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryUrlStoreTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryUrlStoreTest.java @@ -17,12 +17,12 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailrepository.jpa; +package org.apache.james.mailrepository.postgres; import org.apache.james.mailrepository.MailRepositoryUrlStoreContract; import org.junit.jupiter.api.extension.ExtendWith; -@ExtendWith(JPAMailRepositoryUrlStoreExtension.class) -public class JPAMailRepositoryUrlStoreTest implements MailRepositoryUrlStoreContract { +@ExtendWith(PostgresMailRepositoryUrlStoreExtension.class) +public class PostgresMailRepositoryUrlStoreTest implements MailRepositoryUrlStoreContract { } From cf5da8fcec27e1915b6feb17887093c3992e9386 Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Tue, 12 Dec 2023 11:22:49 +0100 Subject: [PATCH 124/341] JAMES-2586 Remove JPAMailRepositoryUrlStore.java --- .../main/resources/META-INF/persistence.xml | 1 - .../jpa/JPAMailRepositoryUrlStore.java | 65 ------------------- .../mailrepository/jpa/model/JPAUrl.java | 65 ------------------- .../src/test/resources/persistence.xml | 1 - 4 files changed, 132 deletions(-) delete mode 100644 server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryUrlStore.java delete mode 100644 server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/model/JPAUrl.java diff --git a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml index 489b1e81d78..a5837560c7d 100644 --- a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml +++ b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml @@ -24,7 +24,6 @@ version="2.0"> - org.apache.james.mailrepository.jpa.model.JPAUrl org.apache.james.mailrepository.jpa.model.JPAMail org.apache.james.sieve.postgres.model.JPASieveScript diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryUrlStore.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryUrlStore.java deleted file mode 100644 index 1f448a9eca7..00000000000 --- a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryUrlStore.java +++ /dev/null @@ -1,65 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailrepository.jpa; - -import java.util.stream.Stream; - -import javax.inject.Inject; -import javax.persistence.EntityManagerFactory; - -import org.apache.james.backends.jpa.TransactionRunner; -import org.apache.james.mailrepository.api.MailRepositoryUrl; -import org.apache.james.mailrepository.api.MailRepositoryUrlStore; -import org.apache.james.mailrepository.jpa.model.JPAUrl; - -public class JPAMailRepositoryUrlStore implements MailRepositoryUrlStore { - private final TransactionRunner transactionRunner; - - @Inject - public JPAMailRepositoryUrlStore(EntityManagerFactory entityManagerFactory) { - this.transactionRunner = new TransactionRunner(entityManagerFactory); - } - - @Override - public void add(MailRepositoryUrl url) { - transactionRunner.run(entityManager -> - entityManager.merge(JPAUrl.from(url))); - } - - @Override - public Stream listDistinct() { - return transactionRunner.runAndRetrieveResult(entityManager -> - entityManager - .createNamedQuery("listUrls", JPAUrl.class) - .getResultList() - .stream() - .map(JPAUrl::toMailRepositoryUrl)); - } - - @Override - public boolean contains(MailRepositoryUrl url) { - return transactionRunner.runAndRetrieveResult(entityManager -> - ! entityManager.createNamedQuery("getUrl", JPAUrl.class) - .setParameter("value", url.asString()) - .getResultList() - .isEmpty()); - } -} - diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/model/JPAUrl.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/model/JPAUrl.java deleted file mode 100644 index 9f8e74c69cd..00000000000 --- a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/model/JPAUrl.java +++ /dev/null @@ -1,65 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailrepository.jpa.model; - -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.Id; -import javax.persistence.NamedQueries; -import javax.persistence.NamedQuery; -import javax.persistence.Table; - -import org.apache.james.mailrepository.api.MailRepositoryUrl; - -@Entity(name = "JamesMailRepos") -@Table(name = "JAMES_MAIL_REPOS") -@NamedQueries({ - @NamedQuery(name = "listUrls", query = "SELECT url FROM JamesMailRepos url"), - @NamedQuery(name = "getUrl", query = "SELECT url FROM JamesMailRepos url WHERE url.value=:value")}) -public class JPAUrl { - public static JPAUrl from(MailRepositoryUrl url) { - return new JPAUrl(url.asString()); - } - - @Id - @Column(name = "MAIL_REPO_NAME", nullable = false) - private String value; - - /** - * Default no-args constructor for JPA class enhancement. - * The constructor need to be public or protected to be used by JPA. - * See: http://docs.oracle.com/javaee/6/tutorial/doc/bnbqa.html - * Do not us this constructor, it is for JPA only. - */ - protected JPAUrl() { - } - - public JPAUrl(String value) { - this.value = value; - } - - public String getValue() { - return value; - } - - public MailRepositoryUrl toMailRepositoryUrl() { - return MailRepositoryUrl.from(value); - } -} diff --git a/server/data/data-postgres/src/test/resources/persistence.xml b/server/data/data-postgres/src/test/resources/persistence.xml index 6ac35df9a45..4ba44478005 100644 --- a/server/data/data-postgres/src/test/resources/persistence.xml +++ b/server/data/data-postgres/src/test/resources/persistence.xml @@ -26,7 +26,6 @@ org.apache.openjpa.persistence.PersistenceProviderImpl osgi:service/javax.sql.DataSource/(osgi.jndi.service.name=jdbc/james) - org.apache.james.mailrepository.jpa.model.JPAUrl org.apache.james.mailrepository.jpa.model.JPAMail org.apache.james.sieve.postgres.model.JPASieveScript true From 0a677e630203bbcf3c51d92aefa1f42d317cd9f8 Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Tue, 12 Dec 2023 11:53:37 +0100 Subject: [PATCH 125/341] JAMES-2586 Implement and bind PostgresHealthCheck --- .../postgres/utils/PostgresHealthCheck.java | 55 ++++++++++++++++ .../backends/postgres/PostgresExtension.java | 12 ++++ .../utils/PostgresHealthCheckTest.java | 66 +++++++++++++++++++ .../modules/data/PostgresCommonModule.java | 5 ++ 4 files changed, 138 insertions(+) create mode 100644 backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresHealthCheck.java create mode 100644 backends-common/postgres/src/test/java/org/apache/james/backends/postgres/utils/PostgresHealthCheckTest.java diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresHealthCheck.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresHealthCheck.java new file mode 100644 index 00000000000..40262bd88ee --- /dev/null +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresHealthCheck.java @@ -0,0 +1,55 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres.utils; + +import java.time.Duration; + +import javax.inject.Inject; + +import org.apache.james.core.healthcheck.ComponentName; +import org.apache.james.core.healthcheck.HealthCheck; +import org.apache.james.core.healthcheck.Result; +import org.jooq.impl.DSL; +import org.reactivestreams.Publisher; + +import reactor.core.publisher.Mono; + +public class PostgresHealthCheck implements HealthCheck { + public static final ComponentName COMPONENT_NAME = new ComponentName("Postgres"); + private final PostgresExecutor postgresExecutor; + + @Inject + public PostgresHealthCheck(PostgresExecutor postgresExecutor) { + this.postgresExecutor = postgresExecutor; + } + + @Override + public ComponentName componentName() { + return COMPONENT_NAME; + } + + @Override + public Publisher check() { + return postgresExecutor.executeRow(context -> Mono.from(context.select(DSL.now()))) + .timeout(Duration.ofSeconds(5)) + .map(any -> Result.healthy(COMPONENT_NAME)) + .onErrorResume(e -> Mono.just(Result.unhealthy(COMPONENT_NAME, "Failed to execute request against postgres", e))); + } +} diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index 126cc722b19..e332b9474d3 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -35,6 +35,8 @@ import org.junit.jupiter.api.extension.ExtensionContext; import org.testcontainers.containers.PostgreSQLContainer; +import com.github.dockerjava.api.command.PauseContainerCmd; +import com.github.dockerjava.api.command.UnpauseContainerCmd; import com.github.fge.lambdas.Throwing; import com.google.inject.Module; import com.google.inject.util.Modules; @@ -69,6 +71,16 @@ public static PostgresExtension empty() { private PostgresqlConnectionFactory connectionFactory; private PostgresExecutor.Factory executorFactory; + public void pause() { + PG_CONTAINER.getDockerClient().pauseContainerCmd(PG_CONTAINER.getContainerId()) + .exec(); + } + + public void unpause() { + PG_CONTAINER.getDockerClient().unpauseContainerCmd(PG_CONTAINER.getContainerId()) + .exec(); + } + private PostgresExtension(PostgresModule postgresModule, boolean rlsEnabled) { this.postgresModule = postgresModule; this.rlsEnabled = rlsEnabled; diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/utils/PostgresHealthCheckTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/utils/PostgresHealthCheckTest.java new file mode 100644 index 00000000000..e380920b5fa --- /dev/null +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/utils/PostgresHealthCheckTest.java @@ -0,0 +1,66 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres.utils; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.quota.PostgresQuotaLimitDAO; +import org.apache.james.backends.postgres.quota.PostgresQuotaModule; +import org.apache.james.core.healthcheck.Result; +import org.apache.james.core.healthcheck.ResultStatus; +import org.apache.james.core.quota.QuotaComponent; +import org.apache.james.core.quota.QuotaLimit; +import org.apache.james.core.quota.QuotaScope; +import org.apache.james.core.quota.QuotaType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import reactor.core.publisher.Mono; + +public class PostgresHealthCheckTest { + private PostgresHealthCheck testee; + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresQuotaModule.MODULE); + + @BeforeEach + void setup() { + testee = new PostgresHealthCheck(postgresExtension.getPostgresExecutor()); + } + + @Test + void shouldBeHealthy() { + Result result = Mono.from(testee.check()).block(); + assertThat(result.getStatus()).isEqualTo(ResultStatus.HEALTHY); + } + + @Test + void shouldBeUnhealthyWhenPaused() { + try { + postgresExtension.pause(); + Result result = Mono.from(testee.check()).block(); + assertThat(result.getStatus()).isEqualTo(ResultStatus.UNHEALTHY); + } finally { + postgresExtension.unpause(); + } + } +} \ No newline at end of file diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index 82366095ae0..53e98144d18 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -30,7 +30,9 @@ import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.backends.postgres.utils.PostgresHealthCheck; import org.apache.james.backends.postgres.utils.SinglePostgresConnectionFactory; +import org.apache.james.core.healthcheck.HealthCheck; import org.apache.james.utils.PropertiesProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -56,6 +58,9 @@ public void configure() { bind(PostgresExecutor.Factory.class).in(Scopes.SINGLETON); bind(PostgresExecutor.class).toProvider(PostgresTableManager.class); + + Multibinder.newSetBinder(binder(), HealthCheck.class) + .addBinding().to(PostgresHealthCheck.class); } @Provides From 932ebc8479206a7225955b871363c2c8ac9cc073 Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Fri, 15 Dec 2023 12:06:05 +0100 Subject: [PATCH 126/341] JAMES-3967 Store mails when relay is exceeded This prevents data loss. --- .../postgres-app/sample-configuration/mailetcontainer.xml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/apps/postgres-app/sample-configuration/mailetcontainer.xml b/server/apps/postgres-app/sample-configuration/mailetcontainer.xml index acc048b8a98..90cbcedef1b 100644 --- a/server/apps/postgres-app/sample-configuration/mailetcontainer.xml +++ b/server/apps/postgres-app/sample-configuration/mailetcontainer.xml @@ -37,7 +37,9 @@ - + + file://var/mail/relay-limit-exceeded/ + transport From 8da059b79028c87e62c67f3853b5c3c2a5ec7691 Mon Sep 17 00:00:00 2001 From: hungphan227 <45198168+hungphan227@users.noreply.github.com> Date: Mon, 18 Dec 2023 14:44:53 +0700 Subject: [PATCH 127/341] JAMES-2586 ADR for Posgres mailbox tables structure (#1857) --- src/adr/0070-postgresql-adoption.md | 2 +- ...071-postgresql-mailbox-tables-structure.md | 58 ++++++++++++++++++ src/adr/img/adr-71-mailbox-tables-diagram.png | Bin 0 -> 146780 bytes 3 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 src/adr/0071-postgresql-mailbox-tables-structure.md create mode 100644 src/adr/img/adr-71-mailbox-tables-diagram.png diff --git a/src/adr/0070-postgresql-adoption.md b/src/adr/0070-postgresql-adoption.md index 115594daa03..5d1caf4f262 100644 --- a/src/adr/0070-postgresql-adoption.md +++ b/src/adr/0070-postgresql-adoption.md @@ -1,4 +1,4 @@ -# 68. Native PostgreSQL adoption +# 70. Native PostgreSQL adoption Date: 2023-10-31 diff --git a/src/adr/0071-postgresql-mailbox-tables-structure.md b/src/adr/0071-postgresql-mailbox-tables-structure.md new file mode 100644 index 00000000000..df859422d46 --- /dev/null +++ b/src/adr/0071-postgresql-mailbox-tables-structure.md @@ -0,0 +1,58 @@ +# 71. Postgresql Mailbox tables structure + +Date: 2023-12-14 + +## Status + +Implemented + +## Context + +As described in [ADR-70](link), we are willing to provide a Postgres implementation for Apache James. +The current document is willing to detail the inner working of the mailbox of the target implementation. + +## Decision + +![diagram for mailbox tables](img/adr-71-mailbox-tables-diagram.png) + +Table list: +- mailbox +- mailbox_annotations +- message +- message_mailbox +- subscription + +Indexes in table message_mailbox: +- message_mailbox_message_id_index (message_id) +- mailbox_id_mail_uid_index (mailbox_id, message_uid) +- mailbox_id_is_seen_mail_uid_index (mailbox_id, is_seen, message_uid) +- mailbox_id_is_recent_mail_uid_index (mailbox_id, is_recent, message_uid) +- mailbox_id_is_delete_mail_uid_index (mailbox_id, is_deleted, message_uid) + +Indexes are used to find records faster. + +The table structure is mostly normalized which mitigates storage costs and achieves consistency easily. + +Foreign key constraints (mailbox_id in mailbox_annotations, message_id in message_mailbox) help to ensure data consistency. For example, message_id 1 in table message_mailbox could not exist if message_id 1 in table message does not exist + +For some fields, hstore data type are used. Hstore is key-value hashmap data structure. Hstore allows us to model complex data types without the need for complex joins. + +Special postgres clauses such as RETURNING, ON CONFLICT are used to ensure consistency without the need of combining multiple queries in a single transaction. + +## Consequences + +Pros: +- Indexes could increase query performance significantly + +Cons: +- Too many indexes in a table could reduce the performance of updating data in the table + +## Alternatives + +## References + +- [JIRA](https://issues.apache.org/jira/browse/JAMES-2586) +- [PostgreSQL](https://www.postgresql.org/) + + + diff --git a/src/adr/img/adr-71-mailbox-tables-diagram.png b/src/adr/img/adr-71-mailbox-tables-diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..c9b2d11b5f5a8824c1e9babae4336ce7c34ee160 GIT binary patch literal 146780 zcma&NWmH~EwARVts-vL2k)ETO zjrCVWGb@%EZ9L{FRY`lbMwh zI7H3Bwv4gz2?XRTh`12HqHD&!??30HF=pCN^(*$c@w$EX^cj&6c!A1 z@rIU=mZXv{+U_r^Nz8h+0^QqZ@TQ9jI2?os_J~|hHaSSet%h8L-μeqi9_Jc~R+ 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

    U0aP0>iU4cx*Meb|wU0M4no| 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 Date: Mon, 11 Dec 2023 17:45:11 +0700 Subject: [PATCH 128/341] JAMES-2586 Implement PostgresSieveScriptDAO + PostgresSieveRepository --- .../postgres/utils/PostgresExecutor.java | 8 + .../lib/SieveRepositoryContract.java | 11 + .../sieve/postgres/PostgresSieveModule.java | 65 ++++ .../postgres/PostgresSieveRepository.java | 279 +++++++----------- .../postgres/PostgresSieveScriptDAO.java | 152 ++++++++++ .../postgres/model/PostgresSieveScript.java | 148 ++++++++++ .../postgres/PostgresSieveRepositoryTest.java | 18 +- 7 files changed, 493 insertions(+), 188 deletions(-) create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveModule.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveScriptDAO.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/model/PostgresSieveScript.java diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index 686145dbeb5..dceb4ac06f1 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -111,6 +111,14 @@ public Mono executeCount(Function>> q .map(Record1::value1); } + public Mono executeReturnAffectedRowsCount(Function> queryFunction) { + return dslContext() + .flatMap(queryFunction) + .cast(Long.class) + .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) + .filter(preparedStatementConflictException())); + } + public Mono connection() { return connection; } diff --git a/server/data/data-library/src/test/java/org/apache/james/sieverepository/lib/SieveRepositoryContract.java b/server/data/data-library/src/test/java/org/apache/james/sieverepository/lib/SieveRepositoryContract.java index 97f58414231..91531749a60 100644 --- a/server/data/data-library/src/test/java/org/apache/james/sieverepository/lib/SieveRepositoryContract.java +++ b/server/data/data-library/src/test/java/org/apache/james/sieverepository/lib/SieveRepositoryContract.java @@ -185,6 +185,17 @@ default void setActiveScriptShouldThrowOnNonExistentScript() { .isInstanceOf(ScriptNotFoundException.class); } + @Test + default void setActiveScriptOnNonExistingScriptShouldNotDeactivateTheCurrentActiveScript() throws Exception { + sieveRepository().putScript(USERNAME, SCRIPT_NAME, SCRIPT_CONTENT); + sieveRepository().setActive(USERNAME, SCRIPT_NAME); + + assertThatThrownBy(() -> sieveRepository().setActive(USERNAME, OTHER_SCRIPT_NAME)) + .isInstanceOf(ScriptNotFoundException.class); + + assertThat(getScriptContent(sieveRepository().getActive(USERNAME))).isEqualTo(SCRIPT_CONTENT); + } + @Test default void setActiveScriptShouldWork() throws Exception { sieveRepository().putScript(USERNAME, SCRIPT_NAME, SCRIPT_CONTENT); diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveModule.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveModule.java new file mode 100644 index 00000000000..b6780f9e63e --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveModule.java @@ -0,0 +1,65 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.sieve.postgres; + +import java.time.OffsetDateTime; + +import org.apache.james.backends.postgres.PostgresIndex; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresSieveModule { + interface PostgresSieveScriptTable { + Table TABLE_NAME = DSL.table("sieve_scripts"); + + Field USERNAME = DSL.field("username", SQLDataType.VARCHAR(255).notNull()); + Field SCRIPT_NAME = DSL.field("script_name", SQLDataType.VARCHAR.notNull()); + Field SCRIPT_SIZE = DSL.field("script_size", SQLDataType.BIGINT.notNull()); + Field SCRIPT_CONTENT = DSL.field("script_content", SQLDataType.VARCHAR.notNull()); + Field IS_ACTIVE = DSL.field("is_active", SQLDataType.BOOLEAN.notNull()); + Field ACTIVATION_DATE_TIME = DSL.field("activation_date_time", SQLDataType.OFFSETDATETIME); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(USERNAME) + .column(SCRIPT_NAME) + .column(SCRIPT_SIZE) + .column(SCRIPT_CONTENT) + .column(IS_ACTIVE) + .column(ACTIVATION_DATE_TIME) + .primaryKey(USERNAME, SCRIPT_NAME))) + .disableRowLevelSecurity(); + + PostgresIndex MAXIMUM_ONE_ACTIVE_SCRIPT_PER_USER_UNIQUE_INDEX = PostgresIndex.name("maximum_one_active_script_per_user") + .createIndexStep(((dsl, indexName) -> dsl.createUniqueIndexIfNotExists(indexName) + .on(TABLE_NAME, USERNAME) + .where(IS_ACTIVE))); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(PostgresSieveScriptTable.TABLE) + .addIndex(PostgresSieveScriptTable.MAXIMUM_ONE_ACTIVE_SCRIPT_PER_USER_UNIQUE_INDEX) + .build(); +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveRepository.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveRepository.java index 544ef43999e..662915c5235 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveRepository.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveRepository.java @@ -19,27 +19,21 @@ package org.apache.james.sieve.postgres; +import static org.apache.james.backends.postgres.utils.PostgresUtils.UNIQUE_CONSTRAINT_VIOLATION_PREDICATE; + import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.time.ZonedDateTime; import java.util.List; import java.util.Optional; -import java.util.function.Consumer; -import java.util.function.Function; import javax.inject.Inject; -import javax.persistence.EntityManager; -import javax.persistence.EntityManagerFactory; -import javax.persistence.EntityTransaction; -import javax.persistence.NoResultException; -import javax.persistence.PersistenceException; import org.apache.commons.io.IOUtils; -import org.apache.james.backends.jpa.TransactionRunner; import org.apache.james.core.Username; import org.apache.james.core.quota.QuotaSizeLimit; import org.apache.james.core.quota.QuotaSizeUsage; -import org.apache.james.sieve.postgres.model.JPASieveScript; +import org.apache.james.sieve.postgres.model.PostgresSieveScript; import org.apache.james.sieverepository.api.ScriptContent; import org.apache.james.sieverepository.api.ScriptName; import org.apache.james.sieverepository.api.ScriptSummary; @@ -49,217 +43,166 @@ import org.apache.james.sieverepository.api.exception.QuotaExceededException; import org.apache.james.sieverepository.api.exception.QuotaNotFoundException; import org.apache.james.sieverepository.api.exception.ScriptNotFoundException; -import org.apache.james.sieverepository.api.exception.StorageException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.github.fge.lambdas.Throwing; -import com.google.common.collect.ImmutableList; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; public class PostgresSieveRepository implements SieveRepository { - - private static final Logger LOGGER = LoggerFactory.getLogger(PostgresSieveRepository.class); - - private final TransactionRunner transactionRunner; private final PostgresSieveQuotaDAO postgresSieveQuotaDAO; + private final PostgresSieveScriptDAO postgresSieveScriptDAO; @Inject - public PostgresSieveRepository(EntityManagerFactory entityManagerFactory, PostgresSieveQuotaDAO postgresSieveQuotaDAO) { - this.transactionRunner = new TransactionRunner(entityManagerFactory); + public PostgresSieveRepository(PostgresSieveQuotaDAO postgresSieveQuotaDAO, + PostgresSieveScriptDAO postgresSieveScriptDAO) { this.postgresSieveQuotaDAO = postgresSieveQuotaDAO; + this.postgresSieveScriptDAO = postgresSieveScriptDAO; } @Override - public void haveSpace(Username username, ScriptName name, long size) throws QuotaExceededException, StorageException { - long usedSpace = findAllSieveScriptsForUser(username).stream() - .filter(sieveScript -> !sieveScript.getScriptName().equals(name.getValue())) - .mapToLong(JPASieveScript::getScriptSize) - .sum(); - - QuotaSizeLimit quota = limitToUser(username); - if (overQuotaAfterModification(usedSpace, size, quota)) { - throw new QuotaExceededException(); + public void haveSpace(Username username, ScriptName name, long size) throws QuotaExceededException { + long sizeDifference = spaceThatWillBeUsedByNewScript(username, name, size).block(); + throwOnOverQuota(username, sizeDifference); + } + + @Override + public void putScript(Username username, ScriptName name, ScriptContent content) throws QuotaExceededException { + long sizeDifference = spaceThatWillBeUsedByNewScript(username, name, content.length()).block(); + throwOnOverQuota(username, sizeDifference); + postgresSieveScriptDAO.upsertScript(PostgresSieveScript.builder() + .username(username.asString()) + .scriptName(name.getValue()) + .scriptContent(content.getValue()) + .scriptSize(content.length()) + .isActive(false) + .build()) + .flatMap(upsertedScripts -> { + if (upsertedScripts > 0) { + return updateSpaceUsed(username, sizeDifference); + } + return Mono.empty(); + }) + .block(); + } + + private Mono updateSpaceUsed(Username username, long spaceToUse) { + if (spaceToUse == 0) { + return Mono.empty(); } + return postgresSieveQuotaDAO.updateSpaceUsed(username, spaceToUse); } - private QuotaSizeLimit limitToUser(Username username) { - return postgresSieveQuotaDAO.getQuota(username) - .filter(Optional::isPresent) - .switchIfEmpty(postgresSieveQuotaDAO.getGlobalQuota()) - .block() - .orElse(QuotaSizeLimit.unlimited()); + private Mono spaceThatWillBeUsedByNewScript(Username username, ScriptName name, long scriptSize) { + return postgresSieveScriptDAO.getScriptSize(username, name) + .defaultIfEmpty(0L) + .map(sizeOfStoredScript -> scriptSize - sizeOfStoredScript); } - private boolean overQuotaAfterModification(long usedSpace, long size, QuotaSizeLimit quota) { - return QuotaSizeUsage.size(usedSpace) - .add(size) - .exceedLimit(quota); + private void throwOnOverQuota(Username username, Long sizeDifference) throws QuotaExceededException { + long spaceUsed = postgresSieveQuotaDAO.spaceUsedBy(username).block(); + QuotaSizeLimit limit = limitToUser(username).block(); + + if (QuotaSizeUsage.size(spaceUsed) + .add(sizeDifference) + .exceedLimit(limit)) { + throw new QuotaExceededException(); + } } - @Override - public void putScript(Username username, ScriptName name, ScriptContent content) { - transactionRunner.runAndHandleException(Throwing.consumer(entityManager -> { - try { - haveSpace(username, name, content.length()); - JPASieveScript jpaSieveScript = JPASieveScript.builder() - .username(username.asString()) - .scriptName(name.getValue()) - .scriptContent(content) - .build(); - entityManager.persist(jpaSieveScript); - } catch (QuotaExceededException | StorageException e) { - rollbackTransactionIfActive(entityManager.getTransaction()); - throw e; - } - }).sneakyThrow(), throwStorageExceptionConsumer("Unable to put script for user " + username.asString())); + private Mono limitToUser(Username username) { + return postgresSieveQuotaDAO.getQuota(username) + .filter(Optional::isPresent) + .switchIfEmpty(postgresSieveQuotaDAO.getGlobalQuota()) + .map(optional -> optional.orElse(QuotaSizeLimit.unlimited())); } @Override public List listScripts(Username username) { - return findAllSieveScriptsForUser(username).stream() - .map(JPASieveScript::toSummary) - .collect(ImmutableList.toImmutableList()); + return listScriptsReactive(username) + .collectList() + .block(); } @Override public Flux listScriptsReactive(Username username) { - return Mono.fromCallable(() -> listScripts(username)).flatMapMany(Flux::fromIterable); - } - - private List findAllSieveScriptsForUser(Username username) { - return transactionRunner.runAndRetrieveResult(entityManager -> { - List sieveScripts = entityManager.createNamedQuery("findAllByUsername", JPASieveScript.class) - .setParameter("username", username.asString()).getResultList(); - return Optional.ofNullable(sieveScripts).orElse(ImmutableList.of()); - }, throwStorageException("Unable to list scripts for user " + username.asString())); + return postgresSieveScriptDAO.getScripts(username) + .map(PostgresSieveScript::toScriptSummary); } @Override public ZonedDateTime getActivationDateForActiveScript(Username username) throws ScriptNotFoundException { - Optional script = findActiveSieveScript(username); - JPASieveScript activeSieveScript = script.orElseThrow(() -> new ScriptNotFoundException("Unable to find active script for user " + username.asString())); - return activeSieveScript.getActivationDateTime().toZonedDateTime(); + return postgresSieveScriptDAO.getActiveScript(username) + .blockOptional() + .orElseThrow(ScriptNotFoundException::new) + .getActivationDateTime() + .toZonedDateTime(); } @Override public InputStream getActive(Username username) throws ScriptNotFoundException { - Optional script = findActiveSieveScript(username); - JPASieveScript activeSieveScript = script.orElseThrow(() -> new ScriptNotFoundException("Unable to find active script for user " + username.asString())); - return IOUtils.toInputStream(activeSieveScript.getScriptContent(), StandardCharsets.UTF_8); - } - - private Optional findActiveSieveScript(Username username) { - return transactionRunner.runAndRetrieveResult( - Throwing.>function(entityManager -> findActiveSieveScript(username, entityManager)).sneakyThrow(), - throwStorageException("Unable to find active script for user " + username.asString())); + return IOUtils.toInputStream(postgresSieveScriptDAO.getActiveScript(username) + .blockOptional() + .orElseThrow(ScriptNotFoundException::new) + .getScriptContent(), StandardCharsets.UTF_8); } - private Optional findActiveSieveScript(Username username, EntityManager entityManager) { - try { - JPASieveScript activeSieveScript = entityManager.createNamedQuery("findActiveByUsername", JPASieveScript.class) - .setParameter("username", username.asString()).getSingleResult(); - return Optional.ofNullable(activeSieveScript); - } catch (NoResultException e) { - LOGGER.debug("Sieve script not found for user {}", username.asString()); - return Optional.empty(); + @Override + public void setActive(Username username, ScriptName name) throws ScriptNotFoundException { + if (SieveRepository.NO_SCRIPT_NAME.equals(name)) { + switchOffCurrentActiveScript(username); + } else { + throwOnScriptNonExistence(username, name); + switchOffCurrentActiveScript(username); + activateScript(username, name); } } - @Override - public void setActive(Username username, ScriptName name) { - transactionRunner.runAndHandleException(Throwing.consumer(entityManager -> { - try { - if (SieveRepository.NO_SCRIPT_NAME.equals(name)) { - switchOffActiveScript(username, entityManager); - } else { - setActiveScript(username, name, entityManager); - } - } catch (StorageException | ScriptNotFoundException e) { - rollbackTransactionIfActive(entityManager.getTransaction()); - throw e; - } - }).sneakyThrow(), throwStorageExceptionConsumer("Unable to set active script " + name.getValue() + " for user " + username.asString())); + private void throwOnScriptNonExistence(Username username, ScriptName name) throws ScriptNotFoundException { + if (!postgresSieveScriptDAO.scriptExists(username, name).block()) { + throw new ScriptNotFoundException(); + } } - private void switchOffActiveScript(Username username, EntityManager entityManager) throws StorageException { - Optional activeSieveScript = findActiveSieveScript(username, entityManager); - activeSieveScript.ifPresent(JPASieveScript::deactivate); + private void switchOffCurrentActiveScript(Username username) { + postgresSieveScriptDAO.deactivateCurrentActiveScript(username).block(); } - private void setActiveScript(Username username, ScriptName name, EntityManager entityManager) throws StorageException, ScriptNotFoundException { - JPASieveScript sieveScript = findSieveScript(username, name, entityManager) - .orElseThrow(() -> new ScriptNotFoundException("Unable to find script " + name.getValue() + " for user " + username.asString())); - findActiveSieveScript(username, entityManager).ifPresent(JPASieveScript::deactivate); - sieveScript.activate(); + private void activateScript(Username username, ScriptName scriptName) { + postgresSieveScriptDAO.activateScript(username, scriptName).block(); } @Override public InputStream getScript(Username username, ScriptName name) throws ScriptNotFoundException { - Optional script = findSieveScript(username, name); - JPASieveScript sieveScript = script.orElseThrow(() -> new ScriptNotFoundException("Unable to find script " + name.getValue() + " for user " + username.asString())); - return IOUtils.toInputStream(sieveScript.getScriptContent(), StandardCharsets.UTF_8); + return IOUtils.toInputStream(postgresSieveScriptDAO.getScript(username, name) + .blockOptional() + .orElseThrow(ScriptNotFoundException::new) + .getScriptContent(), StandardCharsets.UTF_8); } - private Optional findSieveScript(Username username, ScriptName scriptName) { - return transactionRunner.runAndRetrieveResult(entityManager -> findSieveScript(username, scriptName, entityManager), - throwStorageException("Unable to find script " + scriptName.getValue() + " for user " + username.asString())); - } + @Override + public void deleteScript(Username username, ScriptName name) throws ScriptNotFoundException, IsActiveException { + boolean isActive = postgresSieveScriptDAO.getIsActive(username, name) + .blockOptional() + .orElseThrow(ScriptNotFoundException::new); - private Optional findSieveScript(Username username, ScriptName scriptName, EntityManager entityManager) { - try { - JPASieveScript sieveScript = entityManager.createNamedQuery("findSieveScript", JPASieveScript.class) - .setParameter("username", username.asString()) - .setParameter("scriptName", scriptName.getValue()).getSingleResult(); - return Optional.ofNullable(sieveScript); - } catch (NoResultException e) { - LOGGER.debug("Sieve script not found for user {}", username.asString()); - return Optional.empty(); + if (isActive) { + throw new IsActiveException(); } - } - @Override - public void deleteScript(Username username, ScriptName name) { - transactionRunner.runAndHandleException(Throwing.consumer(entityManager -> { - Optional sieveScript = findSieveScript(username, name, entityManager); - if (!sieveScript.isPresent()) { - rollbackTransactionIfActive(entityManager.getTransaction()); - throw new ScriptNotFoundException("Unable to find script " + name.getValue() + " for user " + username.asString()); - } - JPASieveScript sieveScriptToRemove = sieveScript.get(); - if (sieveScriptToRemove.isActive()) { - rollbackTransactionIfActive(entityManager.getTransaction()); - throw new IsActiveException("Unable to delete active script " + name.getValue() + " for user " + username.asString()); - } - entityManager.remove(sieveScriptToRemove); - }).sneakyThrow(), throwStorageExceptionConsumer("Unable to delete script " + name.getValue() + " for user " + username.asString())); + postgresSieveScriptDAO.deleteScript(username, name).block(); } @Override - public void renameScript(Username username, ScriptName oldName, ScriptName newName) { - transactionRunner.runAndHandleException(Throwing.consumer(entityManager -> { - Optional sieveScript = findSieveScript(username, oldName, entityManager); - if (!sieveScript.isPresent()) { - rollbackTransactionIfActive(entityManager.getTransaction()); - throw new ScriptNotFoundException("Unable to find script " + oldName.getValue() + " for user " + username.asString()); + public void renameScript(Username username, ScriptName oldName, ScriptName newName) throws DuplicateException, ScriptNotFoundException { + try { + long renamedScripts = postgresSieveScriptDAO.renameScript(username, oldName, newName).block(); + if (renamedScripts == 0) { + throw new ScriptNotFoundException(); } - - Optional duplicatedSieveScript = findSieveScript(username, newName, entityManager); - if (duplicatedSieveScript.isPresent()) { - rollbackTransactionIfActive(entityManager.getTransaction()); - throw new DuplicateException("Unable to rename script. Duplicate found " + newName.getValue() + " for user " + username.asString()); + } catch (Exception e) { + if (UNIQUE_CONSTRAINT_VIOLATION_PREDICATE.test(e)) { + throw new DuplicateException(); } - - JPASieveScript sieveScriptToRename = sieveScript.get(); - sieveScriptToRename.renameTo(newName); - }).sneakyThrow(), throwStorageExceptionConsumer("Unable to rename script " + oldName.getValue() + " for user " + username.asString())); - } - - private void rollbackTransactionIfActive(EntityTransaction transaction) { - if (transaction.isActive()) { - transaction.rollback(); + throw e; } } @@ -316,18 +259,6 @@ public void removeQuota(Username username) { postgresSieveQuotaDAO.removeQuota(username).block(); } - private Function throwStorageException(String message) { - return Throwing.function(e -> { - throw new StorageException(message, e); - }).sneakyThrow(); - } - - private Consumer throwStorageExceptionConsumer(String message) { - return Throwing.consumer(e -> { - throw new StorageException(message, e); - }).sneakyThrow(); - } - @Override public Mono resetSpaceUsedReactive(Username username, long spaceUsed) { return Mono.error(new UnsupportedOperationException()); diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveScriptDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveScriptDAO.java new file mode 100644 index 00000000000..b87778db9f1 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveScriptDAO.java @@ -0,0 +1,152 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.sieve.postgres; + +import static org.apache.james.backends.postgres.utils.PostgresExecutor.DEFAULT_INJECT; +import static org.apache.james.sieve.postgres.PostgresSieveModule.PostgresSieveScriptTable.ACTIVATION_DATE_TIME; +import static org.apache.james.sieve.postgres.PostgresSieveModule.PostgresSieveScriptTable.IS_ACTIVE; +import static org.apache.james.sieve.postgres.PostgresSieveModule.PostgresSieveScriptTable.SCRIPT_CONTENT; +import static org.apache.james.sieve.postgres.PostgresSieveModule.PostgresSieveScriptTable.SCRIPT_NAME; +import static org.apache.james.sieve.postgres.PostgresSieveModule.PostgresSieveScriptTable.SCRIPT_SIZE; +import static org.apache.james.sieve.postgres.PostgresSieveModule.PostgresSieveScriptTable.TABLE_NAME; +import static org.apache.james.sieve.postgres.PostgresSieveModule.PostgresSieveScriptTable.USERNAME; + +import java.time.OffsetDateTime; +import java.util.function.Function; + +import javax.inject.Inject; +import javax.inject.Named; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.core.Username; +import org.apache.james.sieve.postgres.model.PostgresSieveScript; +import org.apache.james.sieverepository.api.ScriptName; +import org.jooq.Record; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresSieveScriptDAO { + private final PostgresExecutor postgresExecutor; + + @Inject + public PostgresSieveScriptDAO(@Named(DEFAULT_INJECT) PostgresExecutor postgresExecutor) { + this.postgresExecutor = postgresExecutor; + } + + public Mono upsertScript(PostgresSieveScript sieveScript) { + return postgresExecutor.executeReturnAffectedRowsCount(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME) + .set(USERNAME, sieveScript.getUsername()) + .set(SCRIPT_NAME, sieveScript.getScriptName()) + .set(SCRIPT_SIZE, sieveScript.getScriptSize()) + .set(SCRIPT_CONTENT, sieveScript.getScriptContent()) + .set(IS_ACTIVE, sieveScript.isActive()) + .set(ACTIVATION_DATE_TIME, sieveScript.getActivationDateTime()) + .onConflict(USERNAME, SCRIPT_NAME) + .doUpdate() + .set(SCRIPT_SIZE, sieveScript.getScriptSize()) + .set(SCRIPT_CONTENT, sieveScript.getScriptContent()) + .set(IS_ACTIVE, sieveScript.isActive()) + .set(ACTIVATION_DATE_TIME, sieveScript.getActivationDateTime()))); + } + + public Mono getScript(Username username, ScriptName scriptName) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.selectFrom(TABLE_NAME) + .where(USERNAME.eq(username.asString()), + SCRIPT_NAME.eq(scriptName.getValue())))) + .map(recordToPostgresSieveScript()); + } + + public Mono getScriptSize(Username username, ScriptName scriptName) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select(SCRIPT_SIZE) + .from(TABLE_NAME) + .where(USERNAME.eq(username.asString()), + SCRIPT_NAME.eq(scriptName.getValue())))) + .map(record -> record.get(SCRIPT_SIZE)); + } + + public Mono getIsActive(Username username, ScriptName scriptName) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select(IS_ACTIVE) + .from(TABLE_NAME) + .where(USERNAME.eq(username.asString()), + SCRIPT_NAME.eq(scriptName.getValue())))) + .map(record -> record.get(IS_ACTIVE)); + } + + public Mono scriptExists(Username username, ScriptName scriptName) { + return postgresExecutor.executeCount(dslContext -> Mono.from(dslContext.selectCount() + .from(TABLE_NAME) + .where(USERNAME.eq(username.asString()), + SCRIPT_NAME.eq(scriptName.getValue())))) + .map(count -> count > 0); + } + + public Flux getScripts(Username username) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.selectFrom(TABLE_NAME) + .where(USERNAME.eq(username.asString())))) + .map(recordToPostgresSieveScript()); + } + + public Mono getActiveScript(Username username) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.selectFrom(TABLE_NAME) + .where(USERNAME.eq(username.asString()), + IS_ACTIVE.eq(true)))) + .map(recordToPostgresSieveScript()); + } + + public Mono activateScript(Username username, ScriptName scriptName) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.update(TABLE_NAME) + .set(IS_ACTIVE, true) + .set(ACTIVATION_DATE_TIME, OffsetDateTime.now()) + .where(USERNAME.eq(username.asString()), + SCRIPT_NAME.eq(scriptName.getValue())))); + } + + public Mono deactivateCurrentActiveScript(Username username) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.update(TABLE_NAME) + .set(IS_ACTIVE, false) + .where(USERNAME.eq(username.asString()), + IS_ACTIVE.eq(true)))); + } + + public Mono renameScript(Username username, ScriptName oldName, ScriptName newName) { + return postgresExecutor.executeReturnAffectedRowsCount(dslContext -> Mono.from(dslContext.update(TABLE_NAME) + .set(SCRIPT_NAME, newName.getValue()) + .where(USERNAME.eq(username.asString()), + SCRIPT_NAME.eq(oldName.getValue())))); + } + + public Mono deleteScript(Username username, ScriptName scriptName) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(TABLE_NAME) + .where(USERNAME.eq(username.asString()), + SCRIPT_NAME.eq(scriptName.getValue())))); + } + + private Function recordToPostgresSieveScript() { + return record -> PostgresSieveScript.builder() + .username(record.get(USERNAME)) + .scriptName(record.get(SCRIPT_NAME)) + .scriptContent(record.get(SCRIPT_CONTENT)) + .scriptSize(record.get(SCRIPT_SIZE)) + .isActive(record.get(IS_ACTIVE)) + .activationDateTime(record.get(ACTIVATION_DATE_TIME)) + .build(); + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/model/PostgresSieveScript.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/model/PostgresSieveScript.java new file mode 100644 index 00000000000..d5831d54649 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/model/PostgresSieveScript.java @@ -0,0 +1,148 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.sieve.postgres.model; + +import java.time.OffsetDateTime; +import java.util.Objects; + +import org.apache.commons.lang3.StringUtils; +import org.apache.james.sieverepository.api.ScriptName; +import org.apache.james.sieverepository.api.ScriptSummary; + +import com.google.common.base.Preconditions; + +public class PostgresSieveScript { + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String username; + private String scriptName; + private String scriptContent; + private long scriptSize; + private boolean isActive; + private OffsetDateTime activationDateTime; + + public Builder username(String username) { + Preconditions.checkNotNull(username); + this.username = username; + return this; + } + + public Builder scriptName(String scriptName) { + Preconditions.checkNotNull(scriptName); + this.scriptName = scriptName; + return this; + } + + public Builder scriptContent(String scriptContent) { + this.scriptContent = scriptContent; + return this; + } + + public Builder scriptSize(long scriptSize) { + this.scriptSize = scriptSize; + return this; + } + + public Builder isActive(boolean isActive) { + this.isActive = isActive; + return this; + } + + public Builder activationDateTime(OffsetDateTime offsetDateTime) { + this.activationDateTime = offsetDateTime; + return this; + } + + public PostgresSieveScript build() { + Preconditions.checkState(StringUtils.isNotBlank(username), "'username' is mandatory"); + Preconditions.checkState(StringUtils.isNotBlank(scriptName), "'scriptName' is mandatory"); + + return new PostgresSieveScript(username, scriptName, scriptContent, scriptSize, isActive, activationDateTime); + } + } + + private final String username; + private final String scriptName; + private final String scriptContent; + private final long scriptSize; + private final boolean isActive; + private final OffsetDateTime activationDateTime; + + private PostgresSieveScript(String username, String scriptName, String scriptContent, long scriptSize, boolean isActive, OffsetDateTime activationDateTime) { + this.username = username; + this.scriptName = scriptName; + this.scriptContent = scriptContent; + this.scriptSize = scriptSize; + this.isActive = isActive; + this.activationDateTime = activationDateTime; + } + + public String getUsername() { + return username; + } + + public String getScriptName() { + return scriptName; + } + + public String getScriptContent() { + return scriptContent; + } + + public long getScriptSize() { + return scriptSize; + } + + public boolean isActive() { + return isActive; + } + + public OffsetDateTime getActivationDateTime() { + return activationDateTime; + } + + public ScriptSummary toScriptSummary() { + return new ScriptSummary(new ScriptName(scriptName), isActive, scriptSize); + } + + @Override + public final boolean equals(Object o) { + if (o instanceof PostgresSieveScript) { + PostgresSieveScript that = (PostgresSieveScript) o; + + return Objects.equals(this.scriptSize, that.scriptSize) + && Objects.equals(this.isActive, that.isActive) + && Objects.equals(this.username, that.username) + && Objects.equals(this.scriptName, that.scriptName) + && Objects.equals(this.scriptContent, that.scriptContent) + && Objects.equals(this.activationDateTime, that.activationDateTime); + } + return false; + } + + @Override + public final int hashCode() { + return Objects.hash(username, scriptName); + } +} diff --git a/server/data/data-postgres/src/test/java/org/apache/james/sieve/postgres/PostgresSieveRepositoryTest.java b/server/data/data-postgres/src/test/java/org/apache/james/sieve/postgres/PostgresSieveRepositoryTest.java index b31b1e173aa..d67c71069ee 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/sieve/postgres/PostgresSieveRepositoryTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/sieve/postgres/PostgresSieveRepositoryTest.java @@ -19,37 +19,27 @@ package org.apache.james.sieve.postgres; -import org.apache.james.backends.jpa.JpaTestCluster; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.backends.postgres.quota.PostgresQuotaCurrentValueDAO; import org.apache.james.backends.postgres.quota.PostgresQuotaLimitDAO; import org.apache.james.backends.postgres.quota.PostgresQuotaModule; -import org.apache.james.sieve.postgres.model.JPASieveScript; import org.apache.james.sieverepository.api.SieveRepository; import org.apache.james.sieverepository.lib.SieveRepositoryContract; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.RegisterExtension; class PostgresSieveRepositoryTest implements SieveRepositoryContract { @RegisterExtension - static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresModule.aggregateModules(PostgresQuotaModule.MODULE)); - - final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPASieveScript.class); + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresModule.aggregateModules(PostgresQuotaModule.MODULE, + PostgresSieveModule.MODULE)); SieveRepository sieveRepository; @BeforeEach void setUp() { - sieveRepository = new PostgresSieveRepository(JPA_TEST_CLUSTER.getEntityManagerFactory(), - new PostgresSieveQuotaDAO(new PostgresQuotaCurrentValueDAO(postgresExtension.getPostgresExecutor()), - new PostgresQuotaLimitDAO(postgresExtension.getPostgresExecutor()))); - } - - @AfterEach - void tearDown() { - JPA_TEST_CLUSTER.clear("JAMES_SIEVE_SCRIPT"); + sieveRepository = new PostgresSieveRepository(new PostgresSieveQuotaDAO(new PostgresQuotaCurrentValueDAO(postgresExtension.getPostgresExecutor()), new PostgresQuotaLimitDAO(postgresExtension.getPostgresExecutor())), + new PostgresSieveScriptDAO(postgresExtension.getPostgresExecutor())); } @Override From e574eff88f5f02fe63114075e49f678a7295eaff Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Tue, 12 Dec 2023 10:02:02 +0100 Subject: [PATCH 129/341] JAMES-2586 Delete JPASieveScript.java Co-authored-by: Quan Tran --- .../main/resources/META-INF/persistence.xml | 1 - server/data/data-postgres/pom.xml | 6 +- .../sieve/postgres/model/JPASieveScript.java | 200 ------------------ .../src/test/resources/persistence.xml | 1 - 4 files changed, 2 insertions(+), 206 deletions(-) delete mode 100644 server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/model/JPASieveScript.java diff --git a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml index a5837560c7d..9c79e80651b 100644 --- a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml +++ b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml @@ -25,7 +25,6 @@ org.apache.james.mailrepository.jpa.model.JPAMail - org.apache.james.sieve.postgres.model.JPASieveScript diff --git a/server/data/data-postgres/pom.xml b/server/data/data-postgres/pom.xml index 88b2b88b9c8..f3be0c4f27b 100644 --- a/server/data/data-postgres/pom.xml +++ b/server/data/data-postgres/pom.xml @@ -155,8 +155,7 @@ openjpa-maven-plugin ${apache.openjpa.version} - org/apache/james/sieve/postgres/model/JPASieveScript.class, - org/apache/james/mailrepository/jpa/model/JPAUrl.class, + org/apache/james/mailrepository/jpa/model/JPAUrl.class, org/apache/james/mailrepository/jpa/model/JPAMail.class true true @@ -167,8 +166,7 @@ metaDataFactory - jpa(Types=org.apache.james.sieve.postgres.model.JPASieveScript; - org.apache.james.mailrepository.jpa.model.JPAUrl; + jpa(Types=org.apache.james.mailrepository.jpa.model.JPAUrl; org.apache.james.mailrepository.jpa.model.JPAMail) diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/model/JPASieveScript.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/model/JPASieveScript.java deleted file mode 100644 index 8575b34a171..00000000000 --- a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/model/JPASieveScript.java +++ /dev/null @@ -1,200 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.sieve.postgres.model; - -import java.time.OffsetDateTime; -import java.util.Objects; -import java.util.UUID; - -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.Id; -import javax.persistence.NamedQueries; -import javax.persistence.NamedQuery; -import javax.persistence.Table; - -import org.apache.commons.lang3.StringUtils; -import org.apache.james.sieverepository.api.ScriptContent; -import org.apache.james.sieverepository.api.ScriptName; -import org.apache.james.sieverepository.api.ScriptSummary; - -import com.google.common.base.MoreObjects; -import com.google.common.base.Preconditions; - -@Entity(name = "JamesSieveScript") -@Table(name = "JAMES_SIEVE_SCRIPT") -@NamedQueries({ - @NamedQuery(name = "findAllByUsername", query = "SELECT sieveScript FROM JamesSieveScript sieveScript WHERE sieveScript.username=:username"), - @NamedQuery(name = "findActiveByUsername", query = "SELECT sieveScript FROM JamesSieveScript sieveScript WHERE sieveScript.username=:username AND sieveScript.isActive=true"), - @NamedQuery(name = "findSieveScript", query = "SELECT sieveScript FROM JamesSieveScript sieveScript WHERE sieveScript.username=:username AND sieveScript.scriptName=:scriptName") -}) -public class JPASieveScript { - - public static Builder builder() { - return new Builder(); - } - - public static ScriptSummary toSummary(JPASieveScript script) { - return new ScriptSummary(new ScriptName(script.getScriptName()), script.isActive(), script.getScriptSize()); - } - - public static class Builder { - - private String username; - private String scriptName; - private String scriptContent; - private long scriptSize; - private boolean isActive; - private OffsetDateTime activationDateTime; - - public Builder username(String username) { - Preconditions.checkNotNull(username); - this.username = username; - return this; - } - - public Builder scriptName(String scriptName) { - Preconditions.checkNotNull(scriptName); - this.scriptName = scriptName; - return this; - } - - public Builder scriptContent(ScriptContent scriptContent) { - Preconditions.checkNotNull(scriptContent); - this.scriptContent = scriptContent.getValue(); - this.scriptSize = scriptContent.length(); - return this; - } - - public Builder isActive(boolean isActive) { - this.isActive = isActive; - return this; - } - - public JPASieveScript build() { - Preconditions.checkState(StringUtils.isNotBlank(username), "'username' is mandatory"); - Preconditions.checkState(StringUtils.isNotBlank(scriptName), "'scriptName' is mandatory"); - this.activationDateTime = isActive ? OffsetDateTime.now() : null; - return new JPASieveScript(username, scriptName, scriptContent, scriptSize, isActive, activationDateTime); - } - } - - @Id - private String uuid = UUID.randomUUID().toString(); - - @Column(name = "USER_NAME", nullable = false, length = 100) - private String username; - - @Column(name = "SCRIPT_NAME", nullable = false, length = 255) - private String scriptName; - - @Column(name = "SCRIPT_CONTENT", nullable = false, length = 1024) - private String scriptContent; - - @Column(name = "SCRIPT_SIZE", nullable = false) - private long scriptSize; - - @Column(name = "IS_ACTIVE", nullable = false) - private boolean isActive; - - @Column(name = "ACTIVATION_DATE_TIME") - private OffsetDateTime activationDateTime; - - /** - * @deprecated enhancement only - */ - @Deprecated - protected JPASieveScript() { - } - - private JPASieveScript(String username, String scriptName, String scriptContent, long scriptSize, boolean isActive, OffsetDateTime activationDateTime) { - this.username = username; - this.scriptName = scriptName; - this.scriptContent = scriptContent; - this.scriptSize = scriptSize; - this.isActive = isActive; - this.activationDateTime = activationDateTime; - } - - public String getUsername() { - return username; - } - - public String getScriptName() { - return scriptName; - } - - public String getScriptContent() { - return scriptContent; - } - - public long getScriptSize() { - return scriptSize; - } - - public boolean isActive() { - return isActive; - } - - public OffsetDateTime getActivationDateTime() { - return activationDateTime; - } - - public void activate() { - this.isActive = true; - this.activationDateTime = OffsetDateTime.now(); - } - - public void deactivate() { - this.isActive = false; - this.activationDateTime = null; - } - - public void renameTo(ScriptName newName) { - this.scriptName = newName.getValue(); - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - JPASieveScript that = (JPASieveScript) o; - return Objects.equals(uuid, that.uuid); - } - - @Override - public int hashCode() { - return Objects.hash(uuid); - } - - @Override - public String toString() { - return MoreObjects.toStringHelper(this) - .add("uuid", uuid) - .add("username", username) - .add("scriptName", scriptName) - .add("isActive", isActive) - .toString(); - } -} diff --git a/server/data/data-postgres/src/test/resources/persistence.xml b/server/data/data-postgres/src/test/resources/persistence.xml index 4ba44478005..d8441861057 100644 --- a/server/data/data-postgres/src/test/resources/persistence.xml +++ b/server/data/data-postgres/src/test/resources/persistence.xml @@ -27,7 +27,6 @@ org.apache.openjpa.persistence.PersistenceProviderImpl osgi:service/javax.sql.DataSource/(osgi.jndi.service.name=jdbc/james) org.apache.james.mailrepository.jpa.model.JPAMail - org.apache.james.sieve.postgres.model.JPASieveScript true From 98c01a94718a2fff69787b1bc6185b0be85d6143 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 12 Dec 2023 16:07:14 +0700 Subject: [PATCH 130/341] JAMES-2586 Guice binding for PostgresSieveScriptDAO --- .../modules/data/SievePostgresRepositoryModules.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/server/container/guice/sieve-postgres/src/main/java/org/apache/james/modules/data/SievePostgresRepositoryModules.java b/server/container/guice/sieve-postgres/src/main/java/org/apache/james/modules/data/SievePostgresRepositoryModules.java index b2784c6be7b..a3191352624 100644 --- a/server/container/guice/sieve-postgres/src/main/java/org/apache/james/modules/data/SievePostgresRepositoryModules.java +++ b/server/container/guice/sieve-postgres/src/main/java/org/apache/james/modules/data/SievePostgresRepositoryModules.java @@ -19,18 +19,27 @@ package org.apache.james.modules.data; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.sieve.postgres.PostgresSieveModule; +import org.apache.james.sieve.postgres.PostgresSieveQuotaDAO; import org.apache.james.sieve.postgres.PostgresSieveRepository; +import org.apache.james.sieve.postgres.PostgresSieveScriptDAO; import org.apache.james.sieverepository.api.SieveQuotaRepository; import org.apache.james.sieverepository.api.SieveRepository; import com.google.inject.AbstractModule; import com.google.inject.Scopes; +import com.google.inject.multibindings.Multibinder; public class SievePostgresRepositoryModules extends AbstractModule { @Override protected void configure() { - bind(PostgresSieveRepository.class).in(Scopes.SINGLETON); + Multibinder.newSetBinder(binder(), PostgresModule.class).addBinding().toInstance(PostgresSieveModule.MODULE); + + bind(PostgresSieveQuotaDAO.class).in(Scopes.SINGLETON); + bind(PostgresSieveScriptDAO.class).in(Scopes.SINGLETON); + bind(PostgresSieveRepository.class).in(Scopes.SINGLETON); bind(SieveRepository.class).to(PostgresSieveRepository.class); bind(SieveQuotaRepository.class).to(PostgresSieveRepository.class); } From bbd01da8446bcabd7420824f0138b00378855ee9 Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Thu, 14 Dec 2023 10:14:35 +0100 Subject: [PATCH 131/341] JAMES-2586 Implement PostgresMailRepository Co-authored-by: Quan Tran --- .../postgres/utils/PostgresExecutor.java | 5 +- server/data/data-postgres/pom.xml | 15 + .../postgres/PostgresMailRepository.java | 360 ++++++++++++++++++ .../PostgresMailRepositoryFactory.java | 52 +++ .../PostgresMailRepositoryModule.java | 43 +++ .../postgres/PostgresMailRepositoryTest.java | 63 +++ 6 files changed, 536 insertions(+), 2 deletions(-) create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepository.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryFactory.java create mode 100644 server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryTest.java diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index dceb4ac06f1..67f6c2067ba 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -34,6 +34,7 @@ import org.jooq.conf.Settings; import org.jooq.conf.StatementType; import org.jooq.impl.DSL; +import org.reactivestreams.Publisher; import com.google.common.annotations.VisibleForTesting; @@ -96,9 +97,9 @@ public Flux executeRows(Function> queryFunction .filter(preparedStatementConflictException())); } - public Mono executeRow(Function> queryFunction) { + public Mono executeRow(Function> queryFunction) { return dslContext() - .flatMap(queryFunction) + .flatMap(queryFunction.andThen(Mono::from)) .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) .filter(preparedStatementConflictException())); } diff --git a/server/data/data-postgres/pom.xml b/server/data/data-postgres/pom.xml index f3be0c4f27b..31a1d2a70ef 100644 --- a/server/data/data-postgres/pom.xml +++ b/server/data/data-postgres/pom.xml @@ -32,6 +32,17 @@ test-jar test + + ${james.groupId} + blob-api + test-jar + test + + + ${james.groupId} + blob-memory + test + ${james.groupId} james-server-core @@ -75,6 +86,10 @@ ${james.groupId} james-server-lifecycle-api + + ${james.groupId} + james-server-mail-store + ${james.groupId} james-server-mailrepository-api diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepository.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepository.java new file mode 100644 index 00000000000..c899f7da512 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepository.java @@ -0,0 +1,360 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailrepository.postgres; + +import static org.apache.james.backends.postgres.PostgresCommons.DATE_TO_LOCAL_DATE_TIME; +import static org.apache.james.backends.postgres.PostgresCommons.LOCAL_DATE_TIME_DATE_FUNCTION; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.ATTRIBUTES; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.BODY_BLOB_ID; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.ERROR; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.HEADER_BLOB_ID; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.KEY; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.LAST_UPDATED; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.PER_RECIPIENT_SPECIFIC_HEADERS; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.RECIPIENTS; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.REMOTE_ADDRESS; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.REMOTE_HOST; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.SENDER; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.STATE; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.TABLE_NAME; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.URL; +import static org.apache.james.util.ReactorUtils.DEFAULT_CONCURRENCY; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collection; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import javax.mail.MessagingException; +import javax.mail.internet.MimeMessage; + +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.backends.postgres.utils.PostgresUtils; +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.Store; +import org.apache.james.blob.mail.MimeMessagePartsId; +import org.apache.james.blob.mail.MimeMessageStore; +import org.apache.james.core.MailAddress; +import org.apache.james.core.MaybeSender; +import org.apache.james.mailrepository.api.MailKey; +import org.apache.james.mailrepository.api.MailRepository; +import org.apache.james.mailrepository.api.MailRepositoryUrl; +import org.apache.james.server.core.MailImpl; +import org.apache.james.server.core.MimeMessageWrapper; +import org.apache.james.util.AuditTrail; +import org.apache.mailet.Attribute; +import org.apache.mailet.AttributeName; +import org.apache.mailet.AttributeValue; +import org.apache.mailet.Mail; +import org.apache.mailet.PerRecipientHeaders; +import org.jooq.Record; +import org.jooq.postgres.extensions.types.Hstore; + +import com.fasterxml.jackson.databind.JsonNode; +import com.github.fge.lambdas.Throwing; +import com.google.common.base.Splitter; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Multimap; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresMailRepository implements MailRepository { + private static final String HEADERS_SEPARATOR = "; "; + + private final PostgresExecutor postgresExecutor; + private final MailRepositoryUrl url; + private final Store mimeMessageStore; + private final BlobId.Factory blobIdFactory; + + public PostgresMailRepository(PostgresExecutor postgresExecutor, + MailRepositoryUrl url, + MimeMessageStore.Factory mimeMessageStoreFactory, + BlobId.Factory blobIdFactory) { + this.postgresExecutor = postgresExecutor; + this.url = url; + this.mimeMessageStore = mimeMessageStoreFactory.mimeMessageStore(); + this.blobIdFactory = blobIdFactory; + } + + @Override + public long size() throws MessagingException { + return sizeReactive().block(); + } + + @Override + public Mono sizeReactive() { + return postgresExecutor.executeCount(context -> Mono.from(context.selectCount() + .from(TABLE_NAME) + .where(URL.eq(url.asString())))) + .map(Integer::longValue); + } + + @Override + public MailKey store(Mail mail) throws MessagingException { + MailKey mailKey = MailKey.forMail(mail); + + return storeMailBlob(mail) + .flatMap(mimeMessagePartsId -> storeMailMetadata(mail, mailKey, mimeMessagePartsId) + .doOnSuccess(auditTrailStoredMail(mail)) + .onErrorResume(PostgresUtils.UNIQUE_CONSTRAINT_VIOLATION_PREDICATE, e -> Mono.from(mimeMessageStore.delete(mimeMessagePartsId)) + .thenReturn(mailKey))) + .block(); + } + + private Mono storeMailBlob(Mail mail) throws MessagingException { + return mimeMessageStore.save(mail.getMessage()); + } + + private Mono storeMailMetadata(Mail mail, MailKey mailKey, MimeMessagePartsId mimeMessagePartsId) { + return postgresExecutor.executeVoid(context -> Mono.from(context.insertInto(TABLE_NAME) + .set(URL, url.asString()) + .set(KEY, mailKey.asString()) + .set(HEADER_BLOB_ID, mimeMessagePartsId.getHeaderBlobId().asString()) + .set(BODY_BLOB_ID, mimeMessagePartsId.getBodyBlobId().asString()) + .set(STATE, mail.getState()) + .set(ERROR, mail.getErrorMessage()) + .set(SENDER, mail.getMaybeSender().asString()) + .set(RECIPIENTS, asStringArray(mail.getRecipients())) + .set(REMOTE_ADDRESS, mail.getRemoteAddr()) + .set(REMOTE_HOST, mail.getRemoteHost()) + .set(LAST_UPDATED, DATE_TO_LOCAL_DATE_TIME.apply(mail.getLastUpdated())) + .set(ATTRIBUTES, asHstore(mail.attributes())) + .set(PER_RECIPIENT_SPECIFIC_HEADERS, asHstore(mail.getPerRecipientSpecificHeaders().getHeadersByRecipient())) + .onConflict(URL, KEY) + .doUpdate() + .set(HEADER_BLOB_ID, mimeMessagePartsId.getHeaderBlobId().asString()) + .set(BODY_BLOB_ID, mimeMessagePartsId.getBodyBlobId().asString()) + .set(STATE, mail.getState()) + .set(ERROR, mail.getErrorMessage()) + .set(SENDER, mail.getMaybeSender().asString()) + .set(RECIPIENTS, asStringArray(mail.getRecipients())) + .set(REMOTE_ADDRESS, mail.getRemoteAddr()) + .set(REMOTE_HOST, mail.getRemoteHost()) + .set(LAST_UPDATED, DATE_TO_LOCAL_DATE_TIME.apply(mail.getLastUpdated())) + .set(ATTRIBUTES, asHstore(mail.attributes())) + .set(PER_RECIPIENT_SPECIFIC_HEADERS, asHstore(mail.getPerRecipientSpecificHeaders().getHeadersByRecipient())) + )) + .thenReturn(mailKey); + } + + private Consumer auditTrailStoredMail(Mail mail) { + return Throwing.consumer(any -> AuditTrail.entry() + .protocol("mailrepository") + .action("store") + .parameters(Throwing.supplier(() -> ImmutableMap.of("mailId", mail.getName(), + "mimeMessageId", Optional.ofNullable(mail.getMessage()) + .map(Throwing.function(MimeMessage::getMessageID)) + .orElse(""), + "sender", mail.getMaybeSender().asString(), + "recipients", StringUtils.join(mail.getRecipients())))) + .log("PostgresMailRepository stored mail.")); + } + + private String[] asStringArray(Collection mailAddresses) { + return mailAddresses.stream() + .map(MailAddress::asString) + .toArray(String[]::new); + } + + private Hstore asHstore(Multimap multimap) { + return Hstore.hstore(multimap + .asMap() + .entrySet() + .stream() + .map(recipientToHeaders -> Pair.of(recipientToHeaders.getKey().asString(), + asString(recipientToHeaders.getValue()))) + .collect(ImmutableMap.toImmutableMap(Pair::getLeft, Pair::getRight))); + } + + private String asString(Collection headers) { + return StringUtils.join(headers.stream() + .map(PerRecipientHeaders.Header::asString) + .collect(ImmutableList.toImmutableList()), HEADERS_SEPARATOR); + } + + private Hstore asHstore(Stream attributes) { + return Hstore.hstore(attributes + .flatMap(attribute -> attribute.getValue() + .toJson() + .map(JsonNode::toString) + .map(value -> Pair.of(attribute.getName().asString(), value)).stream()) + .collect(ImmutableMap.toImmutableMap(Pair::getLeft, Pair::getRight))); + } + + @Override + public Iterator list() throws MessagingException { + return listMailKeys() + .toStream() + .iterator(); + } + + private Flux listMailKeys() { + return postgresExecutor.executeRows(context -> Flux.from(context.select(KEY) + .from(TABLE_NAME) + .where(URL.eq(url.asString())))) + .map(record -> new MailKey(record.get(KEY))); + } + + @Override + public Mail retrieve(MailKey key) { + return postgresExecutor.executeRow(context -> Mono.from(context.select() + .from(TABLE_NAME) + .where(URL.eq(url.asString())) + .and(KEY.eq(key.asString())))) + .flatMap(this::toMail) + .blockOptional() + .orElse(null); + } + + private Mono toMail(Record record) { + return mimeMessageStore.read(toMimeMessagePartsId(record)) + .map(Throwing.function(mimeMessage -> toMail(record, mimeMessage))); + } + + private Mail toMail(Record record, MimeMessage mimeMessage) throws MessagingException { + List recipients = Arrays.stream(record.get(RECIPIENTS)) + .map(Throwing.function(MailAddress::new)) + .collect(ImmutableList.toImmutableList()); + + PerRecipientHeaders perRecipientHeaders = getPerRecipientHeaders(record); + + List attributes = Hstore.hstore(record.get(ATTRIBUTES, LinkedHashMap.class)) + .data() + .entrySet() + .stream() + .map(Throwing.function(entry -> new Attribute(AttributeName.of(entry.getKey()), + AttributeValue.fromJsonString(entry.getValue())))) + .collect(ImmutableList.toImmutableList()); + + MailImpl mail = MailImpl.builder() + .name(record.get(KEY)) + .sender(MaybeSender.getMailSender(record.get(SENDER))) + .addRecipients(recipients) + .lastUpdated(LOCAL_DATE_TIME_DATE_FUNCTION.apply(record.get(LAST_UPDATED, LocalDateTime.class))) + .errorMessage(record.get(ERROR)) + .remoteHost(record.get(REMOTE_HOST)) + .remoteAddr(record.get(REMOTE_ADDRESS)) + .state(record.get(STATE)) + .addAllHeadersForRecipients(perRecipientHeaders) + .addAttributes(attributes) + .build(); + + if (mimeMessage instanceof MimeMessageWrapper) { + mail.setMessageNoCopy((MimeMessageWrapper) mimeMessage); + } else { + mail.setMessage(mimeMessage); + } + + return mail; + } + + private PerRecipientHeaders getPerRecipientHeaders(Record record) { + PerRecipientHeaders perRecipientHeaders = new PerRecipientHeaders(); + + Hstore.hstore(record.get(PER_RECIPIENT_SPECIFIC_HEADERS, LinkedHashMap.class)) + .data() + .entrySet() + .stream() + .flatMap(this::recipientToHeaderStream) + .forEach(recipientToHeaderPair -> perRecipientHeaders.addHeaderForRecipient( + recipientToHeaderPair.getRight(), + recipientToHeaderPair.getLeft())); + + return perRecipientHeaders; + } + + private Stream> recipientToHeaderStream(Map.Entry recipientToHeadersString) { + List headers = Splitter.on(HEADERS_SEPARATOR) + .splitToList(recipientToHeadersString.getValue()); + + return headers + .stream() + .map(headerAsString -> Pair.of( + asMailAddress(recipientToHeadersString.getKey()), + PerRecipientHeaders.Header.fromString(headerAsString))); + } + + private MailAddress asMailAddress(String mailAddress) { + return Throwing.supplier(() -> new MailAddress(mailAddress)) + .get(); + } + + private MimeMessagePartsId toMimeMessagePartsId(Record record) { + return MimeMessagePartsId.builder() + .headerBlobId(blobIdFactory.from(record.get(HEADER_BLOB_ID))) + .bodyBlobId(blobIdFactory.from(record.get(BODY_BLOB_ID))) + .build(); + } + + @Override + public void remove(MailKey key) { + removeReactive(key).block(); + } + + private Mono removeReactive(MailKey key) { + return getMimeMessagePartsId(key) + .flatMap(mimeMessagePartsId -> deleteMailMetadata(key) + .then(deleteMailBlob(mimeMessagePartsId))); + } + + private Mono getMimeMessagePartsId(MailKey key) { + return postgresExecutor.executeRow(context -> Mono.from(context.select(HEADER_BLOB_ID, BODY_BLOB_ID) + .from(TABLE_NAME) + .where(URL.eq(url.asString())) + .and(KEY.eq(key.asString())))) + .map(this::toMimeMessagePartsId); + } + + private Mono deleteMailMetadata(MailKey key) { + return postgresExecutor.executeVoid(context -> Mono.from(context.deleteFrom(TABLE_NAME) + .where(URL.eq(url.asString())) + .and(KEY.eq(key.asString())))); + } + + private Mono deleteMailBlob(MimeMessagePartsId mimeMessagePartsId) { + return Mono.from(mimeMessageStore.delete(mimeMessagePartsId)); + } + + @Override + public void remove(Collection keys) { + Flux.fromIterable(keys) + .concatMap(this::removeReactive) + .then() + .block(); + } + + @Override + public void removeAll() { + listMailKeys() + .flatMap(this::removeReactive, DEFAULT_CONCURRENCY) + .then() + .block(); + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryFactory.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryFactory.java new file mode 100644 index 00000000000..d947775d9bf --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryFactory.java @@ -0,0 +1,52 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailrepository.postgres; + +import javax.inject.Inject; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.mail.MimeMessageStore; +import org.apache.james.mailrepository.api.MailRepository; +import org.apache.james.mailrepository.api.MailRepositoryFactory; +import org.apache.james.mailrepository.api.MailRepositoryUrl; + +public class PostgresMailRepositoryFactory implements MailRepositoryFactory { + private final PostgresExecutor executor; + private final MimeMessageStore.Factory mimeMessageStoreFactory; + private final BlobId.Factory blobIdFactory; + + @Inject + public PostgresMailRepositoryFactory(PostgresExecutor executor, MimeMessageStore.Factory mimeMessageStoreFactory, BlobId.Factory blobIdFactory) { + this.executor = executor; + this.mimeMessageStoreFactory = mimeMessageStoreFactory; + this.blobIdFactory = blobIdFactory; + } + + @Override + public Class mailRepositoryClass() { + return PostgresMailRepository.class; + } + + @Override + public MailRepository create(MailRepositoryUrl url) { + return new PostgresMailRepository(executor, url, mimeMessageStoreFactory, blobIdFactory); + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryModule.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryModule.java index 2bfafd5284c..cf923561dda 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryModule.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryModule.java @@ -19,6 +19,11 @@ package org.apache.james.mailrepository.postgres; +import static org.apache.james.backends.postgres.PostgresCommons.DataTypes.HSTORE; + +import java.time.LocalDateTime; + +import org.apache.james.backends.postgres.PostgresCommons; import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.backends.postgres.PostgresTable; import org.jooq.Field; @@ -26,6 +31,7 @@ import org.jooq.Table; import org.jooq.impl.DSL; import org.jooq.impl.SQLDataType; +import org.jooq.postgres.extensions.types.Hstore; public interface PostgresMailRepositoryModule { interface PostgresMailRepositoryUrlTable { @@ -40,7 +46,44 @@ interface PostgresMailRepositoryUrlTable { .disableRowLevelSecurity(); } + interface PostgresMailRepositoryContentTable { + Table TABLE_NAME = DSL.table("mail_repository_content"); + + Field URL = DSL.field("url", SQLDataType.VARCHAR(255).notNull()); + Field KEY = DSL.field("key", SQLDataType.VARCHAR.notNull()); + Field STATE = DSL.field("state", SQLDataType.VARCHAR.notNull()); + Field ERROR = DSL.field("error", SQLDataType.VARCHAR); + Field HEADER_BLOB_ID = DSL.field("header_blob_id", SQLDataType.VARCHAR.notNull()); + Field BODY_BLOB_ID = DSL.field("body_blob_id", SQLDataType.VARCHAR.notNull()); + Field ATTRIBUTES = DSL.field("attributes", HSTORE.notNull()); + Field SENDER = DSL.field("sender", SQLDataType.VARCHAR); + Field RECIPIENTS = DSL.field("recipients", SQLDataType.VARCHAR.getArrayDataType().notNull()); + Field REMOTE_HOST = DSL.field("remote_host", SQLDataType.VARCHAR.notNull()); + Field REMOTE_ADDRESS = DSL.field("remote_address", SQLDataType.VARCHAR.notNull()); + Field LAST_UPDATED = DSL.field("last_updated", PostgresCommons.DataTypes.TIMESTAMP.notNull()); + Field PER_RECIPIENT_SPECIFIC_HEADERS = DSL.field("per_recipient_specific_headers", HSTORE.notNull()); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(URL) + .column(KEY) + .column(STATE) + .column(ERROR) + .column(HEADER_BLOB_ID) + .column(BODY_BLOB_ID) + .column(ATTRIBUTES) + .column(SENDER) + .column(RECIPIENTS) + .column(REMOTE_HOST) + .column(REMOTE_ADDRESS) + .column(LAST_UPDATED) + .column(PER_RECIPIENT_SPECIFIC_HEADERS) + .primaryKey(URL, KEY))) + .disableRowLevelSecurity(); + } + PostgresModule MODULE = PostgresModule.builder() .addTable(PostgresMailRepositoryUrlTable.TABLE) + .addTable(PostgresMailRepositoryContentTable.TABLE) .build(); } diff --git a/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryTest.java b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryTest.java new file mode 100644 index 00000000000..71ba41f5de8 --- /dev/null +++ b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryTest.java @@ -0,0 +1,63 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailrepository.postgres; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.blob.api.BlobStore; +import org.apache.james.blob.api.TestBlobId; +import org.apache.james.blob.mail.MimeMessageStore; +import org.apache.james.blob.memory.MemoryBlobStoreFactory; +import org.apache.james.mailrepository.MailRepositoryContract; +import org.apache.james.mailrepository.api.MailRepository; +import org.apache.james.mailrepository.api.MailRepositoryPath; +import org.apache.james.mailrepository.api.MailRepositoryUrl; +import org.apache.james.mailrepository.api.Protocol; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresMailRepositoryTest implements MailRepositoryContract { + static final TestBlobId.Factory BLOB_ID_FACTORY = new TestBlobId.Factory(); + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresModule.aggregateModules(PostgresMailRepositoryModule.MODULE)); + + private PostgresMailRepository mailRepository; + + @BeforeEach + void setUp() { + mailRepository = retrieveRepository(MailRepositoryPath.from("testrepo")); + } + + @Override + public MailRepository retrieveRepository() { + return mailRepository; + } + + @Override + public PostgresMailRepository retrieveRepository(MailRepositoryPath path) { + MailRepositoryUrl url = MailRepositoryUrl.fromPathAndProtocol(new Protocol("postgres"), path); + BlobStore blobStore = MemoryBlobStoreFactory.builder() + .blobIdFactory(BLOB_ID_FACTORY) + .defaultBucketName() + .passthrough(); + return new PostgresMailRepository(postgresExtension.getPostgresExecutor(), url, MimeMessageStore.factory(blobStore), BLOB_ID_FACTORY); + } +} From 1f29fd519bf4e63129e4bebd590e932529e3c24b Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Mon, 18 Dec 2023 14:54:53 +0700 Subject: [PATCH 132/341] JAMES-2586 Guice binding for PostgresMailRepository + remove related JPA code --- .../main/resources/META-INF/persistence.xml | 2 - .../data/PostgresMailRepositoryModule.java | 10 +- server/data/data-postgres/pom.xml | 7 - .../mailrepository/jpa/JPAMailRepository.java | 407 ------------------ .../jpa/JPAMailRepositoryFactory.java | 52 --- .../jpa/MimeMessageJPASource.java | 54 --- .../mailrepository/jpa/model/JPAMail.java | 246 ----------- .../postgres/PostgresMailRepository.java | 2 + .../jpa/JPAMailRepositoryTest.java | 70 --- .../src/test/resources/persistence.xml | 1 - 10 files changed, 7 insertions(+), 844 deletions(-) delete mode 100644 server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/JPAMailRepository.java delete mode 100644 server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryFactory.java delete mode 100644 server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/MimeMessageJPASource.java delete mode 100644 server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/model/JPAMail.java delete mode 100644 server/data/data-postgres/src/test/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryTest.java diff --git a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml index 9c79e80651b..80b798e907e 100644 --- a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml +++ b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml @@ -24,8 +24,6 @@ version="2.0"> - org.apache.james.mailrepository.jpa.model.JPAMail - diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresMailRepositoryModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresMailRepositoryModule.java index b0c33698153..f0bbbfa3ae9 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresMailRepositoryModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresMailRepositoryModule.java @@ -24,9 +24,9 @@ import org.apache.james.mailrepository.api.MailRepositoryFactory; import org.apache.james.mailrepository.api.MailRepositoryUrlStore; import org.apache.james.mailrepository.api.Protocol; -import org.apache.james.mailrepository.jpa.JPAMailRepository; -import org.apache.james.mailrepository.jpa.JPAMailRepositoryFactory; import org.apache.james.mailrepository.memory.MailRepositoryStoreConfiguration; +import org.apache.james.mailrepository.postgres.PostgresMailRepository; +import org.apache.james.mailrepository.postgres.PostgresMailRepositoryFactory; import org.apache.james.mailrepository.postgres.PostgresMailRepositoryUrlStore; import com.google.common.collect.ImmutableList; @@ -43,12 +43,12 @@ protected void configure() { bind(MailRepositoryStoreConfiguration.Item.class) .toProvider(() -> new MailRepositoryStoreConfiguration.Item( - ImmutableList.of(new Protocol("jpa")), - JPAMailRepository.class.getName(), + ImmutableList.of(new Protocol("postgres")), + PostgresMailRepository.class.getName(), new BaseHierarchicalConfiguration())); Multibinder.newSetBinder(binder(), MailRepositoryFactory.class) - .addBinding().to(JPAMailRepositoryFactory.class); + .addBinding().to(PostgresMailRepositoryFactory.class); Multibinder.newSetBinder(binder(), PostgresModule.class) .addBinding().toInstance(org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.MODULE); } diff --git a/server/data/data-postgres/pom.xml b/server/data/data-postgres/pom.xml index 31a1d2a70ef..107a939364d 100644 --- a/server/data/data-postgres/pom.xml +++ b/server/data/data-postgres/pom.xml @@ -170,8 +170,6 @@ openjpa-maven-plugin ${apache.openjpa.version} - org/apache/james/mailrepository/jpa/model/JPAUrl.class, - org/apache/james/mailrepository/jpa/model/JPAMail.class true true @@ -179,11 +177,6 @@ log TOOL=TRACE - - metaDataFactory - jpa(Types=org.apache.james.mailrepository.jpa.model.JPAUrl; - org.apache.james.mailrepository.jpa.model.JPAMail) - ${basedir}/src/test/resources/persistence.xml diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/JPAMailRepository.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/JPAMailRepository.java deleted file mode 100644 index a70b4be6f7b..00000000000 --- a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/JPAMailRepository.java +++ /dev/null @@ -1,407 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailrepository.jpa; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.sql.Timestamp; -import java.util.Collection; -import java.util.Collections; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.StringTokenizer; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import javax.annotation.PostConstruct; -import javax.inject.Inject; -import javax.mail.MessagingException; -import javax.mail.internet.AddressException; -import javax.mail.internet.MimeMessage; -import javax.persistence.EntityManager; -import javax.persistence.EntityManagerFactory; -import javax.persistence.EntityTransaction; -import javax.persistence.NoResultException; - -import org.apache.commons.configuration2.HierarchicalConfiguration; -import org.apache.commons.configuration2.ex.ConfigurationException; -import org.apache.commons.configuration2.tree.ImmutableNode; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.tuple.Pair; -import org.apache.james.backends.jpa.EntityManagerUtils; -import org.apache.james.core.MailAddress; -import org.apache.james.lifecycle.api.Configurable; -import org.apache.james.mailrepository.api.Initializable; -import org.apache.james.mailrepository.api.MailKey; -import org.apache.james.mailrepository.api.MailRepository; -import org.apache.james.mailrepository.api.MailRepositoryUrl; -import org.apache.james.mailrepository.jpa.model.JPAMail; -import org.apache.james.server.core.MailImpl; -import org.apache.james.server.core.MimeMessageWrapper; -import org.apache.james.util.AuditTrail; -import org.apache.james.util.streams.Iterators; -import org.apache.mailet.Attribute; -import org.apache.mailet.AttributeName; -import org.apache.mailet.AttributeValue; -import org.apache.mailet.Mail; -import org.apache.mailet.PerRecipientHeaders; -import org.apache.mailet.PerRecipientHeaders.Header; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.JsonNodeFactory; -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.github.fge.lambdas.Throwing; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; - -/** - * Implementation of a MailRepository on a database via JPA. - */ -public class JPAMailRepository implements MailRepository, Configurable, Initializable { - private static final Logger LOGGER = LoggerFactory.getLogger(JPAMailRepository.class); - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - - private String repositoryName; - - private final EntityManagerFactory entityManagerFactory; - - @Inject - public JPAMailRepository(EntityManagerFactory entityManagerFactory) { - this.entityManagerFactory = entityManagerFactory; - } - - public JPAMailRepository(EntityManagerFactory entityManagerFactory, MailRepositoryUrl url) throws ConfigurationException { - this.entityManagerFactory = entityManagerFactory; - this.repositoryName = url.getPath().asString(); - if (repositoryName.isEmpty()) { - throw new ConfigurationException( - "Malformed destinationURL - Must be of the format 'jpa://'. Was passed " + url); - } - } - - public String getRepositoryName() { - return repositoryName; - } - - // note: caller must close the returned EntityManager when done using it - protected EntityManager entityManager() { - return entityManagerFactory.createEntityManager(); - } - - @Override - public void configure(HierarchicalConfiguration configuration) throws ConfigurationException { - LOGGER.debug("{}.configure()", getClass().getName()); - String destination = configuration.getString("[@destinationURL]"); - MailRepositoryUrl url = MailRepositoryUrl.from(destination); // also validates url and standardizes slashes - repositoryName = url.getPath().asString(); - if (repositoryName.isEmpty()) { - throw new ConfigurationException( - "Malformed destinationURL - Must be of the format 'jpa://'. Was passed " + url); - } - LOGGER.debug("Parsed URL: repositoryName = '{}'", repositoryName); - } - - /** - * Initialises the JPA repository. - * - * @throws Exception if an error occurs - */ - @Override - @PostConstruct - public void init() throws Exception { - LOGGER.debug("{}.initialize()", getClass().getName()); - list(); - } - - @Override - public MailKey store(Mail mail) throws MessagingException { - MailKey key = MailKey.forMail(mail); - EntityManager entityManager = entityManager(); - try { - JPAMail jpaMail = new JPAMail(); - jpaMail.setRepositoryName(repositoryName); - jpaMail.setMessageName(mail.getName()); - jpaMail.setMessageState(mail.getState()); - jpaMail.setErrorMessage(mail.getErrorMessage()); - if (!mail.getMaybeSender().isNullSender()) { - jpaMail.setSender(mail.getMaybeSender().get().toString()); - } - String recipients = mail.getRecipients().stream() - .map(MailAddress::toString) - .collect(Collectors.joining("\r\n")); - jpaMail.setRecipients(recipients); - jpaMail.setRemoteHost(mail.getRemoteHost()); - jpaMail.setRemoteAddr(mail.getRemoteAddr()); - jpaMail.setPerRecipientHeaders(serializePerRecipientHeaders(mail.getPerRecipientSpecificHeaders())); - jpaMail.setLastUpdated(new Timestamp(mail.getLastUpdated().getTime())); - jpaMail.setMessageBody(getBody(mail)); - jpaMail.setMessageAttributes(serializeAttributes(mail.attributes())); - EntityTransaction transaction = entityManager.getTransaction(); - transaction.begin(); - jpaMail = entityManager.merge(jpaMail); - transaction.commit(); - - AuditTrail.entry() - .protocol("mailrepository") - .action("store") - .parameters(Throwing.supplier(() -> ImmutableMap.of("mailId", mail.getName(), - "mimeMessageId", Optional.ofNullable(mail.getMessage()) - .map(Throwing.function(MimeMessage::getMessageID)) - .orElse(""), - "sender", mail.getMaybeSender().asString(), - "recipients", StringUtils.join(mail.getRecipients())))) - .log("JPAMailRepository stored mail."); - - return key; - } catch (MessagingException e) { - LOGGER.error("Exception caught while storing mail {}", key, e); - throw e; - } catch (Exception e) { - LOGGER.error("Exception caught while storing mail {}", key, e); - throw new MessagingException("Exception caught while storing mail " + key, e); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - private byte[] getBody(Mail mail) throws MessagingException, IOException { - ByteArrayOutputStream out = new ByteArrayOutputStream((int)mail.getMessageSize()); - if (mail instanceof MimeMessageWrapper) { - // we need to force the loading of the message from the - // stream as we want to override the old message - ((MimeMessageWrapper) mail).loadMessage(); - ((MimeMessageWrapper) mail).writeTo(out, out, null, true); - } else { - mail.getMessage().writeTo(out); - } - return out.toByteArray(); - } - - private String serializeAttributes(Stream attributes) { - Map map = attributes - .flatMap(entry -> entry.getValue().toJson().map(value -> Pair.of(entry.getName().asString(), value)).stream()) - .collect(ImmutableMap.toImmutableMap(Pair::getKey, Pair::getValue)); - - return new ObjectNode(JsonNodeFactory.instance, map).toString(); - } - - private List deserializeAttributes(String data) { - try { - JsonNode jsonNode = OBJECT_MAPPER.readTree(data); - if (jsonNode instanceof ObjectNode) { - ObjectNode objectNode = (ObjectNode) jsonNode; - - return Iterators.toStream(objectNode.fields()) - .map(entry -> new Attribute(AttributeName.of(entry.getKey()), AttributeValue.fromJson(entry.getValue()))) - .collect(ImmutableList.toImmutableList()); - } - throw new IllegalArgumentException("JSON object corresponding to mail attibutes must be a JSON object"); - } catch (JsonProcessingException e) { - throw new IllegalArgumentException("Mail attributes is not a valid JSON object", e); - } - } - - private String serializePerRecipientHeaders(PerRecipientHeaders perRecipientHeaders) { - if (perRecipientHeaders == null) { - return null; - } - Map> map = perRecipientHeaders.getHeadersByRecipient().asMap(); - if (map.isEmpty()) { - return null; - } - ObjectNode node = JsonNodeFactory.instance.objectNode(); - for (Map.Entry> entry : map.entrySet()) { - String recipient = entry.getKey().asString(); - ObjectNode headers = node.putObject(recipient); - entry.getValue().forEach(header -> headers.put(header.getName(), header.getValue())); - } - return node.toString(); - } - - private PerRecipientHeaders deserializePerRecipientHeaders(String data) { - if (data == null || data.isEmpty()) { - return null; - } - PerRecipientHeaders perRecipientHeaders = new PerRecipientHeaders(); - try { - JsonNode node = OBJECT_MAPPER.readTree(data); - if (node instanceof ObjectNode) { - ObjectNode objectNode = (ObjectNode) node; - Iterators.toStream(objectNode.fields()).forEach( - entry -> addPerRecipientHeaders(perRecipientHeaders, entry.getKey(), entry.getValue())); - return perRecipientHeaders; - } - throw new IllegalArgumentException("JSON object corresponding to recipient headers must be a JSON object"); - } catch (JsonProcessingException e) { - throw new IllegalArgumentException("per recipient headers is not a valid JSON object", e); - } - } - - private void addPerRecipientHeaders(PerRecipientHeaders perRecipientHeaders, String recipient, JsonNode headers) { - try { - MailAddress address = new MailAddress(recipient); - Iterators.toStream(headers.fields()).forEach( - entry -> { - String name = entry.getKey(); - String value = entry.getValue().textValue(); - Header header = Header.builder().name(name).value(value).build(); - perRecipientHeaders.addHeaderForRecipient(header, address); - }); - } catch (AddressException ae) { - throw new IllegalArgumentException("invalid recipient address", ae); - } - } - - @Override - public Mail retrieve(MailKey key) throws MessagingException { - EntityManager entityManager = entityManager(); - try { - JPAMail jpaMail = entityManager.createNamedQuery("findMailMessage", JPAMail.class) - .setParameter("repositoryName", repositoryName) - .setParameter("messageName", key.asString()) - .getSingleResult(); - - MailImpl.Builder mail = MailImpl.builder().name(key.asString()); - if (jpaMail.getMessageAttributes() != null) { - mail.addAttributes(deserializeAttributes(jpaMail.getMessageAttributes())); - } - mail.state(jpaMail.getMessageState()); - mail.errorMessage(jpaMail.getErrorMessage()); - String sender = jpaMail.getSender(); - if (sender == null) { - mail.sender((MailAddress)null); - } else { - mail.sender(new MailAddress(sender)); - } - StringTokenizer st = new StringTokenizer(jpaMail.getRecipients(), "\r\n", false); - while (st.hasMoreTokens()) { - mail.addRecipient(st.nextToken()); - } - mail.remoteHost(jpaMail.getRemoteHost()); - mail.remoteAddr(jpaMail.getRemoteAddr()); - PerRecipientHeaders perRecipientHeaders = deserializePerRecipientHeaders(jpaMail.getPerRecipientHeaders()); - if (perRecipientHeaders != null) { - mail.addAllHeadersForRecipients(perRecipientHeaders); - } - mail.lastUpdated(jpaMail.getLastUpdated()); - - MimeMessageJPASource source = new MimeMessageJPASource(this, key.asString(), jpaMail.getMessageBody()); - MimeMessageWrapper message = new MimeMessageWrapper(source); - mail.mimeMessage(message); - return mail.build(); - } catch (NoResultException nre) { - LOGGER.debug("Did not find mail {} in repository {}", key, repositoryName); - return null; - } catch (Exception e) { - throw new MessagingException("Exception while retrieving mail: " + e.getMessage(), e); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - @Override - public long size() throws MessagingException { - EntityManager entityManager = entityManager(); - try { - return entityManager.createNamedQuery("countMailMessages", long.class) - .setParameter("repositoryName", repositoryName) - .getSingleResult(); - } catch (Exception me) { - throw new MessagingException("Exception while listing messages: " + me.getMessage(), me); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - @Override - public Iterator list() throws MessagingException { - EntityManager entityManager = entityManager(); - try { - return entityManager.createNamedQuery("listMailMessages", String.class) - .setParameter("repositoryName", repositoryName) - .getResultStream() - .map(MailKey::new) - .iterator(); - } catch (Exception me) { - throw new MessagingException("Exception while listing messages: " + me.getMessage(), me); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - @Override - public void remove(MailKey key) throws MessagingException { - remove(Collections.singleton(key)); - } - - @Override - public void remove(Collection keys) throws MessagingException { - Collection messageNames = keys.stream().map(MailKey::asString).collect(Collectors.toList()); - EntityManager entityManager = entityManager(); - EntityTransaction transaction = entityManager.getTransaction(); - transaction.begin(); - try { - entityManager.createNamedQuery("deleteMailMessages") - .setParameter("repositoryName", repositoryName) - .setParameter("messageNames", messageNames) - .executeUpdate(); - transaction.commit(); - } catch (Exception e) { - throw new MessagingException("Exception while removing message(s): " + e.getMessage(), e); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - @Override - public void removeAll() throws MessagingException { - EntityManager entityManager = entityManager(); - EntityTransaction transaction = entityManager.getTransaction(); - transaction.begin(); - try { - entityManager.createNamedQuery("deleteAllMailMessages") - .setParameter("repositoryName", repositoryName) - .executeUpdate(); - transaction.commit(); - } catch (Exception e) { - throw new MessagingException("Exception while removing message(s): " + e.getMessage(), e); - } finally { - EntityManagerUtils.safelyClose(entityManager); - } - } - - @Override - public boolean equals(Object obj) { - return obj instanceof JPAMailRepository - && Objects.equals(repositoryName, ((JPAMailRepository)obj).repositoryName); - } - - @Override - public int hashCode() { - return Objects.hash(repositoryName); - } -} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryFactory.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryFactory.java deleted file mode 100644 index 09bb004ef88..00000000000 --- a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryFactory.java +++ /dev/null @@ -1,52 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailrepository.jpa; - -import javax.inject.Inject; -import javax.persistence.EntityManagerFactory; - -import org.apache.james.mailrepository.api.MailRepository; -import org.apache.james.mailrepository.api.MailRepositoryFactory; -import org.apache.james.mailrepository.api.MailRepositoryUrl; - -import com.github.fge.lambdas.Throwing; - -public class JPAMailRepositoryFactory implements MailRepositoryFactory { - private final EntityManagerFactory entityManagerFactory; - - @Inject - public JPAMailRepositoryFactory(EntityManagerFactory entityManagerFactory) { - this.entityManagerFactory = entityManagerFactory; - } - - @Override - public Class mailRepositoryClass() { - return JPAMailRepository.class; - } - - @Override - public MailRepository create(MailRepositoryUrl url) { - // Injecting the url here is redundant since the class is also a - // Configurable and the mail repository store will call #configure() - // with the same effect. However, this paves the way to drop the - // Configurable aspect in the future. - return Throwing.supplier(() -> new JPAMailRepository(entityManagerFactory, url)).sneakyThrow().get(); - } -} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/MimeMessageJPASource.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/MimeMessageJPASource.java deleted file mode 100644 index f5445c279c5..00000000000 --- a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/MimeMessageJPASource.java +++ /dev/null @@ -1,54 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailrepository.jpa; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; - -import org.apache.james.server.core.MimeMessageSource; - -public class MimeMessageJPASource implements MimeMessageSource { - - private final JPAMailRepository jpaMailRepository; - private final String key; - private final byte[] body; - - public MimeMessageJPASource(JPAMailRepository jpaMailRepository, String key, byte[] body) { - this.jpaMailRepository = jpaMailRepository; - this.key = key; - this.body = body; - } - - @Override - public String getSourceId() { - return jpaMailRepository.getRepositoryName() + "/" + key; - } - - @Override - public InputStream getInputStream() throws IOException { - return new ByteArrayInputStream(body); - } - - @Override - public long getMessageSize() throws IOException { - return body.length; - } -} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/model/JPAMail.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/model/JPAMail.java deleted file mode 100644 index 187241dfcb8..00000000000 --- a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/jpa/model/JPAMail.java +++ /dev/null @@ -1,246 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailrepository.jpa.model; - -import java.io.Serializable; -import java.sql.Timestamp; -import java.util.Objects; - -import javax.persistence.Basic; -import javax.persistence.Column; -import javax.persistence.Entity; -import javax.persistence.FetchType; -import javax.persistence.Id; -import javax.persistence.IdClass; -import javax.persistence.Index; -import javax.persistence.Lob; -import javax.persistence.NamedQueries; -import javax.persistence.NamedQuery; -import javax.persistence.Table; - -@Entity(name = "JamesMailStore") -@IdClass(JPAMail.JPAMailId.class) -@Table(name = "JAMES_MAIL_STORE", indexes = { - @Index(name = "REPOSITORY_NAME_MESSAGE_NAME_INDEX", columnList = "REPOSITORY_NAME, MESSAGE_NAME") -}) -@NamedQueries({ - @NamedQuery(name = "listMailMessages", - query = "SELECT mail.messageName FROM JamesMailStore mail WHERE mail.repositoryName = :repositoryName"), - @NamedQuery(name = "countMailMessages", - query = "SELECT COUNT(mail) FROM JamesMailStore mail WHERE mail.repositoryName = :repositoryName"), - @NamedQuery(name = "deleteMailMessages", - query = "DELETE FROM JamesMailStore mail WHERE mail.repositoryName = :repositoryName AND mail.messageName IN (:messageNames)"), - @NamedQuery(name = "deleteAllMailMessages", - query = "DELETE FROM JamesMailStore mail WHERE mail.repositoryName = :repositoryName"), - @NamedQuery(name = "findMailMessage", - query = "SELECT mail FROM JamesMailStore mail WHERE mail.repositoryName = :repositoryName AND mail.messageName = :messageName") -}) -public class JPAMail { - - static class JPAMailId implements Serializable { - public JPAMailId() { - } - - String repositoryName; - String messageName; - - public boolean equals(Object obj) { - return obj instanceof JPAMailId - && Objects.equals(messageName, ((JPAMailId) obj).messageName) - && Objects.equals(repositoryName, ((JPAMailId) obj).repositoryName); - } - - public int hashCode() { - return Objects.hash(messageName, repositoryName); - } - } - - @Id - @Basic(optional = false) - @Column(name = "REPOSITORY_NAME", nullable = false, length = 255) - private String repositoryName; - - @Id - @Basic(optional = false) - @Column(name = "MESSAGE_NAME", nullable = false, length = 200) - private String messageName; - - @Basic(optional = false) - @Column(name = "MESSAGE_STATE", nullable = false, length = 30) - private String messageState; - - @Basic(optional = true) - @Column(name = "ERROR_MESSAGE", nullable = true, length = 200) - private String errorMessage; - - @Basic(optional = true) - @Column(name = "SENDER", nullable = true, length = 255) - private String sender; - - @Basic(optional = false) - @Column(name = "RECIPIENTS", nullable = false) - private String recipients; // CRLF delimited - - @Basic(optional = false) - @Column(name = "REMOTE_HOST", nullable = false, length = 255) - private String remoteHost; - - @Basic(optional = false) - @Column(name = "REMOTE_ADDR", nullable = false, length = 20) - private String remoteAddr; - - @Basic(optional = false) - @Column(name = "LAST_UPDATED", nullable = false) - private Timestamp lastUpdated; - - @Basic(optional = true) - @Column(name = "PER_RECIPIENT_HEADERS", nullable = true, length = 10485760) - @Lob - private String perRecipientHeaders; - - @Basic(optional = false, fetch = FetchType.LAZY) - @Column(name = "MESSAGE_BODY", nullable = false, length = 1048576000) - @Lob - private byte[] messageBody; // TODO: support streaming body where possible (see e.g. JPAStreamingMailboxMessage) - - @Basic(optional = true) - @Column(name = "MESSAGE_ATTRIBUTES", nullable = true, length = 10485760) - @Lob - private String messageAttributes; - - public JPAMail() { - } - - public String getRepositoryName() { - return repositoryName; - } - - public void setRepositoryName(String repositoryName) { - this.repositoryName = repositoryName; - } - - public String getMessageName() { - return messageName; - } - - public void setMessageName(String messageName) { - this.messageName = messageName; - } - - public String getMessageState() { - return messageState; - } - - public void setMessageState(String messageState) { - this.messageState = messageState; - } - - public String getErrorMessage() { - return errorMessage; - } - - public void setErrorMessage(String errorMessage) { - this.errorMessage = errorMessage; - } - - public String getSender() { - return sender; - } - - public void setSender(String sender) { - this.sender = sender; - } - - public String getRecipients() { - return recipients; - } - - public void setRecipients(String recipients) { - this.recipients = recipients; - } - - public String getRemoteHost() { - return remoteHost; - } - - public void setRemoteHost(String remoteHost) { - this.remoteHost = remoteHost; - } - - public String getRemoteAddr() { - return remoteAddr; - } - - public void setRemoteAddr(String remoteAddr) { - this.remoteAddr = remoteAddr; - } - - public Timestamp getLastUpdated() { - return lastUpdated; - } - - public void setLastUpdated(Timestamp lastUpdated) { - this.lastUpdated = lastUpdated; - } - - public String getPerRecipientHeaders() { - return perRecipientHeaders; - } - - public void setPerRecipientHeaders(String perRecipientHeaders) { - this.perRecipientHeaders = perRecipientHeaders; - } - - public byte[] getMessageBody() { - return messageBody; - } - - public void setMessageBody(byte[] messageBody) { - this.messageBody = messageBody; - } - - public String getMessageAttributes() { - return messageAttributes; - } - - public void setMessageAttributes(String messageAttributes) { - this.messageAttributes = messageAttributes; - } - - @Override - public String toString() { - return "JPAMail ( " - + "repositoryName = " + repositoryName - + ", messageName = " + messageName - + " )"; - } - - @Override - public final boolean equals(Object obj) { - return obj instanceof JPAMail - && Objects.equals(this.repositoryName, ((JPAMail)obj).repositoryName) - && Objects.equals(this.messageName, ((JPAMail)obj).messageName); - } - - @Override - public final int hashCode() { - return Objects.hash(repositoryName, messageName); - } -} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepository.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepository.java index c899f7da512..241fb215368 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepository.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepository.java @@ -48,6 +48,7 @@ import java.util.function.Consumer; import java.util.stream.Stream; +import javax.inject.Inject; import javax.mail.MessagingException; import javax.mail.internet.MimeMessage; @@ -93,6 +94,7 @@ public class PostgresMailRepository implements MailRepository { private final Store mimeMessageStore; private final BlobId.Factory blobIdFactory; + @Inject public PostgresMailRepository(PostgresExecutor postgresExecutor, MailRepositoryUrl url, MimeMessageStore.Factory mimeMessageStoreFactory, diff --git a/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryTest.java b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryTest.java deleted file mode 100644 index 3c41aa53abe..00000000000 --- a/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/jpa/JPAMailRepositoryTest.java +++ /dev/null @@ -1,70 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailrepository.jpa; - -import org.apache.commons.configuration2.BaseHierarchicalConfiguration; -import org.apache.james.backends.jpa.JpaTestCluster; -import org.apache.james.mailrepository.MailRepositoryContract; -import org.apache.james.mailrepository.api.MailRepository; -import org.apache.james.mailrepository.api.MailRepositoryPath; -import org.apache.james.mailrepository.api.MailRepositoryUrl; -import org.apache.james.mailrepository.api.Protocol; -import org.apache.james.mailrepository.jpa.model.JPAMail; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; - -public class JPAMailRepositoryTest implements MailRepositoryContract { - - final JpaTestCluster JPA_TEST_CLUSTER = JpaTestCluster.create(JPAMail.class); - - private JPAMailRepository mailRepository; - - @BeforeEach - void setUp() throws Exception { - mailRepository = retrieveRepository(MailRepositoryPath.from("testrepo")); - } - - @AfterEach - void tearDown() { - JPA_TEST_CLUSTER.clear("JAMES_MAIL_STORE"); - } - - @Override - public MailRepository retrieveRepository() { - return mailRepository; - } - - @Override - public JPAMailRepository retrieveRepository(MailRepositoryPath url) throws Exception { - BaseHierarchicalConfiguration conf = new BaseHierarchicalConfiguration(); - conf.addProperty("[@destinationURL]", MailRepositoryUrl.fromPathAndProtocol(new Protocol("jpa"), url).asString()); - JPAMailRepository mailRepository = new JPAMailRepository(JPA_TEST_CLUSTER.getEntityManagerFactory()); - mailRepository.configure(conf); - mailRepository.init(); - return mailRepository; - } - - @Override - @Disabled("JAMES-3431 No support for Attribute collection Java serialization yet") - public void shouldPreserveDsnParameters() throws Exception { - MailRepositoryContract.super.shouldPreserveDsnParameters(); - } -} diff --git a/server/data/data-postgres/src/test/resources/persistence.xml b/server/data/data-postgres/src/test/resources/persistence.xml index d8441861057..1e66a76c65f 100644 --- a/server/data/data-postgres/src/test/resources/persistence.xml +++ b/server/data/data-postgres/src/test/resources/persistence.xml @@ -26,7 +26,6 @@ org.apache.openjpa.persistence.PersistenceProviderImpl osgi:service/javax.sql.DataSource/(osgi.jndi.service.name=jdbc/james) - org.apache.james.mailrepository.jpa.model.JPAMail true From cbb61b5be92a7144b2e7eeb8deeaa3c6d40b9e09 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Mon, 18 Dec 2023 15:08:41 +0700 Subject: [PATCH 133/341] JAMES-2586 Documentation for PostgresMailRepository --- src/site/xdoc/server/config-mailrepositorystore.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/site/xdoc/server/config-mailrepositorystore.xml b/src/site/xdoc/server/config-mailrepositorystore.xml index 365b559c0e9..d8fd9f285c2 100644 --- a/src/site/xdoc/server/config-mailrepositorystore.xml +++ b/src/site/xdoc/server/config-mailrepositorystore.xml @@ -90,6 +90,12 @@

    Cassandra Guice wiring allows to use the cassandra:// protocol for your ToRepository mailets.

    + + +

    Postgres Guice wiring allows to use the postgres:// protocol for your ToRepository mailets.

    + +

    This repository stores mail metadata in the Postgres database while the headers and body to the blob store.

    +
    From b431e7c9264066150828298825ce16b69c3f5084 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Mon, 18 Dec 2023 15:09:41 +0700 Subject: [PATCH 134/341] JAMES-2586 Updating postgres-app default configuration to PostgresMailRepository --- .../sample-configuration/mailetcontainer.xml | 10 +++++----- .../sample-configuration/mailrepositorystore.xml | 8 +++----- .../src/test/resources/mailetcontainer.xml | 10 +++++----- .../src/test/resources/mailrepositorystore.xml | 4 ++-- 4 files changed, 15 insertions(+), 17 deletions(-) diff --git a/server/apps/postgres-app/sample-configuration/mailetcontainer.xml b/server/apps/postgres-app/sample-configuration/mailetcontainer.xml index 90cbcedef1b..5c6eed887fc 100644 --- a/server/apps/postgres-app/sample-configuration/mailetcontainer.xml +++ b/server/apps/postgres-app/sample-configuration/mailetcontainer.xml @@ -31,7 +31,7 @@ 20 - file://var/mail/error/ + postgres://var/mail/error/ @@ -53,7 +53,7 @@ ignore
    - file://var/mail/error/ + postgres://var/mail/error/ propagate
    @@ -105,7 +105,7 @@ none - file://var/mail/address-error/ + postgres://var/mail/address-error/ @@ -117,7 +117,7 @@ none - file://var/mail/relay-denied/ + postgres://var/mail/relay-denied/ Warning: You are sending an e-mail to a remote server. You must be authenticated to perform such an operation @@ -133,7 +133,7 @@ - file://var/mail/rrt-error/ + postgres://var/mail/rrt-error/ true diff --git a/server/apps/postgres-app/sample-configuration/mailrepositorystore.xml b/server/apps/postgres-app/sample-configuration/mailrepositorystore.xml index 1e04a5f7ef2..445f2727f29 100644 --- a/server/apps/postgres-app/sample-configuration/mailrepositorystore.xml +++ b/server/apps/postgres-app/sample-configuration/mailrepositorystore.xml @@ -22,13 +22,11 @@ - file + postgres - - - + - file + postgres diff --git a/server/apps/postgres-app/src/test/resources/mailetcontainer.xml b/server/apps/postgres-app/src/test/resources/mailetcontainer.xml index b8b531ddfb7..d152c1b1137 100644 --- a/server/apps/postgres-app/src/test/resources/mailetcontainer.xml +++ b/server/apps/postgres-app/src/test/resources/mailetcontainer.xml @@ -27,7 +27,7 @@ 20 - file://var/mail/error/ + postgres://var/mail/error/ @@ -44,7 +44,7 @@ ignore - file://var/mail/error/ + postgres://var/mail/error/ propagate @@ -87,7 +87,7 @@ none - file://var/mail/address-error/ + postgres://var/mail/address-error/ @@ -96,7 +96,7 @@ none - file://var/mail/relay-denied/ + postgres://var/mail/relay-denied/ Warning: You are sending an e-mail to a remote server. You must be authentified to perform such an operation @@ -109,7 +109,7 @@ - file://var/mail/rrt-error/ + postgres://var/mail/rrt-error/ true diff --git a/server/apps/postgres-app/src/test/resources/mailrepositorystore.xml b/server/apps/postgres-app/src/test/resources/mailrepositorystore.xml index 3ca4a1d0056..689745af60f 100644 --- a/server/apps/postgres-app/src/test/resources/mailrepositorystore.xml +++ b/server/apps/postgres-app/src/test/resources/mailrepositorystore.xml @@ -21,9 +21,9 @@ - + - file + postgres From 58d0fa26e39b682e006d6438b50d6da8bbb89eb0 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Mon, 18 Dec 2023 16:22:31 +0700 Subject: [PATCH 135/341] JAMES-2586 Finally remove the rest of JPA in postgres-app --- .../backends/postgres/PostgresExtension.java | 10 -- mailbox/postgres/pom.xml | 5 - mpt/impl/imap-mailbox/postgres/pom.xml | 13 +- server/apps/postgres-app/docker-compose.yml | 3 - server/apps/postgres-app/pom.xml | 9 +- .../james-database-postgres.properties | 49 -------- .../james-database.properties | 53 -------- .../main/resources/META-INF/persistence.xml | 36 ------ .../james/JamesCapabilitiesServerTest.java | 1 - .../apache/james/PostgresJamesServerTest.java | 3 +- .../PostgresWithLDAPJamesServerTest.java | 3 +- .../mailbox/PostgresMailboxModule.java | 4 +- ...le.java => PostgresQuotaSearchModule.java} | 2 +- .../modules/data/JPAAuthorizatorModule.java | 35 ------ .../modules/data/JPAEntityManagerModule.java | 116 ------------------ .../james/TestJPAConfigurationModule.java | 53 -------- ...AConfigurationModuleWithSqlValidation.java | 86 ------------- server/data/data-postgres/pom.xml | 46 ------- .../src/test/resources/persistence.xml | 39 ------ 19 files changed, 6 insertions(+), 560 deletions(-) delete mode 100644 server/apps/postgres-app/sample-configuration/james-database-postgres.properties delete mode 100644 server/apps/postgres-app/sample-configuration/james-database.properties delete mode 100644 server/apps/postgres-app/src/main/resources/META-INF/persistence.xml rename server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/{JPAQuotaSearchModule.java => PostgresQuotaSearchModule.java} (96%) delete mode 100644 server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAAuthorizatorModule.java delete mode 100644 server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAEntityManagerModule.java delete mode 100644 server/container/guice/postgres-common/src/test/java/org/apache/james/TestJPAConfigurationModule.java delete mode 100644 server/container/guice/postgres-common/src/test/java/org/apache/james/TestJPAConfigurationModuleWithSqlValidation.java delete mode 100644 server/data/data-postgres/src/test/resources/persistence.xml diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index e332b9474d3..672a770d6ec 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -35,8 +35,6 @@ import org.junit.jupiter.api.extension.ExtensionContext; import org.testcontainers.containers.PostgreSQLContainer; -import com.github.dockerjava.api.command.PauseContainerCmd; -import com.github.dockerjava.api.command.UnpauseContainerCmd; import com.github.fge.lambdas.Throwing; import com.google.inject.Module; import com.google.inject.util.Modules; @@ -206,14 +204,6 @@ public PostgresExecutor.Factory getExecutorFactory() { return executorFactory; } - public PostgresConfiguration getPostgresConfiguration() { - return postgresConfiguration; - } - - public String getJdbcUrl() { - return String.format("jdbc:postgresql://%s:%d/%s", getHost(), getMappedPort(), postgresConfiguration.getDatabaseName()); - } - private void initTablesAndIndexes() { PostgresTableManager postgresTableManager = new PostgresTableManager(postgresExecutor, postgresModule, postgresConfiguration.rowLevelSecurityEnabled()); postgresTableManager.initializeTables().block(); diff --git a/mailbox/postgres/pom.xml b/mailbox/postgres/pom.xml index e3f348f5856..461018541fc 100644 --- a/mailbox/postgres/pom.xml +++ b/mailbox/postgres/pom.xml @@ -142,11 +142,6 @@ com.sun.mail javax.mail - - org.apache.derby - derby - test - org.jasypt jasypt diff --git a/mpt/impl/imap-mailbox/postgres/pom.xml b/mpt/impl/imap-mailbox/postgres/pom.xml index fe69267b82b..e74409d2001 100644 --- a/mpt/impl/imap-mailbox/postgres/pom.xml +++ b/mpt/impl/imap-mailbox/postgres/pom.xml @@ -29,12 +29,6 @@ Apache James :: MPT :: Imap Mailbox :: Postgres - - ${james.groupId} - apache-james-backends-jpa - test-jar - test - ${james.groupId} apache-james-backends-postgres @@ -109,11 +103,6 @@ testing-base test - - org.apache.derby - derby - test - org.testcontainers postgresql @@ -129,7 +118,7 @@ -Djava.library.path= -javaagent:"${settings.localRepository}"/org/jacoco/org.jacoco.agent/${jacoco-maven-plugin.version}/org.jacoco.agent-${jacoco-maven-plugin.version}-runtime.jar=destfile=${basedir}/target/jacoco.exec - -Xms1024m -Xmx2048m -Dopenjpa.Multithreaded=true + -Xms1024m -Xmx2048m diff --git a/server/apps/postgres-app/docker-compose.yml b/server/apps/postgres-app/docker-compose.yml index d7496c60d41..377cde30d41 100644 --- a/server/apps/postgres-app/docker-compose.yml +++ b/server/apps/postgres-app/docker-compose.yml @@ -14,9 +14,6 @@ services: image: apache/james:postgres-latest container_name: james hostname: james.local - volumes: - - $PWD/postgresql-42.5.4.jar:/root/libs/postgresql-42.5.4.jar - - ./sample-configuration/james-database-postgres.properties:/root/conf/james-database.properties command: - --generate-keystore ports: diff --git a/server/apps/postgres-app/pom.xml b/server/apps/postgres-app/pom.xml index 3ce2ab8e599..c49ba04d59e 100644 --- a/server/apps/postgres-app/pom.xml +++ b/server/apps/postgres-app/pom.xml @@ -204,10 +204,6 @@ rest-assured test - - org.apache.derby - derby - org.awaitility awaitility @@ -318,8 +314,6 @@ /root/glowroot/data /root/var - - /var/store @@ -367,13 +361,12 @@ org.apache.maven.plugins maven-surefire-plugin - false 1C -Djava.library.path= -javaagent:"${settings.localRepository}"/org/jacoco/org.jacoco.agent/${jacoco-maven-plugin.version}/org.jacoco.agent-${jacoco-maven-plugin.version}-runtime.jar=destfile=${basedir}/target/jacoco.exec - -Xms512m -Xmx1024m -Dopenjpa.Multithreaded=true + -Xms512m -Xmx1024m diff --git a/server/apps/postgres-app/sample-configuration/james-database-postgres.properties b/server/apps/postgres-app/sample-configuration/james-database-postgres.properties deleted file mode 100644 index 49d818a5cc2..00000000000 --- a/server/apps/postgres-app/sample-configuration/james-database-postgres.properties +++ /dev/null @@ -1,49 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -# This template file can be used as example for James Server configuration -# DO NOT USE IT AS SUCH AND ADAPT IT TO YOUR NEEDS - -# Read https://james.apache.org/server/config-system.html#james-database.properties for further details - -# Use derby as default -database.driverClassName=org.postgresql.Driver -database.url=jdbc:postgresql://postgres/james -database.username=james -database.password=secret1 - -# Use streaming for Blobs -# This is only supported on a limited set of databases atm. You should check if its supported by your DB before enable -# it. -# -# See: -# http://openjpa.apache.org/builds/latest/docs/manual/ref_guide_mapping_jpa.html #7.11. LOB Streaming -# -openjpa.streaming=false - -# Validate the data source before using it -# datasource.testOnBorrow=true -# datasource.validationQueryTimeoutSec=2 -# This is different per database. See https://stackoverflow.com/questions/10684244/dbcp-validationquery-for-different-databases#10684260 -# datasource.validationQuery=select 1 -# The maximum number of active connections that can be allocated from this pool at the same time, or negative for no limit. -# datasource.maxTotal=8 - -# Attachment storage -# *WARNING*: Is not made to store large binary content (no more than 1 GB of data) -# Optional, Allowed values are: true, false, defaults to false -# attachmentStorage.enabled=false diff --git a/server/apps/postgres-app/sample-configuration/james-database.properties b/server/apps/postgres-app/sample-configuration/james-database.properties deleted file mode 100644 index 6aecddbbdd2..00000000000 --- a/server/apps/postgres-app/sample-configuration/james-database.properties +++ /dev/null @@ -1,53 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. - -# This template file can be used as example for James Server configuration -# DO NOT USE IT AS SUCH AND ADAPT IT TO YOUR NEEDS - -# Read https://james.apache.org/server/config-system.html#james-database.properties for further details - -# Use derby as default -database.driverClassName=org.apache.derby.jdbc.EmbeddedDriver -database.url=jdbc:derby:../var/store/derby;create=true -database.username=app -database.password=app - -# Supported adapters are: -# DB2, DERBY, H2, HSQL, INFORMIX, MYSQL, ORACLE, POSTGRESQL, SQL_SERVER, SYBASE -vendorAdapter.database=DERBY - -# Use streaming for Blobs -# This is only supported on a limited set of databases atm. You should check if its supported by your DB before enable -# it. -# -# See: -# http://openjpa.apache.org/builds/latest/docs/manual/ref_guide_mapping_jpa.html #7.11. LOB Streaming -# -openjpa.streaming=false - -# Validate the data source before using it -# datasource.testOnBorrow=true -# datasource.validationQueryTimeoutSec=2 -# This is different per database. See https://stackoverflow.com/questions/10684244/dbcp-validationquery-for-different-databases#10684260 -# datasource.validationQuery=select 1 -# The maximum number of active connections that can be allocated from this pool at the same time, or negative for no limit. -# datasource.maxTotal=8 - -# Attachment storage -# *WARNING*: Is not made to store large binary content (no more than 1 GB of data) -# Optional, Allowed values are: true, false, defaults to false -# attachmentStorage.enabled=false diff --git a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml b/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml deleted file mode 100644 index 80b798e907e..00000000000 --- a/server/apps/postgres-app/src/main/resources/META-INF/persistence.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java index 652d35788b3..b59e3d2ff92 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java @@ -51,7 +51,6 @@ private static MailboxManager mailboxManager() { .usersRepository(DEFAULT) .build()) .server(configuration -> PostgresJamesServerMain.createServer(configuration) - .overrideWith(new TestJPAConfigurationModule(postgresExtension)) .overrideWith(binder -> binder.bind(MailboxManager.class).toInstance(mailboxManager()))) .extension(postgresExtension) .build(); diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java index c2157a7e9ed..cf63a637187 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java @@ -50,8 +50,7 @@ class PostgresJamesServerTest implements JamesServerConcreteContract { .configurationFromClasspath() .usersRepository(DEFAULT) .build()) - .server(configuration -> PostgresJamesServerMain.createServer(configuration) - .overrideWith(new TestJPAConfigurationModule(postgresExtension))) + .server(PostgresJamesServerMain::createServer) .extension(postgresExtension) .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) .build(); diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java index 8f02723bf57..288e26d9b30 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java @@ -43,8 +43,7 @@ class PostgresWithLDAPJamesServerTest { .configurationFromClasspath() .usersRepository(LDAP) .build()) - .server(configuration -> PostgresJamesServerMain.createServer(configuration) - .overrideWith(new TestJPAConfigurationModule(postgresExtension))) + .server(PostgresJamesServerMain::createServer) .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) .extension(new LdapTestExtension()) .extension(postgresExtension) diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java index 8b64558d6ab..6e0be1b6172 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java @@ -62,7 +62,6 @@ import org.apache.james.mailbox.store.mail.ThreadIdGuessingAlgorithm; import org.apache.james.mailbox.store.user.SubscriptionMapperFactory; import org.apache.james.modules.BlobMemoryModule; -import org.apache.james.modules.data.JPAEntityManagerModule; import org.apache.james.modules.data.PostgresCommonModule; import org.apache.james.user.api.DeleteUserDataTaskStep; import org.apache.james.user.api.UsernameChangeTaskStep; @@ -86,8 +85,7 @@ protected void configure() { postgresDataDefinitions.addBinding().toInstance(PostgresMailboxAggregateModule.MODULE); install(new PostgresQuotaModule()); - install(new JPAQuotaSearchModule()); - install(new JPAEntityManagerModule()); + install(new PostgresQuotaSearchModule()); bind(PostgresMailboxSessionMapperFactory.class).in(Scopes.SINGLETON); bind(PostgresMailboxManager.class).in(Scopes.SINGLETON); diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JPAQuotaSearchModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresQuotaSearchModule.java similarity index 96% rename from server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JPAQuotaSearchModule.java rename to server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresQuotaSearchModule.java index dbb0e3f90b7..5cd9108f933 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/JPAQuotaSearchModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresQuotaSearchModule.java @@ -25,7 +25,7 @@ import com.google.inject.AbstractModule; import com.google.inject.Scopes; -public class JPAQuotaSearchModule extends AbstractModule { +public class PostgresQuotaSearchModule extends AbstractModule { @Override protected void configure() { bind(ScanningQuotaSearcher.class).in(Scopes.SINGLETON); diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAAuthorizatorModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAAuthorizatorModule.java deleted file mode 100644 index 4c28118779f..00000000000 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAAuthorizatorModule.java +++ /dev/null @@ -1,35 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.modules.data; - -import org.apache.james.adapter.mailbox.UserRepositoryAuthorizator; -import org.apache.james.mailbox.Authorizator; - -import com.google.inject.AbstractModule; - -public class JPAAuthorizatorModule extends AbstractModule { - - - @Override - protected void configure() { - bind(Authorizator.class).to(UserRepositoryAuthorizator.class); - } - -} diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAEntityManagerModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAEntityManagerModule.java deleted file mode 100644 index 19432d372c0..00000000000 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/JPAEntityManagerModule.java +++ /dev/null @@ -1,116 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ -package org.apache.james.modules.data; - -import java.io.FileNotFoundException; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -import javax.inject.Singleton; -import javax.persistence.EntityManagerFactory; -import javax.persistence.Persistence; - -import org.apache.commons.configuration2.Configuration; -import org.apache.commons.configuration2.ex.ConfigurationException; -import org.apache.james.backends.jpa.JPAConfiguration; -import org.apache.james.utils.PropertiesProvider; - -import com.google.common.base.Joiner; -import com.google.inject.AbstractModule; -import com.google.inject.Provides; - -public class JPAEntityManagerModule extends AbstractModule { - @Provides - @Singleton - public EntityManagerFactory provideEntityManagerFactory(JPAConfiguration jpaConfiguration) { - HashMap properties = new HashMap<>(); - - properties.put(JPAConfiguration.JPA_CONNECTION_DRIVER_NAME, jpaConfiguration.getDriverName()); - properties.put(JPAConfiguration.JPA_CONNECTION_URL, jpaConfiguration.getDriverURL()); - jpaConfiguration.getCredential() - .ifPresent(credential -> { - properties.put(JPAConfiguration.JPA_CONNECTION_USERNAME, credential.getUsername()); - properties.put(JPAConfiguration.JPA_CONNECTION_PASSWORD, credential.getPassword()); - }); - - List connectionProperties = new ArrayList<>(); - jpaConfiguration.isTestOnBorrow().ifPresent(testOnBorrow -> connectionProperties.add("TestOnBorrow=" + testOnBorrow)); - jpaConfiguration.getValidationQueryTimeoutSec() - .ifPresent(timeoutSecond -> connectionProperties.add("ValidationTimeout=" + timeoutSecond * 1000)); - jpaConfiguration.getValidationQuery() - .ifPresent(validationQuery -> connectionProperties.add("ValidationSQL='" + validationQuery + "'")); - jpaConfiguration.getMaxConnections() - .ifPresent(maxConnections -> connectionProperties.add("MaxTotal=" + maxConnections)); - - connectionProperties.addAll(jpaConfiguration.getCustomDatasourceProperties().entrySet().stream().map(entry -> entry.getKey() + "=" + entry.getValue()).collect(Collectors.toList())); - properties.put(JPAConfiguration.JPA_CONNECTION_PROPERTIES, Joiner.on(",").join(connectionProperties)); - properties.putAll(jpaConfiguration.getCustomOpenjpaProperties()); - - jpaConfiguration.isMultithreaded() - .ifPresent(isMultiThread -> - properties.put(JPAConfiguration.JPA_MULTITHREADED, jpaConfiguration.isMultithreaded().toString()) - ); - - jpaConfiguration.isAttachmentStorageEnabled() - .ifPresent(isMultiThread -> - properties.put(JPAConfiguration.ATTACHMENT_STORAGE, jpaConfiguration.isAttachmentStorageEnabled().toString()) - ); - - return Persistence.createEntityManagerFactory("Global", properties); - } - - @Provides - @Singleton - JPAConfiguration provideConfiguration(PropertiesProvider propertiesProvider) throws FileNotFoundException, ConfigurationException { - Configuration dataSource = propertiesProvider.getConfiguration("james-database"); - - Map openjpaProperties = getKeysForPrefix(dataSource, "openjpa", false); - Map datasourceProperties = getKeysForPrefix(dataSource, "datasource", true); - - return JPAConfiguration.builder() - .driverName(dataSource.getString("database.driverClassName")) - .driverURL(dataSource.getString("database.url")) - .testOnBorrow(dataSource.getBoolean("datasource.testOnBorrow", false)) - .validationQueryTimeoutSec(dataSource.getInteger("datasource.validationQueryTimeoutSec", null)) - .validationQuery(dataSource.getString("datasource.validationQuery", null)) - .maxConnections(dataSource.getInteger("datasource.maxTotal", null)) - .multithreaded(dataSource.getBoolean(JPAConfiguration.JPA_MULTITHREADED, true)) - .username(dataSource.getString("database.username")) - .password(dataSource.getString("database.password")) - .setCustomOpenjpaProperties(openjpaProperties) - .setCustomDatasourceProperties(datasourceProperties) - .attachmentStorage(dataSource.getBoolean(JPAConfiguration.ATTACHMENT_STORAGE, false)) - .build(); - } - - private static Map getKeysForPrefix(Configuration dataSource, String prefix, boolean stripPrefix) { - Iterator keys = dataSource.getKeys(prefix); - Map properties = new HashMap<>(); - while (keys.hasNext()) { - String key = keys.next(); - String propertyKey = stripPrefix ? key.replace(prefix + ".", "") : key; - properties.put(propertyKey, dataSource.getString(key)); - } - return properties; - } -} diff --git a/server/container/guice/postgres-common/src/test/java/org/apache/james/TestJPAConfigurationModule.java b/server/container/guice/postgres-common/src/test/java/org/apache/james/TestJPAConfigurationModule.java deleted file mode 100644 index 19ca6b61889..00000000000 --- a/server/container/guice/postgres-common/src/test/java/org/apache/james/TestJPAConfigurationModule.java +++ /dev/null @@ -1,53 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james; - -import javax.inject.Singleton; - -import org.apache.james.backends.jpa.JPAConfiguration; -import org.apache.james.backends.postgres.PostgresExtension; - -import com.google.inject.AbstractModule; -import com.google.inject.Provides; - -public class TestJPAConfigurationModule extends AbstractModule { - public static final String JDBC_EMBEDDED_DRIVER = org.postgresql.Driver.class.getName(); - - private final PostgresExtension postgresExtension; - - public TestJPAConfigurationModule(PostgresExtension postgresExtension) { - this.postgresExtension = postgresExtension; - } - - @Override - protected void configure() { - } - - @Provides - @Singleton - JPAConfiguration provideConfiguration() { - return JPAConfiguration.builder() - .driverName(JDBC_EMBEDDED_DRIVER) - .driverURL(postgresExtension.getJdbcUrl()) - .username(postgresExtension.getPostgresConfiguration().getCredential().getUsername()) - .password(postgresExtension.getPostgresConfiguration().getCredential().getPassword()) - .build(); - } -} diff --git a/server/container/guice/postgres-common/src/test/java/org/apache/james/TestJPAConfigurationModuleWithSqlValidation.java b/server/container/guice/postgres-common/src/test/java/org/apache/james/TestJPAConfigurationModuleWithSqlValidation.java deleted file mode 100644 index dce784827bc..00000000000 --- a/server/container/guice/postgres-common/src/test/java/org/apache/james/TestJPAConfigurationModuleWithSqlValidation.java +++ /dev/null @@ -1,86 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james; - -import static org.apache.james.TestJPAConfigurationModule.JDBC_EMBEDDED_DRIVER; - -import javax.inject.Singleton; - -import org.apache.james.backends.jpa.JPAConfiguration; -import org.apache.james.backends.postgres.PostgresExtension; - -import com.google.inject.AbstractModule; -import com.google.inject.Provides; - -public interface TestJPAConfigurationModuleWithSqlValidation { - - class NoDatabaseAuthentication extends AbstractModule { - private final PostgresExtension postgresExtension; - - public NoDatabaseAuthentication(PostgresExtension postgresExtension) { - this.postgresExtension = postgresExtension; - } - - @Override - protected void configure() { - } - - @Provides - @Singleton - JPAConfiguration provideConfiguration() { - return JPAConfiguration.builder() - .driverName(JDBC_EMBEDDED_DRIVER) - .driverURL(postgresExtension.getJdbcUrl()) - .testOnBorrow(true) - .validationQueryTimeoutSec(2) - .validationQuery(VALIDATION_SQL_QUERY) - .build(); - } - } - - class WithDatabaseAuthentication extends AbstractModule { - private final PostgresExtension postgresExtension; - - public WithDatabaseAuthentication(PostgresExtension postgresExtension) { - this.postgresExtension = postgresExtension; - } - - @Override - protected void configure() { - - } - - @Provides - @Singleton - JPAConfiguration provideConfiguration() { - return JPAConfiguration.builder() - .driverName(JDBC_EMBEDDED_DRIVER) - .driverURL(postgresExtension.getJdbcUrl()) - .testOnBorrow(true) - .validationQueryTimeoutSec(2) - .validationQuery(VALIDATION_SQL_QUERY) - .username(postgresExtension.getPostgresConfiguration().getCredential().getUsername()) - .password(postgresExtension.getPostgresConfiguration().getCredential().getPassword()) - .build(); - } - } - - String VALIDATION_SQL_QUERY = "VALUES 1"; -} diff --git a/server/data/data-postgres/pom.xml b/server/data/data-postgres/pom.xml index 107a939364d..d12ba78633d 100644 --- a/server/data/data-postgres/pom.xml +++ b/server/data/data-postgres/pom.xml @@ -12,16 +12,6 @@ Apache James :: Server :: Data :: Postgres - - ${james.groupId} - apache-james-backends-jpa - - - ${james.groupId} - apache-james-backends-jpa - test-jar - test - ${james.groupId} apache-james-backends-postgres @@ -133,11 +123,6 @@ org.apache.commons commons-configuration2 - - org.apache.derby - derby - test - org.mockito mockito-core @@ -161,35 +146,4 @@ test - - - - - - org.apache.openjpa - openjpa-maven-plugin - ${apache.openjpa.version} - - true - true - - - log - TOOL=TRACE - - - ${basedir}/src/test/resources/persistence.xml - - - - enhancer - - enhance - - process-classes - - - - - diff --git a/server/data/data-postgres/src/test/resources/persistence.xml b/server/data/data-postgres/src/test/resources/persistence.xml deleted file mode 100644 index 1e66a76c65f..00000000000 --- a/server/data/data-postgres/src/test/resources/persistence.xml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - org.apache.openjpa.persistence.PersistenceProviderImpl - osgi:service/javax.sql.DataSource/(osgi.jndi.service.name=jdbc/james) - true - - - - - - - - - - From c05e46bac5f1ab00c05f6120a80a8a4a0ab1924c Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Thu, 14 Dec 2023 16:11:29 +0700 Subject: [PATCH 136/341] JAMES-2586 Add an `addAdditionalAlterQueries` option when declaring Postgres table Would be useful in case jOOQ DSL does not provide support for some queries e.g. create an EXCLUDE constraint. --- .../backends/postgres/PostgresTable.java | 44 ++++++++-- .../postgres/PostgresTableManager.java | 22 +++++ .../postgres/quota/PostgresQuotaModule.java | 6 +- .../postgres/PostgresExtensionTest.java | 6 +- .../postgres/PostgresTableManagerTest.java | 84 ++++++++++++++++--- .../PostgresMailboxAnnotationModule.java | 3 +- .../postgres/mail/PostgresMailboxModule.java | 3 +- .../postgres/mail/PostgresMessageModule.java | 6 +- .../user/PostgresSubscriptionModule.java | 3 +- .../postgres/PostgresDomainModule.java | 3 +- .../PostgresMailRepositoryModule.java | 3 +- .../PostgresRecipientRewriteTableModule.java | 3 +- .../user/postgres/PostgresUserModule.java | 3 +- 13 files changed, 158 insertions(+), 31 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java index 933a7810df5..517ff411bba 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java @@ -19,12 +19,14 @@ package org.apache.james.backends.postgres; +import java.util.List; import java.util.function.Function; import org.jooq.DDLQuery; import org.jooq.DSLContext; import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; public class PostgresTable { @FunctionalInterface @@ -39,31 +41,59 @@ public interface CreateTableFunction { @FunctionalInterface public interface RequireRowLevelSecurity { - PostgresTable supportsRowLevelSecurity(boolean rowLevelSecurityEnabled); + FinalStage supportsRowLevelSecurity(boolean rowLevelSecurityEnabled); - default PostgresTable disableRowLevelSecurity() { + default FinalStage disableRowLevelSecurity() { return supportsRowLevelSecurity(false); } - default PostgresTable supportsRowLevelSecurity() { + default FinalStage supportsRowLevelSecurity() { return supportsRowLevelSecurity(true); } } + public static class FinalStage { + private final String tableName; + private final boolean supportsRowLevelSecurity; + private final Function createTableStepFunction; + private final ImmutableList.Builder additionalAlterQueries; + + public FinalStage(String tableName, boolean supportsRowLevelSecurity, Function createTableStepFunction) { + this.tableName = tableName; + this.supportsRowLevelSecurity = supportsRowLevelSecurity; + this.createTableStepFunction = createTableStepFunction; + this.additionalAlterQueries = ImmutableList.builder(); + } + + /** + * Raw SQL ALTER queries in case not supported by jOOQ DSL. + */ + public FinalStage addAdditionalAlterQueries(String... additionalAlterQueries) { + this.additionalAlterQueries.add(additionalAlterQueries); + return this; + } + + public PostgresTable build() { + return new PostgresTable(tableName, supportsRowLevelSecurity, createTableStepFunction, additionalAlterQueries.build()); + } + } + public static RequireCreateTableStep name(String tableName) { Preconditions.checkNotNull(tableName); - return createTableFunction -> supportsRowLevelSecurity -> new PostgresTable(tableName, supportsRowLevelSecurity, dsl -> createTableFunction.createTable(dsl, tableName)); + return createTableFunction -> supportsRowLevelSecurity -> new FinalStage(tableName, supportsRowLevelSecurity, dsl -> createTableFunction.createTable(dsl, tableName)); } private final String name; private final boolean supportsRowLevelSecurity; private final Function createTableStepFunction; + private final List additionalAlterQueries; - private PostgresTable(String name, boolean supportsRowLevelSecurity, Function createTableStepFunction) { + private PostgresTable(String name, boolean supportsRowLevelSecurity, Function createTableStepFunction, List additionalAlterQueries) { this.name = name; this.supportsRowLevelSecurity = supportsRowLevelSecurity; this.createTableStepFunction = createTableStepFunction; + this.additionalAlterQueries = additionalAlterQueries; } @@ -78,4 +108,8 @@ public Function getCreateTableStepFunction() { public boolean supportsRowLevelSecurity() { return supportsRowLevelSecurity; } + + public List getAdditionalAlterQueries() { + return additionalAlterQueries; + } } diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java index 8f935f69cfc..b4b2fb622c2 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java @@ -90,6 +90,28 @@ private Mono handleTableCreationException(PostgresTable table, Throwable e } private Mono alterTableIfNeeded(PostgresTable table) { + return executeAdditionalAlterQueries(table) + .then(enableRLSIfNeeded(table)); + } + + private Mono executeAdditionalAlterQueries(PostgresTable table) { + return Flux.fromIterable(table.getAdditionalAlterQueries()) + .concatMap(alterSQLQuery -> postgresExecutor.connection() + .flatMapMany(connection -> connection.createStatement(alterSQLQuery) + .execute()) + .flatMap(Result::getRowsUpdated) + .then() + .onErrorResume(e -> { + if (e.getMessage().contains("already exists")) { + return Mono.empty(); + } + LOGGER.error("Error while executing ALTER query for table {}", table.getName(), e); + return Mono.error(e); + })) + .then(); + } + + private Mono enableRLSIfNeeded(PostgresTable table) { if (rowLevelSecurityEnabled && table.supportsRowLevelSecurity()) { return alterTableEnableRLS(table); } diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaModule.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaModule.java index 1810d327356..a21880683f6 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaModule.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaModule.java @@ -50,7 +50,8 @@ interface PostgresQuotaCurrentValueTable { .column(CURRENT_VALUE) .constraint(DSL.constraint(PRIMARY_KEY_CONSTRAINT_NAME) .primaryKey(IDENTIFIER, COMPONENT, TYPE)))) - .disableRowLevelSecurity(); + .disableRowLevelSecurity() + .build(); } interface PostgresQuotaLimitTable { @@ -72,7 +73,8 @@ interface PostgresQuotaLimitTable { .column(QUOTA_TYPE) .column(QUOTA_LIMIT) .constraint(DSL.constraint(PK_CONSTRAINT_NAME).primaryKey(QUOTA_SCOPE, IDENTIFIER, QUOTA_COMPONENT, QUOTA_TYPE)))) - .supportsRowLevelSecurity(); + .supportsRowLevelSecurity() + .build(); } PostgresModule MODULE = PostgresModule.builder() diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtensionTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtensionTest.java index ca3a641eadc..619899ed179 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtensionTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtensionTest.java @@ -38,7 +38,8 @@ class PostgresExtensionTest { .column("column1", SQLDataType.UUID.notNull()) .column("column2", SQLDataType.INTEGER) .column("column3", SQLDataType.VARCHAR(255).notNull())) - .disableRowLevelSecurity(); + .disableRowLevelSecurity() + .build(); static PostgresIndex INDEX_1 = PostgresIndex.name("index1") .createIndexStep((dslContext, indexName) -> dslContext.createIndex(indexName) @@ -47,7 +48,8 @@ class PostgresExtensionTest { static PostgresTable TABLE_2 = PostgresTable.name("table2") .createTableStep((dslContext, tableName) -> dslContext.createTable(tableName) .column("column1", SQLDataType.INTEGER)) - .disableRowLevelSecurity(); + .disableRowLevelSecurity() + .build(); static PostgresIndex INDEX_2 = PostgresIndex.name("index2") .createIndexStep((dslContext, indexName) -> dslContext.createIndex(indexName) diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java index 9b9563d6429..0068fd1566d 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java @@ -52,7 +52,8 @@ void initializeTableShouldSuccessWhenModuleHasSingleTable() { .column("colum1", SQLDataType.UUID.notNull()) .column("colum2", SQLDataType.INTEGER) .column("colum3", SQLDataType.VARCHAR(255).notNull())) - .disableRowLevelSecurity(); + .disableRowLevelSecurity() + .build(); PostgresModule module = PostgresModule.table(table); @@ -74,12 +75,14 @@ void initializeTableShouldSuccessWhenModuleHasMultiTables() { PostgresTable table1 = PostgresTable.name(tableName1) .createTableStep((dsl, tbn) -> dsl.createTable(tbn) - .column("columA", SQLDataType.UUID.notNull())).disableRowLevelSecurity(); + .column("columA", SQLDataType.UUID.notNull())).disableRowLevelSecurity() + .build(); String tableName2 = "tableName2"; PostgresTable table2 = PostgresTable.name(tableName2) .createTableStep((dsl, tbn) -> dsl.createTable(tbn) - .column("columB", SQLDataType.INTEGER)).disableRowLevelSecurity(); + .column("columB", SQLDataType.INTEGER)).disableRowLevelSecurity() + .build(); PostgresTableManager testee = tableManagerFactory.apply(PostgresModule.table(table1, table2)); @@ -100,7 +103,8 @@ void initializeTableShouldNotThrowWhenTableExists() { PostgresTable table1 = PostgresTable.name(tableName1) .createTableStep((dsl, tbn) -> dsl.createTable(tbn) - .column("columA", SQLDataType.UUID.notNull())).disableRowLevelSecurity(); + .column("columA", SQLDataType.UUID.notNull())).disableRowLevelSecurity() + .build(); PostgresTableManager testee = tableManagerFactory.apply(PostgresModule.table(table1)); @@ -116,7 +120,8 @@ void initializeTableShouldNotChangeTableStructureOfExistTable() { String tableName1 = "tableName1"; PostgresTable table1 = PostgresTable.name(tableName1) .createTableStep((dsl, tbn) -> dsl.createTable(tbn) - .column("columA", SQLDataType.UUID.notNull())).disableRowLevelSecurity(); + .column("columA", SQLDataType.UUID.notNull())).disableRowLevelSecurity() + .build(); tableManagerFactory.apply(PostgresModule.table(table1)) .initializeTables() @@ -124,7 +129,8 @@ void initializeTableShouldNotChangeTableStructureOfExistTable() { PostgresTable table1Changed = PostgresTable.name(tableName1) .createTableStep((dsl, tbn) -> dsl.createTable(tbn) - .column("columB", SQLDataType.INTEGER)).disableRowLevelSecurity(); + .column("columB", SQLDataType.INTEGER)).disableRowLevelSecurity() + .build(); tableManagerFactory.apply(PostgresModule.table(table1Changed)) .initializeTables() @@ -144,7 +150,8 @@ void initializeIndexShouldSuccessWhenModuleHasSingleIndex() { .column("colum1", SQLDataType.UUID.notNull()) .column("colum2", SQLDataType.INTEGER) .column("colum3", SQLDataType.VARCHAR(255).notNull())) - .disableRowLevelSecurity(); + .disableRowLevelSecurity() + .build(); String indexName = "idx_test_1"; PostgresIndex index = PostgresIndex.name(indexName) @@ -177,7 +184,8 @@ void initializeIndexShouldSuccessWhenModuleHasMultiIndexes() { .column("colum1", SQLDataType.UUID.notNull()) .column("colum2", SQLDataType.INTEGER) .column("colum3", SQLDataType.VARCHAR(255).notNull())) - .disableRowLevelSecurity(); + .disableRowLevelSecurity() + .build(); String indexName1 = "idx_test_1"; PostgresIndex index1 = PostgresIndex.name(indexName1) @@ -215,7 +223,8 @@ void initializeIndexShouldNotThrowWhenIndexExists() { .column("colum1", SQLDataType.UUID.notNull()) .column("colum2", SQLDataType.INTEGER) .column("colum3", SQLDataType.VARCHAR(255).notNull())) - .disableRowLevelSecurity(); + .disableRowLevelSecurity() + .build(); String indexName = "idx_test_1"; PostgresIndex index = PostgresIndex.name(indexName) @@ -243,7 +252,8 @@ void truncateShouldEmptyTableData() { String tableName1 = "tbn1"; PostgresTable table1 = PostgresTable.name(tableName1) .createTableStep((dsl, tbn) -> dsl.createTable(tbn) - .column("column1", SQLDataType.INTEGER.notNull())).disableRowLevelSecurity(); + .column("column1", SQLDataType.INTEGER.notNull())).disableRowLevelSecurity() + .build(); PostgresTableManager testee = tableManagerFactory.apply(PostgresModule.table(table1)); testee.initializeTables() @@ -285,7 +295,8 @@ void createTableShouldCreateRlsColumnWhenEnableRLS() { .createTableStep((dsl, tbn) -> dsl.createTable(tbn) .column("clm1", SQLDataType.UUID.notNull()) .column("clm2", SQLDataType.VARCHAR(255).notNull())) - .supportsRowLevelSecurity(); + .supportsRowLevelSecurity() + .build(); PostgresModule module = PostgresModule.table(table); @@ -325,7 +336,8 @@ void createTableShouldNotCreateRlsColumnWhenDisableRLS() { .createTableStep((dsl, tbn) -> dsl.createTable(tbn) .column("clm1", SQLDataType.UUID.notNull()) .column("clm2", SQLDataType.VARCHAR(255).notNull())) - .supportsRowLevelSecurity(); + .supportsRowLevelSecurity() + .build(); PostgresModule module = PostgresModule.table(table); boolean disabledRLS = false; @@ -348,7 +360,8 @@ void recreateRLSColumnWhenExistedShouldNotFail() { PostgresTable rlsTable = PostgresTable.name(tableName) .createTableStep((dsl, tbn) -> dsl.createTable(tbn) .column("colum1", SQLDataType.UUID.notNull())) - .supportsRowLevelSecurity(); + .supportsRowLevelSecurity() + .build(); PostgresModule module = PostgresModule.table(rlsTable); @@ -359,6 +372,51 @@ void recreateRLSColumnWhenExistedShouldNotFail() { .doesNotThrowAnyException(); } + @Test + void additionalAlterQueryToCreateConstraintShouldSucceed() { + String constraintName = "exclude_constraint"; + PostgresTable table = PostgresTable.name("tbn1") + .createTableStep((dsl, tbn) -> dsl.createTable(tbn) + .column("clm1", SQLDataType.UUID.notNull()) + .column("clm2", SQLDataType.VARCHAR(255).notNull())) + .disableRowLevelSecurity() + .addAdditionalAlterQueries("ALTER TABLE tbn1 ADD CONSTRAINT " + constraintName + " EXCLUDE (clm2 WITH =)") + .build(); + PostgresModule module = PostgresModule.table(table); + PostgresTableManager testee = new PostgresTableManager(postgresExtension.getPostgresExecutor(), module, false); + + testee.initializeTables().block(); + + boolean constraintExists = postgresExtension.getConnection() + .flatMapMany(connection -> connection.createStatement("SELECT EXISTS(SELECT 1 FROM pg_catalog.pg_constraint WHERE conname = $1) AS constraint_exists;") + .bind("$1", constraintName) + .execute()) + .flatMap(result -> result.map((row, rowMetaData) -> row.get("constraint_exists", Boolean.class))) + .single() + .block(); + + assertThat(constraintExists).isTrue(); + } + + @Test + void additionalAlterQueryToReCreateConstraintShouldNotThrow() { + String constraintName = "exclude_constraint"; + PostgresTable table = PostgresTable.name("tbn1") + .createTableStep((dsl, tbn) -> dsl.createTable(tbn) + .column("clm1", SQLDataType.UUID.notNull()) + .column("clm2", SQLDataType.VARCHAR(255).notNull())) + .disableRowLevelSecurity() + .addAdditionalAlterQueries("ALTER TABLE tbn1 ADD CONSTRAINT " + constraintName + " EXCLUDE (clm2 WITH =)") + .build(); + PostgresModule module = PostgresModule.table(table); + PostgresTableManager testee = new PostgresTableManager(postgresExtension.getPostgresExecutor(), module, false); + + testee.initializeTables().block(); + + assertThatCode(() -> testee.initializeTables().block()) + .doesNotThrowAnyException(); + } + private List> getColumnNameAndDataType(String tableName) { return postgresExtension.getConnection() .flatMapMany(connection -> Flux.from(Mono.from(connection.createStatement("SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_name = $1;") diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAnnotationModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAnnotationModule.java index 4bfae6678bd..64f0937ae81 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAnnotationModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAnnotationModule.java @@ -47,7 +47,8 @@ interface PostgresMailboxAnnotationTable { .column(ANNOTATIONS) .primaryKey(MAILBOX_ID) .constraints(DSL.constraint().foreignKey(MAILBOX_ID).references(PostgresMailboxTable.TABLE_NAME, PostgresMailboxTable.MAILBOX_ID).onDeleteCascade()))) - .supportsRowLevelSecurity(); + .supportsRowLevelSecurity() + .build(); } PostgresModule MODULE = PostgresModule.builder() diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java index af8b1dca049..2d55f8eda97 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java @@ -58,7 +58,8 @@ interface PostgresMailboxTable { .column(MAILBOX_ACL) .constraint(DSL.primaryKey(MAILBOX_ID)) .constraint(DSL.unique(MAILBOX_NAME, USER_NAME, MAILBOX_NAMESPACE)))) - .supportsRowLevelSecurity(); + .supportsRowLevelSecurity() + .build(); } PostgresModule MODULE = PostgresModule.builder() diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java index 6b92d9f2043..feb51c7c5ef 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java @@ -85,7 +85,8 @@ interface MessageTable { .column(CONTENT_DISPOSITION_PARAMETERS) .constraint(DSL.primaryKey(MESSAGE_ID)) .comment("Holds the metadata of a mail"))) - .supportsRowLevelSecurity(); + .supportsRowLevelSecurity() + .build(); } interface MessageToMailboxTable { @@ -128,7 +129,8 @@ interface MessageToMailboxTable { .constraints(DSL.primaryKey(MAILBOX_ID, MESSAGE_UID), foreignKey(MESSAGE_ID).references(MessageTable.TABLE_NAME, MessageTable.MESSAGE_ID)) .comment("Holds mailbox and flags for each message"))) - .supportsRowLevelSecurity(); + .supportsRowLevelSecurity() + .build(); PostgresIndex MESSAGE_ID_INDEX = PostgresIndex.name("message_mailbox_message_id_index") .createIndexStep((dsl, indexName) -> dsl.createIndexIfNotExists(indexName) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java index c188c050c5f..bd8afd42b07 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java @@ -43,7 +43,8 @@ public interface PostgresSubscriptionModule { .column(MAILBOX) .column(USER) .constraint(DSL.unique(MAILBOX, USER)))) - .supportsRowLevelSecurity(); + .supportsRowLevelSecurity() + .build(); PostgresIndex INDEX = PostgresIndex.name("subscription_user_index") .createIndexStep((dsl, indexName) -> dsl.createIndex(indexName) .on(TABLE_NAME, USER)); diff --git a/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainModule.java b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainModule.java index 1d9fd110d06..f0a14669175 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainModule.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainModule.java @@ -37,7 +37,8 @@ interface PostgresDomainTable { .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) .column(DOMAIN) .primaryKey(DOMAIN))) - .disableRowLevelSecurity(); + .disableRowLevelSecurity() + .build(); } PostgresModule MODULE = PostgresModule.builder() diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryModule.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryModule.java index cf923561dda..abf496ed748 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryModule.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryModule.java @@ -43,7 +43,8 @@ interface PostgresMailRepositoryUrlTable { .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) .column(URL) .primaryKey(URL))) - .disableRowLevelSecurity(); + .disableRowLevelSecurity() + .build(); } interface PostgresMailRepositoryContentTable { diff --git a/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableModule.java b/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableModule.java index 7574483439a..33514abf1b1 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableModule.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableModule.java @@ -45,7 +45,8 @@ interface PostgresRecipientRewriteTableTable { .column(DOMAIN_NAME) .column(TARGET_ADDRESS) .constraint(DSL.constraint(PK_CONSTRAINT_NAME).primaryKey(USERNAME, DOMAIN_NAME, TARGET_ADDRESS)))) - .supportsRowLevelSecurity(); + .supportsRowLevelSecurity() + .build(); PostgresIndex INDEX = PostgresIndex.name("idx_rrt_target_address") .createIndexStep((dslContext, indexName) -> dslContext.createIndex(indexName) diff --git a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUserModule.java b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUserModule.java index e5bc618d31d..8840ca22fe9 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUserModule.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUserModule.java @@ -45,7 +45,8 @@ interface PostgresUserTable { .column(AUTHORIZED_USERS) .column(DELEGATED_USERS) .constraint(DSL.primaryKey(USERNAME)))) - .disableRowLevelSecurity(); + .disableRowLevelSecurity() + .build(); } PostgresModule MODULE = PostgresModule.builder() From 1b0416b8fe35206f7212828903c4adf842e40d62 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 19 Dec 2023 09:55:48 +0700 Subject: [PATCH 137/341] JAMES-2586 Fix compilation errors Merge a few PRs parallel led to this. --- .../mailrepository/postgres/PostgresMailRepositoryModule.java | 3 ++- .../org/apache/james/sieve/postgres/PostgresSieveModule.java | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryModule.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryModule.java index abf496ed748..0ea16f49ccb 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryModule.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryModule.java @@ -80,7 +80,8 @@ interface PostgresMailRepositoryContentTable { .column(LAST_UPDATED) .column(PER_RECIPIENT_SPECIFIC_HEADERS) .primaryKey(URL, KEY))) - .disableRowLevelSecurity(); + .disableRowLevelSecurity() + .build(); } PostgresModule MODULE = PostgresModule.builder() diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveModule.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveModule.java index b6780f9e63e..a7f7e018e5d 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveModule.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveModule.java @@ -50,7 +50,8 @@ interface PostgresSieveScriptTable { .column(IS_ACTIVE) .column(ACTIVATION_DATE_TIME) .primaryKey(USERNAME, SCRIPT_NAME))) - .disableRowLevelSecurity(); + .disableRowLevelSecurity() + .build(); PostgresIndex MAXIMUM_ONE_ACTIVE_SCRIPT_PER_USER_UNIQUE_INDEX = PostgresIndex.name("maximum_one_active_script_per_user") .createIndexStep(((dsl, indexName) -> dsl.createUniqueIndexIfNotExists(indexName) From bf453066233e42158de8b4801dd5b960d0cf2ca8 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 19 Dec 2023 14:39:41 +0700 Subject: [PATCH 138/341] JAMES-2586 Fix repositoryPath in postgres-app mailetcontainer.xml Again, an issue because of merging PRs in parallel. --- .../apps/postgres-app/sample-configuration/mailetcontainer.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/apps/postgres-app/sample-configuration/mailetcontainer.xml b/server/apps/postgres-app/sample-configuration/mailetcontainer.xml index 5c6eed887fc..d2d86b9d6e2 100644 --- a/server/apps/postgres-app/sample-configuration/mailetcontainer.xml +++ b/server/apps/postgres-app/sample-configuration/mailetcontainer.xml @@ -38,7 +38,7 @@ - file://var/mail/relay-limit-exceeded/ + postgres://var/mail/relay-limit-exceeded/ transport From 37837e38f3b91fb61f7f56ddb57f7bc8cc2332ef Mon Sep 17 00:00:00 2001 From: hung phan Date: Mon, 18 Dec 2023 17:19:31 +0700 Subject: [PATCH 139/341] JAMES-2586 add missing RLS tests --- .../postgres/quota/PostgresQuotaModule.java | 2 +- ...sAnnotationMapperRowLevelSecurityTest.java | 96 +++++++++++++++ ...gresMessageMapperRowLevelSecurityTest.java | 111 ++++++++++++++++++ .../PostgresRecipientRewriteTableModule.java | 2 +- 4 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperRowLevelSecurityTest.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaModule.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaModule.java index a21880683f6..b0e5c814c56 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaModule.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaModule.java @@ -73,7 +73,7 @@ interface PostgresQuotaLimitTable { .column(QUOTA_TYPE) .column(QUOTA_LIMIT) .constraint(DSL.constraint(PK_CONSTRAINT_NAME).primaryKey(QUOTA_SCOPE, IDENTIFIER, QUOTA_COMPONENT, QUOTA_TYPE)))) - .supportsRowLevelSecurity() + .disableRowLevelSecurity() .build(); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperRowLevelSecurityTest.java new file mode 100644 index 00000000000..826201eea7b --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperRowLevelSecurityTest.java @@ -0,0 +1,96 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BucketName; +import org.apache.james.blob.api.HashBlobId; +import org.apache.james.blob.memory.MemoryBlobStoreDAO; +import org.apache.james.core.Username; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.MailboxSessionUtil; +import org.apache.james.mailbox.model.MailboxAnnotation; +import org.apache.james.mailbox.model.MailboxAnnotationKey; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.UidValidity; +import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; +import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; +import org.apache.james.mailbox.store.mail.MailboxMapper; +import org.apache.james.server.blob.deduplication.DeDuplicationBlobStore; +import org.apache.james.utils.UpdatableTickingClock; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresAnnotationMapperRowLevelSecurityTest { + private static final UidValidity UID_VALIDITY = UidValidity.of(42); + private static final Username BENWA = Username.of("benwa"); + protected static final MailboxPath benwaInboxPath = MailboxPath.forUser(BENWA, "INBOX"); + private static final MailboxSession aliceSession = MailboxSessionUtil.create(Username.of("alice@domain1")); + private static final MailboxSession bobSession = MailboxSessionUtil.create(Username.of("bob@domain1")); + private static final MailboxSession bobDomain2Session = MailboxSessionUtil.create(Username.of("bob@domain2")); + private static final MailboxAnnotationKey PRIVATE_KEY = new MailboxAnnotationKey("/private/comment"); + private static final MailboxAnnotation PRIVATE_ANNOTATION = MailboxAnnotation.newInstance(PRIVATE_KEY, "My private comment"); + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); + + private PostgresMailboxSessionMapperFactory postgresMailboxSessionMapperFactory; + private MailboxId mailboxId; + + private MailboxId generateMailboxId() { + MailboxMapper mailboxMapper = new PostgresMailboxMapper(new PostgresMailboxDAO(postgresExtension.getPostgresExecutor())); + return mailboxMapper.create(benwaInboxPath, UID_VALIDITY).block().getMailboxId(); + } + + @BeforeEach + public void setUp() { + BlobId.Factory blobIdFactory = new HashBlobId.Factory(); + postgresMailboxSessionMapperFactory = new PostgresMailboxSessionMapperFactory(new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory())), + new UpdatableTickingClock(Instant.now()), + new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory), + blobIdFactory); + + mailboxId = generateMailboxId(); + } + + @Test + void annotationsCanBeAccessedAtTheDataLevelByMembersOfTheSameDomain() { + postgresMailboxSessionMapperFactory.getAnnotationMapper(aliceSession).insertAnnotation(mailboxId, PRIVATE_ANNOTATION); + + assertThat(postgresMailboxSessionMapperFactory.getAnnotationMapper(bobSession).getAllAnnotations(mailboxId)).isNotEmpty(); + } + + @Test + void annotationsShouldBeIsolatedByDomain() { + postgresMailboxSessionMapperFactory.getAnnotationMapper(aliceSession).insertAnnotation(mailboxId, PRIVATE_ANNOTATION); + + assertThat(postgresMailboxSessionMapperFactory.getAnnotationMapper(bobDomain2Session).getAllAnnotations(mailboxId)).isEmpty(); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java new file mode 100644 index 00000000000..87ba69c637d --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java @@ -0,0 +1,111 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.util.Date; + +import javax.mail.Flags; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BucketName; +import org.apache.james.blob.api.HashBlobId; +import org.apache.james.blob.memory.MemoryBlobStoreDAO; +import org.apache.james.core.Username; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.MailboxSessionUtil; +import org.apache.james.mailbox.model.ByteContent; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.model.ThreadId; +import org.apache.james.mailbox.model.UidValidity; +import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; +import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; +import org.apache.james.mailbox.store.mail.MailboxMapper; +import org.apache.james.mailbox.store.mail.model.MailboxMessage; +import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; +import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; +import org.apache.james.server.blob.deduplication.DeDuplicationBlobStore; +import org.apache.james.utils.UpdatableTickingClock; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresMessageMapperRowLevelSecurityTest { + private static final int BODY_START = 16; + private static final UidValidity UID_VALIDITY = UidValidity.of(42); + private static final Username BENWA = Username.of("benwa"); + protected static final MailboxPath benwaInboxPath = MailboxPath.forUser(BENWA, "INBOX"); + private static final MailboxSession aliceSession = MailboxSessionUtil.create(Username.of("alice@domain1")); + private static final MailboxSession bobSession = MailboxSessionUtil.create(Username.of("bob@domain1")); + private static final MailboxSession bobDomain2Session = MailboxSessionUtil.create(Username.of("bob@domain2")); + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); + + private PostgresMailboxSessionMapperFactory postgresMailboxSessionMapperFactory; + private Mailbox mailbox; + + private Mailbox generateMailbox() { + MailboxMapper mailboxMapper = new PostgresMailboxMapper(new PostgresMailboxDAO(postgresExtension.getPostgresExecutor())); + return mailboxMapper.create(benwaInboxPath, UID_VALIDITY).block(); + } + + @BeforeEach + public void setUp() { + BlobId.Factory blobIdFactory = new HashBlobId.Factory(); + postgresMailboxSessionMapperFactory = new PostgresMailboxSessionMapperFactory(new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory())), + new UpdatableTickingClock(Instant.now()), + new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory), + blobIdFactory); + + mailbox = generateMailbox(); + } + + @Test + void messagesCanBeAccessedAtTheDataLevelByMembersOfTheSameDomain() throws Exception { + postgresMailboxSessionMapperFactory.getMessageMapper(aliceSession).add(mailbox, createMessage()); + + assertThat(postgresMailboxSessionMapperFactory.getMessageMapper(bobSession).countMessagesInMailbox(mailbox)).isEqualTo(1L); + } + + @Test + void messagesShouldBeIsolatedByDomain() throws Exception { + postgresMailboxSessionMapperFactory.getMessageMapper(aliceSession).add(mailbox, createMessage()); + + assertThat(postgresMailboxSessionMapperFactory.getMessageMapper(bobDomain2Session).countMessagesInMailbox(mailbox)).isEqualTo(0L); + } + + private MailboxMessage createMessage() { + return createMessage(mailbox, new PostgresMessageId.Factory().generate(), "Subject: Test1 \n\nBody1\n.\n", BODY_START, new PropertyBuilder()); + } + + private MailboxMessage createMessage(Mailbox mailbox, MessageId messageId, String content, int bodyStart, PropertyBuilder propertyBuilder) { + return new SimpleMailboxMessage(messageId, ThreadId.fromBaseMessageId(messageId), new Date(), content.length(), bodyStart, new ByteContent(content.getBytes()), new Flags(), propertyBuilder.build(), mailbox.getMailboxId()); + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableModule.java b/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableModule.java index 33514abf1b1..40e5afa3643 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableModule.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableModule.java @@ -45,7 +45,7 @@ interface PostgresRecipientRewriteTableTable { .column(DOMAIN_NAME) .column(TARGET_ADDRESS) .constraint(DSL.constraint(PK_CONSTRAINT_NAME).primaryKey(USERNAME, DOMAIN_NAME, TARGET_ADDRESS)))) - .supportsRowLevelSecurity() + .disableRowLevelSecurity() .build(); PostgresIndex INDEX = PostgresIndex.name("idx_rrt_target_address") From 004df31cacc11801e69a0c5821f4904ee87c894e Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Tue, 19 Dec 2023 15:59:28 +0100 Subject: [PATCH 140/341] JAMES-2586 Add an Id for SieveScript (#1863) While not needed now this extra field would significantly ease a future JMAP Sieve implementation and avoid a migration. --- .../sieve/postgres/PostgresSieveModule.java | 6 ++- .../postgres/PostgresSieveRepository.java | 2 + .../postgres/PostgresSieveScriptDAO.java | 4 ++ .../postgres/model/PostgresSieveScript.java | 18 ++++++++- .../postgres/model/PostgresSieveScriptId.java | 38 +++++++++++++++++++ 5 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/model/PostgresSieveScriptId.java diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveModule.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveModule.java index a7f7e018e5d..74759c81837 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveModule.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveModule.java @@ -20,6 +20,7 @@ package org.apache.james.sieve.postgres; import java.time.OffsetDateTime; +import java.util.UUID; import org.apache.james.backends.postgres.PostgresIndex; import org.apache.james.backends.postgres.PostgresModule; @@ -36,6 +37,7 @@ interface PostgresSieveScriptTable { Field USERNAME = DSL.field("username", SQLDataType.VARCHAR(255).notNull()); Field SCRIPT_NAME = DSL.field("script_name", SQLDataType.VARCHAR.notNull()); + Field SCRIPT_ID = DSL.field("script_id", SQLDataType.UUID.notNull()); Field SCRIPT_SIZE = DSL.field("script_size", SQLDataType.BIGINT.notNull()); Field SCRIPT_CONTENT = DSL.field("script_content", SQLDataType.VARCHAR.notNull()); Field IS_ACTIVE = DSL.field("is_active", SQLDataType.BOOLEAN.notNull()); @@ -43,13 +45,15 @@ interface PostgresSieveScriptTable { PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(SCRIPT_ID) .column(USERNAME) .column(SCRIPT_NAME) .column(SCRIPT_SIZE) .column(SCRIPT_CONTENT) .column(IS_ACTIVE) .column(ACTIVATION_DATE_TIME) - .primaryKey(USERNAME, SCRIPT_NAME))) + .primaryKey(SCRIPT_ID) + .constraint(DSL.unique(USERNAME, SCRIPT_NAME)))) .disableRowLevelSecurity() .build(); diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveRepository.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveRepository.java index 662915c5235..f9b09e8eabb 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveRepository.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveRepository.java @@ -34,6 +34,7 @@ import org.apache.james.core.quota.QuotaSizeLimit; import org.apache.james.core.quota.QuotaSizeUsage; import org.apache.james.sieve.postgres.model.PostgresSieveScript; +import org.apache.james.sieve.postgres.model.PostgresSieveScriptId; import org.apache.james.sieverepository.api.ScriptContent; import org.apache.james.sieverepository.api.ScriptName; import org.apache.james.sieverepository.api.ScriptSummary; @@ -74,6 +75,7 @@ public void putScript(Username username, ScriptName name, ScriptContent content) .scriptContent(content.getValue()) .scriptSize(content.length()) .isActive(false) + .id(PostgresSieveScriptId.generate()) .build()) .flatMap(upsertedScripts -> { if (upsertedScripts > 0) { diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveScriptDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveScriptDAO.java index b87778db9f1..a1d8b93b496 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveScriptDAO.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveScriptDAO.java @@ -23,6 +23,7 @@ import static org.apache.james.sieve.postgres.PostgresSieveModule.PostgresSieveScriptTable.ACTIVATION_DATE_TIME; import static org.apache.james.sieve.postgres.PostgresSieveModule.PostgresSieveScriptTable.IS_ACTIVE; import static org.apache.james.sieve.postgres.PostgresSieveModule.PostgresSieveScriptTable.SCRIPT_CONTENT; +import static org.apache.james.sieve.postgres.PostgresSieveModule.PostgresSieveScriptTable.SCRIPT_ID; import static org.apache.james.sieve.postgres.PostgresSieveModule.PostgresSieveScriptTable.SCRIPT_NAME; import static org.apache.james.sieve.postgres.PostgresSieveModule.PostgresSieveScriptTable.SCRIPT_SIZE; import static org.apache.james.sieve.postgres.PostgresSieveModule.PostgresSieveScriptTable.TABLE_NAME; @@ -37,6 +38,7 @@ import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.Username; import org.apache.james.sieve.postgres.model.PostgresSieveScript; +import org.apache.james.sieve.postgres.model.PostgresSieveScriptId; import org.apache.james.sieverepository.api.ScriptName; import org.jooq.Record; @@ -53,6 +55,7 @@ public PostgresSieveScriptDAO(@Named(DEFAULT_INJECT) PostgresExecutor postgresEx public Mono upsertScript(PostgresSieveScript sieveScript) { return postgresExecutor.executeReturnAffectedRowsCount(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME) + .set(SCRIPT_ID, sieveScript.getId().getValue()) .set(USERNAME, sieveScript.getUsername()) .set(SCRIPT_NAME, sieveScript.getScriptName()) .set(SCRIPT_SIZE, sieveScript.getScriptSize()) @@ -147,6 +150,7 @@ private Function recordToPostgresSieveScript() { .scriptSize(record.get(SCRIPT_SIZE)) .isActive(record.get(IS_ACTIVE)) .activationDateTime(record.get(ACTIVATION_DATE_TIME)) + .id(new PostgresSieveScriptId(record.get(SCRIPT_ID))) .build(); } } diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/model/PostgresSieveScript.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/model/PostgresSieveScript.java index d5831d54649..f1a29812ccb 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/model/PostgresSieveScript.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/model/PostgresSieveScript.java @@ -41,6 +41,7 @@ public static class Builder { private long scriptSize; private boolean isActive; private OffsetDateTime activationDateTime; + private PostgresSieveScriptId id; public Builder username(String username) { Preconditions.checkNotNull(username); @@ -64,6 +65,11 @@ public Builder scriptSize(long scriptSize) { return this; } + public Builder id(PostgresSieveScriptId id) { + this.id = id; + return this; + } + public Builder isActive(boolean isActive) { this.isActive = isActive; return this; @@ -77,11 +83,13 @@ public Builder activationDateTime(OffsetDateTime offsetDateTime) { public PostgresSieveScript build() { Preconditions.checkState(StringUtils.isNotBlank(username), "'username' is mandatory"); Preconditions.checkState(StringUtils.isNotBlank(scriptName), "'scriptName' is mandatory"); + Preconditions.checkState(id != null, "'id' is mandatory"); - return new PostgresSieveScript(username, scriptName, scriptContent, scriptSize, isActive, activationDateTime); + return new PostgresSieveScript(id, username, scriptName, scriptContent, scriptSize, isActive, activationDateTime); } } + private final PostgresSieveScriptId id; private final String username; private final String scriptName; private final String scriptContent; @@ -89,7 +97,9 @@ public PostgresSieveScript build() { private final boolean isActive; private final OffsetDateTime activationDateTime; - private PostgresSieveScript(String username, String scriptName, String scriptContent, long scriptSize, boolean isActive, OffsetDateTime activationDateTime) { + private PostgresSieveScript(PostgresSieveScriptId id, String username, String scriptName, String scriptContent, + long scriptSize, boolean isActive, OffsetDateTime activationDateTime) { + this.id = id; this.username = username; this.scriptName = scriptName; this.scriptContent = scriptContent; @@ -98,6 +108,10 @@ private PostgresSieveScript(String username, String scriptName, String scriptCon this.activationDateTime = activationDateTime; } + public PostgresSieveScriptId getId() { + return id; + } + public String getUsername() { return username; } diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/model/PostgresSieveScriptId.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/model/PostgresSieveScriptId.java new file mode 100644 index 00000000000..adb0778f1fc --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/model/PostgresSieveScriptId.java @@ -0,0 +1,38 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.sieve.postgres.model; + +import java.util.UUID; + +public class PostgresSieveScriptId { + public static PostgresSieveScriptId generate() { + return new PostgresSieveScriptId(UUID.randomUUID()); + } + + private final UUID value; + + public PostgresSieveScriptId(UUID value) { + this.value = value; + } + + public UUID getValue() { + return value; + } +} From 9c53bb9e8635a1d96aef6617b0e0cf7fe4400626 Mon Sep 17 00:00:00 2001 From: vttran Date: Wed, 20 Dec 2023 14:23:37 +0700 Subject: [PATCH 141/341] JAMES-2586 Fix [PGSQL] Concurrency control for flags updates (#1858) --- .../postgres/mail/PostgresMessageMapper.java | 41 ++++--- .../postgres/mail/PostgresMessageModule.java | 19 +++ .../mail/dao/PostgresMailboxMessageDAO.java | 111 +++++++++++++----- .../postgres/mail/PostgresMapperProvider.java | 2 +- .../mailbox/store/FlagsUpdateCalculator.java | 7 ++ .../store/mail/model/MessageMapperTest.java | 71 +++++------ 6 files changed, 174 insertions(+), 77 deletions(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java index 6a10891a127..f158744c381 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java @@ -324,24 +324,37 @@ private Mono updateFlags(ComposedMessageIdWithMetaData currentMeta FlagsUpdateCalculator flagsUpdateCalculator, ModSeq newModSeq) { Flags oldFlags = currentMetaData.getFlags(); - Flags newFlags = flagsUpdateCalculator.buildNewFlags(oldFlags); - ComposedMessageId composedMessageId = currentMetaData.getComposedMessageId(); - return Mono.just(UpdatedFlags.builder() + if (oldFlags.equals(flagsUpdateCalculator.buildNewFlags(oldFlags))) { + return Mono.just(UpdatedFlags.builder() .messageId(composedMessageId.getMessageId()) .oldFlags(oldFlags) - .newFlags(newFlags) - .uid(composedMessageId.getUid())) - .flatMap(builder -> { - if (oldFlags.equals(newFlags)) { - return Mono.just(builder.modSeq(currentMetaData.getModSeq()) - .build()); - } - return Mono.fromCallable(() -> builder.modSeq(newModSeq).build()) - .flatMap(updatedFlags -> mailboxMessageDAO.updateFlag((PostgresMailboxId) composedMessageId.getMailboxId(), composedMessageId.getUid(), updatedFlags) - .thenReturn(updatedFlags)); - }); + .newFlags(oldFlags) + .uid(composedMessageId.getUid()) + .modSeq(currentMetaData.getModSeq()) + .build()); + } else { + return Mono.just(flagsUpdateCalculator.getMode()) + .flatMap(mode -> { + switch (mode) { + case ADD: + return mailboxMessageDAO.addFlags((PostgresMailboxId) composedMessageId.getMailboxId(), composedMessageId.getUid(), flagsUpdateCalculator.providedFlags(), newModSeq); + case REMOVE: + return mailboxMessageDAO.removeFlags((PostgresMailboxId) composedMessageId.getMailboxId(), composedMessageId.getUid(), flagsUpdateCalculator.providedFlags(), newModSeq); + case REPLACE: + return mailboxMessageDAO.replaceFlags((PostgresMailboxId) composedMessageId.getMailboxId(), composedMessageId.getUid(), flagsUpdateCalculator.providedFlags(), newModSeq); + default: + throw new RuntimeException("Unknown MessageRange type " + mode); + } + }).map(updatedFlags -> UpdatedFlags.builder() + .messageId(composedMessageId.getMessageId()) + .oldFlags(oldFlags) + .newFlags(updatedFlags) + .uid(composedMessageId.getUid()) + .modSeq(newModSeq) + .build()); + } } @Override diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java index feb51c7c5ef..01baef4ed7d 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java @@ -108,6 +108,24 @@ interface MessageToMailboxTable { Field USER_FLAGS = DSL.field("user_flags", DataTypes.STRING_ARRAY); Field SAVE_DATE = DSL.field("save_date", DataTypes.TIMESTAMP); + String REMOVE_ELEMENTS_FROM_ARRAY_FUNCTION_NAME = "remove_elements_from_array"; + String CREATE_ARRAY_REMOVE_JAMES_FUNCTION = + "CREATE OR REPLACE FUNCTION " + REMOVE_ELEMENTS_FROM_ARRAY_FUNCTION_NAME + "(\n" + + " source text[],\n" + + " elements_to_remove text[])\n" + + " RETURNS text[]\n" + + "AS\n" + + "$$\n" + + "DECLARE\n" + + " result text[];\n" + + "BEGIN\n" + + " select array_agg(elements) INTO result\n" + + " from (select unnest(source)\n" + + " except\n" + + " select unnest(elements_to_remove)) t (elements);\n" + + " RETURN result;\n" + + "END;\n" + + "$$ LANGUAGE plpgsql;"; PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) @@ -130,6 +148,7 @@ interface MessageToMailboxTable { foreignKey(MESSAGE_ID).references(MessageTable.TABLE_NAME, MessageTable.MESSAGE_ID)) .comment("Holds mailbox and flags for each message"))) .supportsRowLevelSecurity() + .addAdditionalAlterQueries(CREATE_ARRAY_REMOVE_JAMES_FUNCTION) .build(); PostgresIndex MESSAGE_ID_INDEX = PostgresIndex.name("message_mailbox_message_id_index") diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java index b9aa1fc89f8..168e0ff670c 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java @@ -37,6 +37,7 @@ import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.MESSAGE_ID; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.MESSAGE_UID; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.MOD_SEQ; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.REMOVE_ELEMENTS_FROM_ARRAY_FUNCTION_NAME; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.SAVE_DATE; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.TABLE_NAME; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.THREAD_ID; @@ -45,6 +46,7 @@ import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.FETCH_TYPE_TO_FETCH_STRATEGY; import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.MESSAGE_METADATA_FIELDS_REQUIRE; import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.RECORD_TO_COMPOSED_MESSAGE_ID_WITH_META_DATA_FUNCTION; +import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.RECORD_TO_FLAGS_FUNCTION; import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.RECORD_TO_MESSAGE_METADATA_FUNCTION; import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.RECORD_TO_MESSAGE_UID_FUNCTION; @@ -53,7 +55,6 @@ import java.util.function.Function; import javax.mail.Flags; -import javax.mail.Flags.Flag; import org.apache.commons.lang3.tuple.Pair; import org.apache.james.backends.postgres.utils.PostgresExecutor; @@ -62,7 +63,6 @@ import org.apache.james.mailbox.model.ComposedMessageIdWithMetaData; import org.apache.james.mailbox.model.MessageMetaData; import org.apache.james.mailbox.model.MessageRange; -import org.apache.james.mailbox.model.UpdatedFlags; import org.apache.james.mailbox.postgres.PostgresMailboxId; import org.apache.james.mailbox.postgres.PostgresMessageId; import org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable; @@ -83,6 +83,7 @@ import org.jooq.UpdateConditionStep; import org.jooq.UpdateSetStep; import org.jooq.impl.DSL; +import org.jooq.util.postgres.PostgresDSL; import com.google.common.collect.Iterables; @@ -313,46 +314,102 @@ public Flux findAllRecentMessageMetadata(Postgres .map(RECORD_TO_COMPOSED_MESSAGE_ID_WITH_META_DATA_FUNCTION); } - public Mono updateFlag(PostgresMailboxId mailboxId, MessageUid uid, UpdatedFlags updatedFlags) { - return postgresExecutor.executeVoid(dslContext -> - Mono.from(buildUpdateFlagStatement(dslContext, updatedFlags, mailboxId, uid))); + public Mono replaceFlags(PostgresMailboxId mailboxId, MessageUid uid, Flags newFlags, ModSeq newModSeq) { + return postgresExecutor.executeRow(dslContext -> Mono.from(buildReplaceFlagsStatement(dslContext, newFlags, mailboxId, uid, newModSeq) + .returning(MESSAGE_METADATA_FIELDS_REQUIRE))) + .map(RECORD_TO_FLAGS_FUNCTION); } - public Mono listDistinctUserFlags(PostgresMailboxId mailboxId) { - return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.selectDistinct(UNNEST_FIELD.apply(USER_FLAGS)) - .from(TABLE_NAME) - .where(MAILBOX_ID.eq(mailboxId.asUuid())))) - .map(record -> record.get(0, String.class)) - .collectList() - .map(flagList -> { - Flags flags = new Flags(); - flagList.forEach(flags::add); - return flags; - }); + public Mono addFlags(PostgresMailboxId mailboxId, MessageUid uid, Flags appendFlags, ModSeq newModSeq) { + return postgresExecutor.executeRow(dslContext -> Mono.from(buildAddFlagsStatement(dslContext, appendFlags, mailboxId, uid, newModSeq) + .returning(MESSAGE_METADATA_FIELDS_REQUIRE))) + .map(RECORD_TO_FLAGS_FUNCTION); + } + + public Mono removeFlags(PostgresMailboxId mailboxId, MessageUid uid, Flags removeFlags, ModSeq newModSeq) { + return postgresExecutor.executeRow(dslContext -> Mono.from(buildRemoveFlagsStatement(dslContext, removeFlags, mailboxId, uid, newModSeq) + .returning(MESSAGE_METADATA_FIELDS_REQUIRE))) + .map(RECORD_TO_FLAGS_FUNCTION); + } + + private UpdateConditionStep buildAddFlagsStatement(DSLContext dslContext, Flags addFlags, + PostgresMailboxId mailboxId, MessageUid uid, ModSeq newModSeq) { + AtomicReference> updateStatement = new AtomicReference<>(dslContext.update(TABLE_NAME)); + + BOOLEAN_FLAGS_MAPPING.forEach((flagColumn, flagMapped) -> { + if (addFlags.contains(flagMapped)) { + updateStatement.getAndUpdate(currentStatement -> currentStatement.set(flagColumn, true)); + } + }); + + if (addFlags.getUserFlags() != null && addFlags.getUserFlags().length > 0) { + if (addFlags.getUserFlags().length == 1) { + updateStatement.getAndUpdate(currentStatement -> currentStatement.set(USER_FLAGS, PostgresDSL.arrayAppend(USER_FLAGS, addFlags.getUserFlags()[0]))); + } else { + updateStatement.getAndUpdate(currentStatement -> currentStatement.set(USER_FLAGS, PostgresDSL.arrayCat(USER_FLAGS, addFlags.getUserFlags()))); + } + } + + return updateStatement.get() + .set(MOD_SEQ, newModSeq.asLong()) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(MESSAGE_UID.eq(uid.asLong())); + } + + private UpdateConditionStep buildReplaceFlagsStatement(DSLContext dslContext, Flags newFlags, + PostgresMailboxId mailboxId, MessageUid uid, ModSeq newModSeq) { + AtomicReference> updateStatement = new AtomicReference<>(dslContext.update(TABLE_NAME)); + + BOOLEAN_FLAGS_MAPPING.forEach((flagColumn, flagMapped) -> { + updateStatement.getAndUpdate(currentStatement -> currentStatement.set(flagColumn, newFlags.contains(flagMapped))); + }); + + return updateStatement.get() + .set(USER_FLAGS, newFlags.getUserFlags()) + .set(MOD_SEQ, newModSeq.asLong()) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(MESSAGE_UID.eq(uid.asLong())); } - private UpdateConditionStep buildUpdateFlagStatement(DSLContext dslContext, UpdatedFlags updatedFlags, - PostgresMailboxId mailboxId, MessageUid uid) { + private UpdateConditionStep buildRemoveFlagsStatement(DSLContext dslContext, Flags removeFlags, + PostgresMailboxId mailboxId, MessageUid uid, ModSeq newModSeq) { AtomicReference> updateStatement = new AtomicReference<>(dslContext.update(TABLE_NAME)); BOOLEAN_FLAGS_MAPPING.forEach((flagColumn, flagMapped) -> { - if (updatedFlags.isChanged(flagMapped)) { - updateStatement.getAndUpdate(currentStatement -> { - if (flagMapped.equals(Flag.RECENT)) { - return currentStatement.set(flagColumn, updatedFlags.getNewFlags().contains(Flag.RECENT)); - } - return currentStatement.set(flagColumn, updatedFlags.isModifiedToSet(flagMapped)); - }); + if (removeFlags.contains(flagMapped)) { + updateStatement.getAndUpdate(currentStatement -> currentStatement.set(flagColumn, false)); } }); + if (removeFlags.getUserFlags() != null && removeFlags.getUserFlags().length > 0) { + if (removeFlags.getUserFlags().length == 1) { + updateStatement.getAndUpdate(currentStatement -> currentStatement.set(USER_FLAGS, PostgresDSL.arrayRemove(USER_FLAGS, removeFlags.getUserFlags()[0]))); + } else { + updateStatement.getAndUpdate(currentStatement -> currentStatement.set(USER_FLAGS, DSL.function(REMOVE_ELEMENTS_FROM_ARRAY_FUNCTION_NAME, String[].class, + USER_FLAGS, + DSL.array(removeFlags.getUserFlags())))); + } + } + return updateStatement.get() - .set(USER_FLAGS, updatedFlags.getNewFlags().getUserFlags()) - .set(MOD_SEQ, updatedFlags.getModSeq().asLong()) + .set(MOD_SEQ, newModSeq.asLong()) .where(MAILBOX_ID.eq(mailboxId.asUuid())) .and(MESSAGE_UID.eq(uid.asLong())); } + public Mono listDistinctUserFlags(PostgresMailboxId mailboxId) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.selectDistinct(UNNEST_FIELD.apply(USER_FLAGS)) + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())))) + .map(record -> record.get(0, String.class)) + .collectList() + .map(flagList -> { + Flags flags = new Flags(); + flagList.forEach(flags::add); + return flags; + }); + } + public Flux resetRecentFlag(PostgresMailboxId mailboxId, List uids, ModSeq newModSeq) { Function, Flux> queryPublisherFunction = uidsMatching -> postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.update(TABLE_NAME) .set(IS_RECENT, false) diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMapperProvider.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMapperProvider.java index 7258ba7a19e..c4705bf2598 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMapperProvider.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMapperProvider.java @@ -65,7 +65,7 @@ public PostgresMapperProvider(PostgresExtension postgresExtension) { @Override public List getSupportedCapabilities() { - return ImmutableList.of(Capabilities.ANNOTATION, Capabilities.MAILBOX, Capabilities.MESSAGE, Capabilities.MOVE, Capabilities.ATTACHMENT); + return ImmutableList.of(Capabilities.ANNOTATION, Capabilities.MAILBOX, Capabilities.MESSAGE, Capabilities.MOVE, Capabilities.ATTACHMENT, Capabilities.THREAD_SAFE_FLAGS_UPDATE); } @Override diff --git a/mailbox/store/src/main/java/org/apache/james/mailbox/store/FlagsUpdateCalculator.java b/mailbox/store/src/main/java/org/apache/james/mailbox/store/FlagsUpdateCalculator.java index 310ee47370c..4c60a12cd22 100644 --- a/mailbox/store/src/main/java/org/apache/james/mailbox/store/FlagsUpdateCalculator.java +++ b/mailbox/store/src/main/java/org/apache/james/mailbox/store/FlagsUpdateCalculator.java @@ -48,4 +48,11 @@ public Flags buildNewFlags(Flags oldFlags) { return updatedFlags; } + public Flags providedFlags() { + return providedFlags; + } + + public MessageManager.FlagsUpdateMode getMode() { + return mode; + } } diff --git a/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MessageMapperTest.java b/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MessageMapperTest.java index 2bd85ce574c..469b74e0b5f 100644 --- a/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MessageMapperTest.java +++ b/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MessageMapperTest.java @@ -264,11 +264,11 @@ void getHeadersBytesShouldBePresentWhenAttachmentMetadataFetchType() throws Exce void messagesCanBeRetrievedInMailboxWithRangeTypeRange() throws MailboxException, IOException { saveMessages(); Iterator retrievedMessageIterator = messageMapper - .findInMailbox(benwaInboxMailbox, MessageRange.range(message1.getUid(), message4.getUid()), MessageMapper.FetchType.FULL, LIMIT); + .findInMailbox(benwaInboxMailbox, MessageRange.range(message1.getUid(), message4.getUid()), MessageMapper.FetchType.FULL, LIMIT); assertMessages(Lists.newArrayList(retrievedMessageIterator)).containOnly(message1, message2, message3, message4); } - + @Test void messagesCanBeRetrievedInMailboxWithRangeTypeRangeContainingAHole() throws MailboxException, IOException { saveMessages(); @@ -282,7 +282,7 @@ void messagesCanBeRetrievedInMailboxWithRangeTypeRangeContainingAHole() throws M void messagesCanBeRetrievedInMailboxWithRangeTypeFrom() throws MailboxException, IOException { saveMessages(); Iterator retrievedMessageIterator = messageMapper - .findInMailbox(benwaInboxMailbox, MessageRange.from(message3.getUid()), MessageMapper.FetchType.FULL, LIMIT); + .findInMailbox(benwaInboxMailbox, MessageRange.from(message3.getUid()), MessageMapper.FetchType.FULL, LIMIT); assertMessages(Lists.newArrayList(retrievedMessageIterator)).containOnly(message3, message4, message5); } @@ -291,7 +291,7 @@ void messagesCanBeRetrievedInMailboxWithRangeTypeFromContainingAHole() throws Ma saveMessages(); messageMapper.delete(benwaInboxMailbox, message4); Iterator retrievedMessageIterator = messageMapper - .findInMailbox(benwaInboxMailbox, MessageRange.from(message3.getUid()), MessageMapper.FetchType.FULL, LIMIT); + .findInMailbox(benwaInboxMailbox, MessageRange.from(message3.getUid()), MessageMapper.FetchType.FULL, LIMIT); assertMessages(Lists.newArrayList(retrievedMessageIterator)).containOnly(message3, message5); } @@ -307,7 +307,7 @@ void messagesCanBeRetrievedInMailboxWithRangeTypeAllContainingHole() throws Mail saveMessages(); messageMapper.delete(benwaInboxMailbox, message1); Iterator retrievedMessageIterator = messageMapper - .findInMailbox(benwaInboxMailbox, MessageRange.all(), MessageMapper.FetchType.FULL, LIMIT); + .findInMailbox(benwaInboxMailbox, MessageRange.all(), MessageMapper.FetchType.FULL, LIMIT); assertMessages(Lists.newArrayList(retrievedMessageIterator)).containOnly(message2, message3, message4, message5); } @@ -679,9 +679,9 @@ void copyShouldCreateAMessageInDestination() throws MailboxException, IOExceptio assertThat(messageMapper.getLastUid(benwaInboxMailbox).get()).isGreaterThan(message6.getUid()); MailboxMessage result = messageMapper.findInMailbox(benwaInboxMailbox, - MessageRange.one(messageMapper.getLastUid(benwaInboxMailbox).get()), - MessageMapper.FetchType.FULL, - LIMIT) + MessageRange.one(messageMapper.getLastUid(benwaInboxMailbox).get()), + MessageMapper.FetchType.FULL, + LIMIT) .next(); assertThat(result).isEqualToWithoutUidAndAttachment(message7, MessageMapper.FetchType.FULL); @@ -707,11 +707,11 @@ void copiedMessageShouldBeMarkedAsRecent() throws MailboxException { MessageMetaData metaData = messageMapper.copy(benwaInboxMailbox, message); assertThat( messageMapper.findInMailbox(benwaInboxMailbox, - MessageRange.one(metaData.getUid()), - MessageMapper.FetchType.METADATA, - LIMIT - ).next() - .isRecent() + MessageRange.one(metaData.getUid()), + MessageMapper.FetchType.METADATA, + LIMIT + ).next() + .isRecent() ).isTrue(); } @@ -723,10 +723,10 @@ void copiedRecentMessageShouldBeMarkedAsRecent() throws MailboxException { MessageMetaData metaData = messageMapper.copy(benwaInboxMailbox, message); assertThat( messageMapper.findInMailbox(benwaInboxMailbox, - MessageRange.one(metaData.getUid()), - MessageMapper.FetchType.METADATA, - LIMIT - ).next() + MessageRange.one(metaData.getUid()), + MessageMapper.FetchType.METADATA, + LIMIT + ).next() .isRecent() ).isTrue(); } @@ -738,11 +738,11 @@ void copiedMessageShouldNotChangeTheFlagsOnOriginalMessage() throws MailboxExcep messageMapper.copy(benwaInboxMailbox, message); assertThat( messageMapper.findInMailbox(benwaWorkMailbox, - MessageRange.one(message6.getUid()), - MessageMapper.FetchType.METADATA, - LIMIT - ).next() - .isRecent() + MessageRange.one(message6.getUid()), + MessageMapper.FetchType.METADATA, + LIMIT + ).next() + .isRecent() ).isFalse(); } @@ -758,7 +758,7 @@ protected void flagsReplacementShouldReturnAnUpdatedFlagHighlightingTheReplaceme saveMessages(); ModSeq modSeq = messageMapper.getHighestModSeq(benwaInboxMailbox); Optional updatedFlags = messageMapper.updateFlags(benwaInboxMailbox, message1.getUid(), - new FlagsUpdateCalculator(new Flags(Flags.Flag.FLAGGED), FlagsUpdateMode.REPLACE)); + new FlagsUpdateCalculator(new Flags(Flags.Flag.FLAGGED), FlagsUpdateMode.REPLACE)); assertThat(updatedFlags) .contains(UpdatedFlags.builder() .uid(message1.getUid()) @@ -776,12 +776,12 @@ protected void flagsAdditionShouldReturnAnUpdatedFlagHighlightingTheAddition() t ModSeq modSeq = messageMapper.getHighestModSeq(benwaInboxMailbox); assertThat(messageMapper.updateFlags(benwaInboxMailbox, message1.getUid(), new FlagsUpdateCalculator(new Flags(Flags.Flag.SEEN), FlagsUpdateMode.ADD))) .contains(UpdatedFlags.builder() - .uid(message1.getUid()) - .messageId(message1.getMessageId()) - .modSeq(modSeq.next()) - .oldFlags(new Flags(Flags.Flag.FLAGGED)) - .newFlags(new FlagsBuilder().add(Flags.Flag.SEEN, Flags.Flag.FLAGGED).build()) - .build()); + .uid(message1.getUid()) + .messageId(message1.getMessageId()) + .modSeq(modSeq.next()) + .oldFlags(new Flags(Flags.Flag.FLAGGED)) + .newFlags(new FlagsBuilder().add(Flags.Flag.SEEN, Flags.Flag.FLAGGED).build()) + .build()); } @Test @@ -865,7 +865,7 @@ void messagePropertiesShouldBeStored() throws Exception { assertProperties(message.getProperties().toProperties()).containsOnly(propBuilder.toProperties()); } - + @Test void messagePropertiesShouldBeStoredWhenDuplicateEntries() throws Exception { PropertyBuilder propBuilder = new PropertyBuilder(); @@ -949,7 +949,7 @@ protected void userFlagsUpdateShouldReturnCorrectUpdatedFlagsWhenNoop() throws E saveMessages(); assertThat( - messageMapper.updateFlags(benwaInboxMailbox,message1.getUid(), + messageMapper.updateFlags(benwaInboxMailbox, message1.getUid(), new FlagsUpdateCalculator(new Flags(USER_FLAG), FlagsUpdateMode.REMOVE))) .contains( UpdatedFlags.builder() @@ -991,7 +991,7 @@ public void setFlagsShouldWorkWithConcurrencyWithRemove() throws Exception { int updateCount = 40; ConcurrentTestRunner.builder() .operation((threadNumber, step) -> { - if (step < updateCount / 2) { + if (step < updateCount / 2) { messageMapper.updateFlags(benwaInboxMailbox, message1.getUid(), new FlagsUpdateCalculator(new Flags("custom-" + threadNumber + "-" + step), FlagsUpdateMode.ADD)); } else { @@ -1171,7 +1171,7 @@ void getApplicableFlagShouldHaveEffectWhenUnsetMessageFlagThenComputingApplicabl @Test void getApplicableFlagShouldHaveNotEffectWhenUnsetMessageFlagThenIncrementalApplicableFlags() throws Exception { - Assume.assumeTrue(mapperProvider.getSupportedCapabilities().contains(MapperProvider.Capabilities.THREAD_SAFE_FLAGS_UPDATE)); + Assume.assumeTrue(mapperProvider.getSupportedCapabilities().contains(MapperProvider.Capabilities.INCREMENTAL_APPLICABLE_FLAGS)); String customFlag1 = "custom1"; String customFlag2 = "custom2"; message1.setFlags(new Flags(customFlag1)); @@ -1265,7 +1265,8 @@ void getUidsShouldNotReturnUidsOfDeletedMessages() throws Exception { messageMapper.updateFlags(benwaInboxMailbox, new FlagsUpdateCalculator(new Flags(Flag.DELETED), FlagsUpdateMode.ADD), - MessageRange.range(message2.getUid(), message4.getUid())).forEachRemaining(any -> {}); + MessageRange.range(message2.getUid(), message4.getUid())).forEachRemaining(any -> { + }); List uids = messageMapper.retrieveMessagesMarkedForDeletion(benwaInboxMailbox, MessageRange.all()); messageMapper.deleteMessages(benwaInboxMailbox, uids); @@ -1397,7 +1398,7 @@ protected void saveMessages() throws MailboxException { private MailboxMessage retrieveMessageFromStorage(MailboxMessage message) throws MailboxException { return messageMapper.findInMailbox(benwaInboxMailbox, MessageRange.one(message.getUid()), MessageMapper.FetchType.METADATA, LIMIT).next(); } - + private MailboxMessage createMessage(Mailbox mailbox, MessageId messageId, String content, int bodyStart, PropertyBuilder propertyBuilder) { return new SimpleMailboxMessage(messageId, ThreadId.fromBaseMessageId(messageId), new Date(), content.length(), bodyStart, new ByteContent(content.getBytes()), new Flags(), propertyBuilder.build(), mailbox.getMailboxId()); } From c7c72c9b28f532e59d8186b12562ad29813f03ea Mon Sep 17 00:00:00 2001 From: vttran Date: Wed, 20 Dec 2023 16:56:10 +0700 Subject: [PATCH 142/341] [PGSQL] ADR on PGSQL flags update concurrency control mechanism (#1867) --- ...gresql-flags-update-concurrency-control.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/adr/0072-postgresql-flags-update-concurrency-control.md diff --git a/src/adr/0072-postgresql-flags-update-concurrency-control.md b/src/adr/0072-postgresql-flags-update-concurrency-control.md new file mode 100644 index 00000000000..060e0c60960 --- /dev/null +++ b/src/adr/0072-postgresql-flags-update-concurrency-control.md @@ -0,0 +1,57 @@ +# 72. Postgresql flags update concurrency control mechanism + +Date: 2023-12-19 + +## Status + +Not-Implemented + +## Context + +We are facing a concurrency issue when update flags concurrently. +The multiple queries from clients simultaneously access the `user_flags` column of the `message_mailbox` table in PostgreSQL. +Currently, the James fetches the current data, performs changes, and then updates to database. +However, this approach does not ensure thread safety and may lead to concurrency issues. + +CRDT (conflict-free replicated data types) principles semantic can lay the ground to solving concurrency issues in a lock-free manner, and could thus be used for the problem at hand. This explores a different paradigm for addressing concurrency challenges without resorting to traditional transactions. + +## Decision + +To address the concurrency issue when clients make changes to the user_flags column, +we decide to use PostgreSQL's built-in functions to perform direct operations on the `user_flags` array column +(without fetching the current data and recalculating on James application). + +Specifically, we will use PostgreSQL functions such as +`array_remove`, `array_cat`, or `array_append` to perform specific operations as requested by the client (e.g., add, remove, replace elements). + +Additionally, we will create a custom function, say `remove_elements_from_array`, +for removing elements from the array since PostgreSQL does not support `array_remove` with an array input. + +## Consequences + +Pros: +- This solution reduces the complexity of working with the evaluate new user flags on James. +- Eliminates the step of fetching the current data and recalculating the new value of user_flags before updating. +- Ensures thread safety and reduces the risk of concurrency issues. + +Cons: +- The performance will depend on the performance of the PostgreSQL functions. + +## Alternatives + +- Optimistic Concurrency Control (OCC): Using optimistic concurrency control to ensure that only one version of the data is updated at a time. +However, this may increase the complexity of the code and require careful management of data versions. +The chosen solution using PostgreSQL functions was preferred for its simplicity and direct support for array operations. + +- Read-Then-Write Logic into Transactions: Transactions come with associated costs, including extra locking, coordination overhead, +and dependency on connection pooling. By avoiding the use of transactions, we aim to reduce these potential drawbacks +and explore other mechanisms for ensuring data consistency. + +## References + +- [JIRA](https://issues.apache.org/jira/browse/JAMES-2586) +- [PostgreSQL Array Functions and Operators](https://www.postgresql.org/docs/current/functions-array.html) +- [CRDT](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type) + + + From 5056b8679b5b5fa4918682b430d6409a39e7d874 Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Tue, 19 Dec 2023 17:28:53 +0700 Subject: [PATCH 143/341] JAMES-2586 Add search module chooser for Postgres app --- server/apps/postgres-app/pom.xml | 34 +++++ .../james/PostgresJamesConfiguration.java | 24 +++- .../apache/james/PostgresJamesServerMain.java | 9 +- .../james/JamesCapabilitiesServerTest.java | 2 + .../apache/james/PostgresJamesServerTest.java | 2 + .../PostgresWithLDAPJamesServerTest.java | 2 + .../PostgresWithOpenSearchDisabledTest.java | 128 ++++++++++++++++++ .../apache/james/PostgresWithTikaTest.java | 44 ++++++ .../WithScanningSearchImmutableTest.java} | 30 ++-- .../james/WithScanningSearchMutableTest.java | 42 ++++++ .../container/guice/mailbox-postgres/pom.xml | 4 - .../mailbox/LuceneSearchMailboxModule.java | 58 -------- .../mailbox/PostgresMailboxModule.java | 1 - 13 files changed, 302 insertions(+), 78 deletions(-) create mode 100644 server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithOpenSearchDisabledTest.java create mode 100644 server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithTikaTest.java rename server/{container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresQuotaSearchModule.java => apps/postgres-app/src/test/java/org/apache/james/WithScanningSearchImmutableTest.java} (55%) create mode 100644 server/apps/postgres-app/src/test/java/org/apache/james/WithScanningSearchMutableTest.java delete mode 100644 server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/LuceneSearchMailboxModule.java diff --git a/server/apps/postgres-app/pom.xml b/server/apps/postgres-app/pom.xml index c49ba04d59e..ab53a56738f 100644 --- a/server/apps/postgres-app/pom.xml +++ b/server/apps/postgres-app/pom.xml @@ -44,12 +44,24 @@ + + ${james.groupId} + apache-james-backends-opensearch + test-jar + test + ${james.groupId} apache-james-backends-postgres test-jar test + + ${james.groupId} + apache-james-mailbox-opensearch + test-jar + test + ${james.groupId} apache-james-mailbox-postgres @@ -60,6 +72,18 @@ ${james.groupId} apache-james-mailbox-quota-search-scanning + + ${james.groupId} + apache-james-mailbox-tika + test-jar + test + + + ${james.groupId} + james-server-cassandra-app + test-jar + test + ${james.groupId} james-server-cli @@ -119,6 +143,10 @@ ${james.groupId} james-server-guice-managedsieve + + ${james.groupId} + james-server-guice-opensearch + ${james.groupId} james-server-guice-pop @@ -151,6 +179,12 @@ ${james.groupId} james-server-guice-webadmin-mailrepository + + ${james.groupId} + james-server-jmap-draft-integration-testing + test-jar + test + ${james.groupId} james-server-mailbox-adapter diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java index 34305a69e36..54554aa3fe1 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java @@ -30,14 +30,19 @@ import org.apache.james.server.core.configuration.Configuration; import org.apache.james.server.core.configuration.FileConfigurationProvider; import org.apache.james.server.core.filesystem.FileSystemImpl; +import org.apache.james.utils.PropertiesProvider; + +import com.github.fge.lambdas.Throwing; public class PostgresJamesConfiguration implements Configuration { public static class Builder { private Optional rootDirectory; private Optional configurationPath; private Optional usersRepositoryImplementation; + private Optional searchConfiguration; private Builder() { + searchConfiguration = Optional.empty(); rootDirectory = Optional.empty(); configurationPath = Optional.empty(); usersRepositoryImplementation = Optional.empty(); @@ -76,12 +81,21 @@ public Builder usersRepository(UsersRepositoryModuleChooser.Implementation imple return this; } + public Builder searchConfiguration(SearchConfiguration searchConfiguration) { + this.searchConfiguration = Optional.of(searchConfiguration); + return this; + } + public PostgresJamesConfiguration build() { ConfigurationPath configurationPath = this.configurationPath.orElse(new ConfigurationPath(FileSystem.FILE_PROTOCOL_AND_CONF)); JamesServerResourceLoader directories = new JamesServerResourceLoader(rootDirectory .orElseThrow(() -> new MissingArgumentException("Server needs a working.directory env entry"))); FileSystemImpl fileSystem = new FileSystemImpl(directories); + PropertiesProvider propertiesProvider = new PropertiesProvider(fileSystem, configurationPath); + + SearchConfiguration searchConfiguration = this.searchConfiguration.orElseGet(Throwing.supplier( + () -> SearchConfiguration.parse(propertiesProvider))); FileConfigurationProvider configurationProvider = new FileConfigurationProvider(fileSystem, Basic.builder() .configurationPath(configurationPath) @@ -93,6 +107,7 @@ public PostgresJamesConfiguration build() { return new PostgresJamesConfiguration( configurationPath, directories, + searchConfiguration, usersRepositoryChoice); } } @@ -103,11 +118,14 @@ public static Builder builder() { private final ConfigurationPath configurationPath; private final JamesDirectoriesProvider directories; + private final SearchConfiguration searchConfiguration; private final UsersRepositoryModuleChooser.Implementation usersRepositoryImplementation; - public PostgresJamesConfiguration(ConfigurationPath configurationPath, JamesDirectoriesProvider directories, UsersRepositoryModuleChooser.Implementation usersRepositoryImplementation) { + public PostgresJamesConfiguration(ConfigurationPath configurationPath, JamesDirectoriesProvider directories, + SearchConfiguration searchConfiguration, UsersRepositoryModuleChooser.Implementation usersRepositoryImplementation) { this.configurationPath = configurationPath; this.directories = directories; + this.searchConfiguration = searchConfiguration; this.usersRepositoryImplementation = usersRepositoryImplementation; } @@ -121,6 +139,10 @@ public JamesDirectoriesProvider directories() { return directories; } + public SearchConfiguration searchConfiguration() { + return searchConfiguration; + } + public UsersRepositoryModuleChooser.Implementation getUsersRepositoryImplementation() { return usersRepositoryImplementation; } diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index 24cfa7d1cd3..d346debd37c 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -28,9 +28,9 @@ import org.apache.james.modules.data.PostgresUsersRepositoryModule; import org.apache.james.modules.data.SievePostgresRepositoryModules; import org.apache.james.modules.mailbox.DefaultEventModule; -import org.apache.james.modules.mailbox.LuceneSearchMailboxModule; import org.apache.james.modules.mailbox.MemoryDeadLetterModule; import org.apache.james.modules.mailbox.PostgresMailboxModule; +import org.apache.james.modules.mailbox.TikaMailboxModule; import org.apache.james.modules.protocols.IMAPServerModule; import org.apache.james.modules.protocols.LMTPServerModule; import org.apache.james.modules.protocols.ManageSieveServerModule; @@ -85,13 +85,13 @@ public class PostgresJamesServerMain implements JamesServerMain { new PostgresMailboxModule(), new PostgresDataModule(), new MailboxModule(), - new LuceneSearchMailboxModule(), new NoJwtModule(), new RawPostDequeueDecoratorModule(), new SievePostgresRepositoryModules(), new DefaultEventModule(), new TaskManagerModule(), - new MemoryDeadLetterModule()); + new MemoryDeadLetterModule(), + new TikaMailboxModule()); private static final Module POSTGRES_MODULE_AGGREGATE = Modules.combine( new MailetProcessingModule(), POSTGRES_SERVER_MODULE, PROTOCOLS); @@ -112,7 +112,10 @@ public static void main(String[] args) throws Exception { } static GuiceJamesServer createServer(PostgresJamesConfiguration configuration) { + SearchConfiguration searchConfiguration = configuration.searchConfiguration(); + return GuiceJamesServer.forConfiguration(configuration) + .combineWith(SearchModuleChooser.chooseModules(searchConfiguration)) .combineWith(new UsersRepositoryModuleChooser(new PostgresUsersRepositoryModule()) .chooseModules(configuration.getUsersRepositoryImplementation())) .combineWith(POSTGRES_MODULE_AGGREGATE); diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java index b59e3d2ff92..b40620638a6 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java @@ -48,10 +48,12 @@ private static MailboxManager mailboxManager() { PostgresJamesConfiguration.builder() .workingDirectory(tmpDir) .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.openSearch()) .usersRepository(DEFAULT) .build()) .server(configuration -> PostgresJamesServerMain.createServer(configuration) .overrideWith(binder -> binder.bind(MailboxManager.class).toInstance(mailboxManager()))) + .extension(new DockerOpenSearchExtension()) .extension(postgresExtension) .build(); diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java index cf63a637187..cdadef140b5 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java @@ -48,9 +48,11 @@ class PostgresJamesServerTest implements JamesServerConcreteContract { PostgresJamesConfiguration.builder() .workingDirectory(tmpDir) .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.openSearch()) .usersRepository(DEFAULT) .build()) .server(PostgresJamesServerMain::createServer) + .extension(new DockerOpenSearchExtension()) .extension(postgresExtension) .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) .build(); diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java index 288e26d9b30..b5add8f9af5 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java @@ -41,11 +41,13 @@ class PostgresWithLDAPJamesServerTest { PostgresJamesConfiguration.builder() .workingDirectory(tmpDir) .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.openSearch()) .usersRepository(LDAP) .build()) .server(PostgresJamesServerMain::createServer) .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) .extension(new LdapTestExtension()) + .extension(new DockerOpenSearchExtension()) .extension(postgresExtension) .build(); diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithOpenSearchDisabledTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithOpenSearchDisabledTest.java new file mode 100644 index 00000000000..4412dc39d99 --- /dev/null +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithOpenSearchDisabledTest.java @@ -0,0 +1,128 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; + +import org.apache.james.backends.opensearch.OpenSearchConfiguration; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.core.Domain; +import org.apache.james.mailbox.DefaultMailboxes; +import org.apache.james.mailbox.opensearch.events.OpenSearchListeningMessageSearchIndex; +import org.apache.james.modules.EventDeadLettersProbe; +import org.apache.james.modules.MailboxProbeImpl; +import org.apache.james.util.Host; +import org.apache.james.util.Port; +import org.apache.james.utils.DataProbeImpl; +import org.apache.james.utils.SMTPMessageSender; +import org.apache.james.utils.TestIMAPClient; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.google.common.io.Resources; + +public class PostgresWithOpenSearchDisabledTest implements MailsShouldBeWellReceivedConcreteContract { + static PostgresExtension postgresExtension = PostgresExtension.empty(); + + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.openSearchDisabled()) + .usersRepository(DEFAULT) + .build()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(binder -> binder.bind(OpenSearchConfiguration.class) + .toInstance(OpenSearchConfiguration.builder() + .addHost(Host.from("127.0.0.1", 9042)) + .build()))) + .extension(postgresExtension) + .build(); + + @Test + void mailsShouldBeKeptInDeadLetterForLaterIndexing(GuiceJamesServer server) throws Exception { + server.getProbe(DataProbeImpl.class).fluent() + .addDomain(DOMAIN) + .addUser(JAMES_USER, PASSWORD) + .addUser(SENDER, PASSWORD); + + MailboxProbeImpl mailboxProbe = server.getProbe(MailboxProbeImpl.class); + mailboxProbe.createMailbox("#private", JAMES_USER, DefaultMailboxes.INBOX); + + Port smtpPort = Port.of(smtpPort(server)); + String message = Resources.toString(Resources.getResource("eml/htmlMail.eml"), StandardCharsets.UTF_8); + + try (SMTPMessageSender sender = new SMTPMessageSender(Domain.LOCALHOST.asString())) { + sender.connect(JAMES_SERVER_HOST, smtpPort).authenticate(SENDER, PASSWORD); + sendUniqueMessage(sender, message); + } + + CALMLY_AWAIT.until(() -> server.getProbe(EventDeadLettersProbe.class).getEventDeadLetters() + .groupsWithFailedEvents().collectList().block().contains(new OpenSearchListeningMessageSearchIndex.OpenSearchListeningMessageSearchIndexGroup())); + } + + @Test + void searchShouldFail(GuiceJamesServer server) throws Exception { + server.getProbe(DataProbeImpl.class).fluent() + .addDomain(DOMAIN) + .addUser(JAMES_USER, PASSWORD) + .addUser(SENDER, PASSWORD); + + MailboxProbeImpl mailboxProbe = server.getProbe(MailboxProbeImpl.class); + mailboxProbe.createMailbox("#private", JAMES_USER, DefaultMailboxes.INBOX); + + try (TestIMAPClient reader = new TestIMAPClient()) { + int imapPort = imapPort(server); + reader.connect(JAMES_SERVER_HOST, imapPort) + .login(JAMES_USER, PASSWORD) + .select(TestIMAPClient.INBOX); + + assertThat(reader.sendCommand("SEARCH SUBJECT thy")) + .contains("NO SEARCH processing failed"); + } + } + + @Test + @Disabled("Overrides not implemented yet for Postgresql") + void searchShouldSucceedOnSearchOverrides(GuiceJamesServer server) throws Exception { + server.getProbe(DataProbeImpl.class).fluent() + .addDomain(DOMAIN) + .addUser(JAMES_USER, PASSWORD) + .addUser(SENDER, PASSWORD); + + MailboxProbeImpl mailboxProbe = server.getProbe(MailboxProbeImpl.class); + mailboxProbe.createMailbox("#private", JAMES_USER, DefaultMailboxes.INBOX); + + try (TestIMAPClient reader = new TestIMAPClient()) { + int imapPort = imapPort(server); + reader.connect(JAMES_SERVER_HOST, imapPort) + .login(JAMES_USER, PASSWORD) + .select(TestIMAPClient.INBOX); + + assertThat(reader.sendCommand("SEARCH UNSEEN")) + .contains("OK SEARCH"); + } + } +} diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithTikaTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithTikaTest.java new file mode 100644 index 00000000000..12e5577d748 --- /dev/null +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithTikaTest.java @@ -0,0 +1,44 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresWithTikaTest implements JamesServerConcreteContract { + static PostgresExtension postgresExtension = PostgresExtension.empty(); + + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.openSearch()) + .usersRepository(DEFAULT) + .build()) + .server(PostgresJamesServerMain::createServer) + .extension(new DockerOpenSearchExtension()) + .extension(new TikaExtension()) + .extension(postgresExtension) + .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) + .build(); +} diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresQuotaSearchModule.java b/server/apps/postgres-app/src/test/java/org/apache/james/WithScanningSearchImmutableTest.java similarity index 55% rename from server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresQuotaSearchModule.java rename to server/apps/postgres-app/src/test/java/org/apache/james/WithScanningSearchImmutableTest.java index 5cd9108f933..51fbd2f023f 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresQuotaSearchModule.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/WithScanningSearchImmutableTest.java @@ -17,18 +17,26 @@ * under the License. * ****************************************************************/ -package org.apache.james.modules.mailbox; +package org.apache.james; -import org.apache.james.quota.search.QuotaSearcher; -import org.apache.james.quota.search.scanning.ScanningQuotaSearcher; +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; -import com.google.inject.AbstractModule; -import com.google.inject.Scopes; +import org.apache.james.backends.postgres.PostgresExtension; +import org.junit.jupiter.api.extension.RegisterExtension; -public class PostgresQuotaSearchModule extends AbstractModule { - @Override - protected void configure() { - bind(ScanningQuotaSearcher.class).in(Scopes.SINGLETON); - bind(QuotaSearcher.class).to(ScanningQuotaSearcher.class); - } +public class WithScanningSearchImmutableTest implements JamesServerConcreteContract { + static PostgresExtension postgresExtension = PostgresExtension.empty(); + + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.scanning()) + .usersRepository(DEFAULT) + .build()) + .server(PostgresJamesServerMain::createServer) + .extension(postgresExtension) + .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) + .build(); } diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/WithScanningSearchMutableTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/WithScanningSearchMutableTest.java new file mode 100644 index 00000000000..7f1e84a3cc0 --- /dev/null +++ b/server/apps/postgres-app/src/test/java/org/apache/james/WithScanningSearchMutableTest.java @@ -0,0 +1,42 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class WithScanningSearchMutableTest implements MailsShouldBeWellReceivedConcreteContract { + static PostgresExtension postgresExtension = PostgresExtension.empty(); + + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.scanning()) + .usersRepository(DEFAULT) + .build()) + .server(PostgresJamesServerMain::createServer) + .extension(postgresExtension) + .lifeCycle(JamesServerExtension.Lifecycle.PER_TEST) + .build(); +} diff --git a/server/container/guice/mailbox-postgres/pom.xml b/server/container/guice/mailbox-postgres/pom.xml index 6aa052c0414..3f345720af9 100644 --- a/server/container/guice/mailbox-postgres/pom.xml +++ b/server/container/guice/mailbox-postgres/pom.xml @@ -33,10 +33,6 @@ Apache James :: Server :: Postgres - Guice injection - - ${james.groupId} - apache-james-mailbox-lucene - ${james.groupId} apache-james-mailbox-postgres diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/LuceneSearchMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/LuceneSearchMailboxModule.java deleted file mode 100644 index 71a2bc741ec..00000000000 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/LuceneSearchMailboxModule.java +++ /dev/null @@ -1,58 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.modules.mailbox; - -import java.io.IOException; - -import org.apache.james.events.EventListener; -import org.apache.james.filesystem.api.FileSystem; -import org.apache.james.mailbox.lucene.search.LuceneMessageSearchIndex; -import org.apache.james.mailbox.store.search.ListeningMessageSearchIndex; -import org.apache.james.mailbox.store.search.MessageSearchIndex; -import org.apache.lucene.store.Directory; -import org.apache.lucene.store.FSDirectory; - -import com.google.inject.AbstractModule; -import com.google.inject.Provides; -import com.google.inject.Scopes; -import com.google.inject.Singleton; -import com.google.inject.multibindings.Multibinder; - -public class LuceneSearchMailboxModule extends AbstractModule { - - @Override - protected void configure() { - install(new ReIndexingTaskSerializationModule()); - - bind(LuceneMessageSearchIndex.class).in(Scopes.SINGLETON); - bind(MessageSearchIndex.class).to(LuceneMessageSearchIndex.class); - bind(ListeningMessageSearchIndex.class).to(LuceneMessageSearchIndex.class); - - Multibinder.newSetBinder(binder(), EventListener.ReactiveGroupEventListener.class) - .addBinding() - .to(LuceneMessageSearchIndex.class); - } - - @Provides - @Singleton - Directory provideDirectory(FileSystem fileSystem) throws IOException { - return FSDirectory.open(fileSystem.getBasedir()); - } -} diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java index 6e0be1b6172..c0fc44a29f9 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java @@ -85,7 +85,6 @@ protected void configure() { postgresDataDefinitions.addBinding().toInstance(PostgresMailboxAggregateModule.MODULE); install(new PostgresQuotaModule()); - install(new PostgresQuotaSearchModule()); bind(PostgresMailboxSessionMapperFactory.class).in(Scopes.SINGLETON); bind(PostgresMailboxManager.class).in(Scopes.SINGLETON); From e7d4109ed77fd3c8c856638eb04888e8bc66d007 Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Wed, 20 Dec 2023 11:10:09 +0700 Subject: [PATCH 144/341] JAMES-2586 Add docker compose distributed with OpenSearch for postgres app --- server/apps/postgres-app/README.adoc | 10 +- .../docker-compose-distributed.yml | 58 ++++++++++ server/apps/postgres-app/docker-compose.yml | 6 - .../opensearch.properties | 103 ++++++++++++++++++ 4 files changed, 170 insertions(+), 7 deletions(-) create mode 100644 server/apps/postgres-app/docker-compose-distributed.yml create mode 100644 server/apps/postgres-app/sample-configuration-distributed/opensearch.properties diff --git a/server/apps/postgres-app/README.adoc b/server/apps/postgres-app/README.adoc index f37fda4f837..12cb54d4eb5 100644 --- a/server/apps/postgres-app/README.adoc +++ b/server/apps/postgres-app/README.adoc @@ -142,4 +142,12 @@ docker-compose up -d mariadb # 4. Start James docker-compose up james -.... \ No newline at end of file +.... + +=== Docker compose distributed + +We also have a distributed version of the James postgresql app (with OpenSearch as a search indexer). To run it, simply type: + +.... +docker compose -f docker-compose-distributed.yml up -d +.... diff --git a/server/apps/postgres-app/docker-compose-distributed.yml b/server/apps/postgres-app/docker-compose-distributed.yml new file mode 100644 index 00000000000..aa05dab7d54 --- /dev/null +++ b/server/apps/postgres-app/docker-compose-distributed.yml @@ -0,0 +1,58 @@ +version: '3' + +services: + + james: + depends_on: + postgres: + condition: service_started + opensearch: + condition: service_healthy + image: apache/james:postgres-latest + container_name: james + hostname: james.local + command: + - --generate-keystore + ports: + - "80:80" + - "25:25" + - "110:110" + - "143:143" + - "465:465" + - "587:587" + - "993:993" + - "8000:8000" + volumes: + - ./sample-configuration-distributed/opensearch.properties:/root/conf/opensearch.properties + networks: + - james + + opensearch: + image: opensearchproject/opensearch:2.8.0 + container_name: opensearch + healthcheck: + test: curl -s http://opensearch:9200 >/dev/null || exit 1 + interval: 3s + timeout: 10s + retries: 5 + environment: + - discovery.type=single-node + - DISABLE_INSTALL_DEMO_CONFIG=true + - DISABLE_SECURITY_PLUGIN=true + networks: + - james + + postgres: + image: postgres:16.0 + container_name: postgres + ports: + - "5432:5432" + environment: + - POSTGRES_DB=james + - POSTGRES_USER=james + - POSTGRES_PASSWORD=secret1 + networks: + - james + +networks: + james: \ No newline at end of file diff --git a/server/apps/postgres-app/docker-compose.yml b/server/apps/postgres-app/docker-compose.yml index 377cde30d41..2bad9665e8a 100644 --- a/server/apps/postgres-app/docker-compose.yml +++ b/server/apps/postgres-app/docker-compose.yml @@ -1,11 +1,5 @@ version: '3' -# In order to start James Postgres app on top of mariaDB: -# 1. Download the driver: `wget https://jdbc.postgresql.org/download/postgresql-42.5.4.jar` -# 2. Generate the keystore with the default password `james72laBalle`: `keytool -genkey -alias james -keyalg RSA -keystore keystore` -# 3. Start Postgres: `docker-compose up -d postgres` -# 4. Start James: `docker-compose up james` - services: james: diff --git a/server/apps/postgres-app/sample-configuration-distributed/opensearch.properties b/server/apps/postgres-app/sample-configuration-distributed/opensearch.properties new file mode 100644 index 00000000000..7b48defef84 --- /dev/null +++ b/server/apps/postgres-app/sample-configuration-distributed/opensearch.properties @@ -0,0 +1,103 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# This template file can be used as example for James Server configuration +# DO NOT USE IT AS SUCH AND ADAPT IT TO YOUR NEEDS + +# Configuration file for OpenSearch +# Read https://james.apache.org/server/config-opensearch.html for further details + +opensearch.masterHost=opensearch +opensearch.port=9200 + +# Optional. Only http or https are accepted, default is http +# opensearch.hostScheme=http + +# Optional, default is `default` +# Choosing the SSL check strategy when using https scheme +# default: Use the default SSL TrustStore of the system. +# ignore: Ignore SSL Validation check (not recommended). +# override: Override the SSL Context to use a custom TrustStore containing ES server's certificate. +# opensearch.hostScheme.https.sslValidationStrategy=default + +# Optional. Required when using 'https' scheme and 'override' sslValidationStrategy +# Configure OpenSearch rest client to use this trustStore file to recognize nginx's ssl certificate. +# You need to specify both trustStorePath and trustStorePassword +# opensearch.hostScheme.https.trustStorePath=/file/to/trust/keystore.jks + +# Optional. Required when using 'https' scheme and 'override' sslValidationStrategy +# Configure OpenSearch rest client to use this trustStore file with the specified password. +# You need to specify both trustStorePath and trustStorePassword +# opensearch.hostScheme.https.trustStorePassword=myJKSPassword + +# Optional. default is `default` +# Configure OpenSearch rest client to use host name verifier during SSL handshake +# default: using the default hostname verifier provided by apache http client. +# accept_any_hostname: accept any host (not recommended). +# opensearch.hostScheme.https.hostNameVerifier=default + +# Optional. +# Basic auth username to access opensearch. +# Ignore opensearch.user and opensearch.password to not be using authentication (default behaviour). +# Otherwise, you need to specify both properties. +# opensearch.user=elasticsearch + +# Optional. +# Basic auth password to access opensearch. +# Ignore opensearch.user and opensearch.password to not be using authentication (default behaviour). +# Otherwise, you need to specify both properties. +# opensearch.password=secret + +# You can alternatively provide a list of hosts following this format : +# opensearch.hosts=host1:9200,host2:9200 +# opensearch.clusterName=cluster + +opensearch.nb.shards=5 +opensearch.nb.replica=1 +opensearch.index.waitForActiveShards=1 +opensearch.retryConnection.maxRetries=7 +opensearch.retryConnection.minDelay=3000 +# Index or not attachments (default value: true) +# Note: Attachments not implemented yet for postgresql, false for now +opensearch.indexAttachments=false + +# Search overrides allow resolution of predefined search queries against alternative sources of data +# and allow bypassing opensearch. This is useful to handle most resynchronisation queries that +# are simple enough to be resolved against Cassandra. +# +# Possible values are: +# - `org.apache.james.mailbox.cassandra.search.AllSearchOverride` Some IMAP clients uses SEARCH ALL to fully list messages in +# a mailbox and detect deletions. This is typically done by clients not supporting QRESYNC and from an IMAP perspective +# is considered an optimisation as less data is transmitted compared to a FETCH command. Resolving such requests against +# Cassandra is enabled by this search override and likely desirable. +# - `org.apache.james.mailbox.cassandra.search.UidSearchOverride`. Same as above but restricted by ranges. +# - `org.apache.james.mailbox.cassandra.search.DeletedSearchOverride`. Find deleted messages by looking up in the relevant Cassandra +# table. +# - `org.apache.james.mailbox.cassandra.search.DeletedWithRangeSearchOverride`. Same as above but limited by ranges. +# - `org.apache.james.mailbox.cassandra.search.NotDeletedWithRangeSearchOverride`. List non deleted messages in a given range. +# Lists all messages and filters out deleted message thus this is based on the following heuristic: most messages are not marked as deleted. +# - `org.apache.james.mailbox.cassandra.search.UnseenSearchOverride`. List unseen messages in the corresponding cassandra projection. +# +# Please note that custom overrides can be defined here. +# +# Note: Search overrides not implemented yet for postgresql. +# +# opensearch.search.overrides=org.apache.james.mailbox.cassandra.search.AllSearchOverride,org.apache.james.mailbox.cassandra.search.DeletedSearchOverride, org.apache.james.mailbox.cassandra.search.DeletedWithRangeSearchOverride,org.apache.james.mailbox.cassandra.search.NotDeletedWithRangeSearchOverride,org.apache.james.mailbox.cassandra.search.UidSearchOverride,org.apache.james.mailbox.cassandra.search.UnseenSearchOverride + +# Optional. Default is `false` +# When set to true, James will attempt to reindex from the indexed message when moved. If the message is not found, it will fall back to the old behavior (The message will be indexed from the blobStore source) +# opensearch.message.index.optimize.move=false \ No newline at end of file From 6578230bee7e726f2e20283ecf8d6c0b88ad52aa Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Wed, 20 Dec 2023 11:42:52 +0700 Subject: [PATCH 145/341] JAMES-2586 Rework README for Postgres-app and rework the docker compose with only Postgresql after adding search module chooser --- server/apps/postgres-app/README.adoc | 135 ++++++------------ server/apps/postgres-app/docker-compose.yml | 2 + .../search.properties | 2 + 3 files changed, 48 insertions(+), 91 deletions(-) create mode 100644 server/apps/postgres-app/sample-configuration-single/search.properties diff --git a/server/apps/postgres-app/README.adoc b/server/apps/postgres-app/README.adoc index 12cb54d4eb5..d85f7f876ee 100644 --- a/server/apps/postgres-app/README.adoc +++ b/server/apps/postgres-app/README.adoc @@ -1,150 +1,103 @@ = Guice-Postgres Server How-to -// TODO: rewrite this doc by using Postgres instead of JPA -This server target single node James deployments. By default, the derby database is used. +This server targets reactive James deployments with postgresql database. == Requirements * Java 11 SDK -== Running +=== With Postgresql only -To run james, you have to create a directory containing required configuration files. +Firstly, create your own user network on Docker for the James environment: -James requires the configuration to be in a subfolder of working directory that is called -**conf**. A [sample directory](https://github.com/apache/james-project/tree/master/server/container/guice/jpa-guice/sample-configuration) -is provided with some default values you may need to replace. You will need to update its content to match your needs. - -You also need to generate a keystore with the following command: + $ docker network create --driver bridge james -[source] ----- -$ keytool -genkey -alias james -keyalg RSA -keystore conf/keystore ----- +Third party compulsory dependencies: -Once everything is set up, you just have to run the jar with: +* Postgresql 16.0 [source] ---- -$ java -javaagent:james-server-postgres-app.lib/openjpa-3.1.2.jar \ - -Dworking.directory=. \ - -Djdk.tls.ephemeralDHKeySize=2048 \ - -Dlogback.configurationFile=conf/logback.xml \ - -jar james-server-postgres-app.jar +$ docker run -d --network james -p 5432:5432 --name=postgres --env 'POSTGRES_DB=james' --env 'POSTGRES_USER=james' --env 'POSTGRES_PASSWORD=secret1' postgres:16.0 ---- -Note that binding ports below 1024 requires administrative rights. +=== Distributed version -== Docker distribution +Here you have the choice of using other third party softwares to handle object data storage, search indexing and event bus. -To import the image locally: +For now, dependencies supported are: -[source] ----- -docker image load -i target/jib-image.tar ----- - -Then run it: +* OpenSearch 2.8.0 [source] ---- -docker run apache/james:jpa-latest +$ docker run -d --network james -p 9200:9200 --name=opensearch --env 'discovery.type=single-node' opensearchproject/opensearch:2.8.0 ---- -Use the [JAVA_TOOL_OPTIONS environment option](https://github.com/GoogleContainerTools/jib/blob/master/docs/faq.md#jvm-flags) -to pass extra JVM flags. For instance: +== Running manually -[source] ----- -docker run -e "JAVA_TOOL_OPTIONS=-Xmx500m -Xms500m" apache/james:jpa-latest ----- - -For security reasons you are required to generate your own keystore, that you can mount into the container via a volume: - -[source] ----- -keytool -genkey -alias james -keyalg RSA -keystore keystore -docker run -v $PWD/keystore:/root/conf/keystore apache/james:jpa-latest ----- +=== Running with Postgresql only -In the case of quick start James without manually creating a keystore (e.g. for development), just input the command argument `--generate-keystore` when running, -James will auto-generate keystore file with the default setting that is declared in `jmap.properties` (tls.keystoreURL, tls.secret) +To run James manually, you have to create a directory containing required configuration files. -[source] ----- -docker run --network james apache/james:jpa-latest --generate-keystore ----- +James requires the configuration to be in a subfolder of working directory that is called +**conf**. A [sample directory](https://github.com/apache/james-project/tree/master/server/apps/postgres-app/sample-configuration) +is provided with some default values you may need to replace. You will need to update its content to match your needs. -[Glowroot APM](https://glowroot.org/) is packaged as part of the docker distribution to easily enable valuable performances insights. -Disabled by default, its java agent can easily be enabled: +Also you might need to add the files like in the +[sample directory](https://github.com/apache/james-project/tree/master/server/apps/postgres-app/sample-configuration-single) +to not have OpenSearch indexing enabled by default for the search. +You also need to generate a keystore with the following command: [source] ---- -docker run -e "JAVA_TOOL_OPTIONS=-javaagent:/root/glowroot.jar" apache/james:jpa-latest +$ keytool -genkey -alias james -keyalg RSA -keystore conf/keystore ---- -The [CLI](https://james.apache.org/server/manage-cli.html) can easily be used: - +Once everything is set up, you just have to run the jar with: [source] ---- -docker exec CONTAINER-ID james-cli ListDomains +$ java -Dworking.directory=. -Djdk.tls.ephemeralDHKeySize=2048 -Dlogback.configurationFile=conf/logback.xml -jar james-server-postgres-app.jar ---- -Note that you can create a domain via an environment variable. This domain will be created upon James start: +In the case of quick start James without manually creating a keystore (e.g. for development), just input the command argument +`--generate-keystore` when running, James will auto-generate keystore file with the default setting that is declared in +`jmap.properties` (tls.keystoreURL, tls.secret). [source] ---- ---environment DOMAIN=domain.tld +$ java -Dworking.directory=. -Dlogback.configurationFile=conf/logback.xml -Djdk.tls.ephemeralDHKeySize=2048 -jar james-server-postgres-app.jar --generate-keystore ---- +Note that binding ports below 1024 requires administrative rights. -=== Using alternative JDBC drivers - -==== Using alternative JDBC drivers with the ZIP package - -We will need to add the driver JAR on the classpath. +=== Running distributed -This can be done with the following command: +If you want to use the distributed version of James Postgres app, you will need to add configuration in the **conf** folder like in the +[sample directory](https://github.com/apache/james-project/tree/master/server/apps/postgres-app/sample-configuration-distributed). -.... -java \ - -javaagent:james-server-postgres-app.lib/openjpa-3.2.0.jar \ - -Dworking.directory=. \ - -Djdk.tls.ephemeralDHKeySize=2048 \ - -Dlogback.configurationFile=conf/logback.xml \ - -cp "james-server-postgres-app.jar:james-server-postgres-app.lib/*:jdbc-driver.jar" \ - org.apache.james.JPAJamesServerMain -.... +Then you need to generate the keystore, rebuild the application jar and run it like above. -With `jdbc-driver.jar` being the JAR file of your driver, placed in the current directory. +== Docker compose -==== Using alternative JDBC drivers with docker +To import the image locally: -In `james-database.properties`, one can specify any JDBC driver on the class path. +[source] +---- +docker image load -i target/jib-image.tar +---- -With docker, such drivers can be added to the classpath by placing the driver JAR in a volume -and mounting it within `/root/libs` directory. +=== With Postgresql only -We do ship a [docker-compose](https://github.com/apache/james-project/blob/master/server/apps/jpa-smtp-app/docker-compose.yml) -file demonstrating James JPA app usage with MariaDB. In order to run it: +We have a docker compose for running James Postgresql app alongside Postgresql. To run it, simply type: .... -# 1. Download the driver: -wget https://repo1.maven.org/maven2/org/mariadb/jdbc/mariadb-java-client/2.7.2/mariadb-java-client-2.7.2.jar - -# 2. Generate the keystore with the default password `james72laBalle`: -keytool -genkey -alias james -keyalg RSA -keystore keystore - -# 3. Start MariaDB -docker-compose up -d mariadb - -# 4. Start James -docker-compose up james +docker compose up -d .... -=== Docker compose distributed +=== Distributed We also have a distributed version of the James postgresql app (with OpenSearch as a search indexer). To run it, simply type: diff --git a/server/apps/postgres-app/docker-compose.yml b/server/apps/postgres-app/docker-compose.yml index 2bad9665e8a..c8d5f8f995b 100644 --- a/server/apps/postgres-app/docker-compose.yml +++ b/server/apps/postgres-app/docker-compose.yml @@ -19,6 +19,8 @@ services: - "587:587" - "993:993" - "8000:8000" + volumes: + - ./sample-configuration-single/search.properties:/root/conf/search.properties postgres: image: postgres:16.0 diff --git a/server/apps/postgres-app/sample-configuration-single/search.properties b/server/apps/postgres-app/sample-configuration-single/search.properties new file mode 100644 index 00000000000..51833746a92 --- /dev/null +++ b/server/apps/postgres-app/sample-configuration-single/search.properties @@ -0,0 +1,2 @@ +# not for production purposes. To be replaced by PG based search. +implementation=scanning \ No newline at end of file From 40e7645d265ca989ee1ea2e7fcb15e2f2b92167a Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 20 Dec 2023 14:26:00 +0700 Subject: [PATCH 146/341] JAMES-2586 Module chooser: S3, file blobStore --- server/apps/postgres-app/README.adoc | 11 +- .../docker-compose-distributed.yml | 14 +++ server/apps/postgres-app/pom.xml | 28 +++++ .../blob.properties | 104 ++++++++++++++++ .../james/PostgresJamesConfiguration.java | 32 ++++- .../apache/james/PostgresJamesServerMain.java | 24 ++++ .../DistributedPostgresJamesServerTest.java | 111 ++++++++++++++++++ .../james/JamesCapabilitiesServerTest.java | 3 +- .../apache/james/PostgresJamesServerTest.java | 3 +- .../BlobStoreCacheModulesChooser.java | 2 +- .../blobstore/BlobStoreConfiguration.java | 10 +- .../mailbox/PostgresMailboxModule.java | 2 - .../container/guice/postgres-common/pom.xml | 4 + 13 files changed, 333 insertions(+), 15 deletions(-) create mode 100644 server/apps/postgres-app/sample-configuration-distributed/blob.properties create mode 100644 server/apps/postgres-app/src/test/java/org/apache/james/DistributedPostgresJamesServerTest.java diff --git a/server/apps/postgres-app/README.adoc b/server/apps/postgres-app/README.adoc index d85f7f876ee..4d1c90fa343 100644 --- a/server/apps/postgres-app/README.adoc +++ b/server/apps/postgres-app/README.adoc @@ -4,7 +4,7 @@ This server targets reactive James deployments with postgresql database. == Requirements - * Java 11 SDK +* Java 11 SDK === With Postgresql only @@ -34,6 +34,13 @@ For now, dependencies supported are: $ docker run -d --network james -p 9200:9200 --name=opensearch --env 'discovery.type=single-node' opensearchproject/opensearch:2.8.0 ---- +* Zenko Cloudserver or AWS S3 + +[source] +---- +$ docker run -d --network james --env 'REMOTE_MANAGEMENT_DISABLE=1' --env 'SCALITY_ACCESS_KEY_ID=accessKey1' --env 'SCALITY_SECRET_ACCESS_KEY=secretKey1' --name=s3 registry.scality.com/cloudserver/cloudserver:8.7.25 +---- + == Running manually === Running with Postgresql only @@ -99,7 +106,7 @@ docker compose up -d === Distributed -We also have a distributed version of the James postgresql app (with OpenSearch as a search indexer). To run it, simply type: +We also have a distributed version of the James postgresql app (with OpenSearch as a search indexer and S3 as the object storage). To run it, simply type: .... docker compose -f docker-compose-distributed.yml up -d diff --git a/server/apps/postgres-app/docker-compose-distributed.yml b/server/apps/postgres-app/docker-compose-distributed.yml index aa05dab7d54..de6a3d3f630 100644 --- a/server/apps/postgres-app/docker-compose-distributed.yml +++ b/server/apps/postgres-app/docker-compose-distributed.yml @@ -8,6 +8,8 @@ services: condition: service_started opensearch: condition: service_healthy + s3: + condition: service_started image: apache/james:postgres-latest container_name: james hostname: james.local @@ -24,6 +26,7 @@ services: - "8000:8000" volumes: - ./sample-configuration-distributed/opensearch.properties:/root/conf/opensearch.properties + - ./sample-configuration-distributed/blob.properties:/root/conf/blob.properties networks: - james @@ -54,5 +57,16 @@ services: networks: - james + s3: + image: registry.scality.com/cloudserver/cloudserver:8.7.25 + container_name: s3.docker.test + environment: + - SCALITY_ACCESS_KEY_ID=accessKey1 + - SCALITY_SECRET_ACCESS_KEY=secretKey1 + - LOG_LEVEL=trace + - REMOTE_MANAGEMENT_DISABLE=1 + networks: + - james + networks: james: \ No newline at end of file diff --git a/server/apps/postgres-app/pom.xml b/server/apps/postgres-app/pom.xml index ab53a56738f..3e0dab7b67a 100644 --- a/server/apps/postgres-app/pom.xml +++ b/server/apps/postgres-app/pom.xml @@ -78,6 +78,18 @@ test-jar test + + ${james.groupId} + blob-s3 + test-jar + test + + + ${james.groupId} + blob-s3-guice + test-jar + test + ${james.groupId} james-server-cassandra-app @@ -99,6 +111,18 @@ ${james.groupId} james-server-data-postgres + + ${james.groupId} + james-server-distributed-app + test-jar + test + + + ${james.groupId} + james-server-guice-distributed + + + ${james.groupId} james-server-guice-common @@ -143,6 +167,10 @@ ${james.groupId} james-server-guice-managedsieve + + ${james.groupId} + james-server-guice-memory + ${james.groupId} james-server-guice-opensearch diff --git a/server/apps/postgres-app/sample-configuration-distributed/blob.properties b/server/apps/postgres-app/sample-configuration-distributed/blob.properties new file mode 100644 index 00000000000..0e761637054 --- /dev/null +++ b/server/apps/postgres-app/sample-configuration-distributed/blob.properties @@ -0,0 +1,104 @@ +# ============================================= BlobStore Implementation ================================== +# Read https://james.apache.org/server/config-blobstore.html for further details + +# Choose your BlobStore implementation +# Mandatory, allowed values are: file, s3, postgres. +implementation=s3 + +# ========================================= Deduplication ======================================== +# If you choose to enable deduplication, the mails with the same content will be stored only once. +# Warning: Once this feature is enabled, there is no turning back as turning it off will lead to the deletion of all +# the mails sharing the same content once one is deleted. +# Mandatory, Allowed values are: true, false +deduplication.enable=true + +# deduplication.family needs to be incremented every time the deduplication.generation.duration is changed +# Positive integer, defaults to 1 +# deduplication.gc.generation.family=1 + +# Duration of generation. +# Deduplication only takes place within a singe generation. +# Only items two generation old can be garbage collected. (This prevent concurrent insertions issues and +# accounts for a clock skew). +# deduplication.family needs to be incremented everytime this parameter is changed. +# Duration. Default unit: days. Defaults to 30 days. +# deduplication.gc.generation.duration=30days + +# ========================================= Encryption ======================================== +# If you choose to enable encryption, the blob content will be encrypted before storing them in the BlobStore. +# Warning: Once this feature is enabled, there is no turning back as turning it off will lead to all content being +# encrypted. This comes at a performance impact but presents you from leaking data if, for instance the third party +# offering you a S3 service is compromised. +# Optional, Allowed values are: true, false, defaults to false +encryption.aes.enable=false + +# Mandatory (if AES encryption is enabled) salt and password. Salt needs to be an hexadecimal encoded string +#encryption.aes.password=xxx +#encryption.aes.salt=73616c7479 +# Optional, defaults to PBKDF2WithHmacSHA512 +#encryption.aes.private.key.algorithm=PBKDF2WithHmacSHA512 + +# ============================================== ObjectStorage ============================================ + +# ========================================= ObjectStorage Buckets ========================================== +# bucket names prefix +# Optional, default no prefix +# objectstorage.bucketPrefix=prod- + +# Default bucket name +# Optional, default is bucketPrefix + `default` +# objectstorage.namespace=james + +# ========================================= ObjectStorage on S3 ============================================= +# Mandatory if you choose s3 storage service, S3 authentication endpoint +objectstorage.s3.endPoint=http://s3.docker.test:8000/ + +# Mandatory if you choose s3 storage service, S3 region +#objectstorage.s3.region=eu-west-1 +objectstorage.s3.region=us-east-1 + +# Mandatory if you choose aws-s3 storage service, access key id configured in S3 +objectstorage.s3.accessKeyId=accessKey1 + +# Mandatory if you choose s3 storage service, secret key configured in S3 +objectstorage.s3.secretKey=secretKey1 + +# Optional if you choose s3 storage service: The trust store file, secret, and algorithm to use +# when connecting to the storage service. If not specified falls back to Java defaults. +#objectstorage.s3.truststore.path= +#objectstorage.s3.truststore.type=JKS +#objectstorage.s3.truststore.secret= +#objectstorage.s3.truststore.algorithm=SunX509 + + +# optional: Object read in memory will be rejected if they exceed the size limit exposed here. Size, exemple `100M`. +# Supported units: K, M, G, defaults to B if no unit is specified. If unspecified, big object won't be prevented +# from being loaded in memory. This settings complements protocol limits. +# objectstorage.s3.in.read.limit=50M + +# ============================================ Blobs Exporting ============================================== +# Read https://james.apache.org/server/config-blob-export.html for further details + +# Choosing blob exporting mechanism, allowed mechanism are: localFile, linshare +# LinShare is a file sharing service, will be explained in the below section +# Optional, default is localFile +blob.export.implementation=localFile + +# ======================================= Local File Blobs Exporting ======================================== +# Optional, directory to store exported blob, directory path follows James file system format +# default is file://var/blobExporting +blob.export.localFile.directory=file://var/blobExporting + +# ======================================= LinShare File Blobs Exporting ======================================== +# LinShare is a sharing service where you can use james, connects to an existing LinShare server and shares files to +# other mail addresses as long as those addresses available in LinShare. For example you can deploy James and LinShare +# sharing the same LDAP repository +# Mandatory if you choose LinShare, url to connect to LinShare service +# blob.export.linshare.url=http://linshare:8080 + +# ======================================= LinShare Configuration BasicAuthentication =================================== +# Authentication is mandatory if you choose LinShare, TechnicalAccount is need to connect to LinShare specific service. +# For Example: It will be formalized to 'Authorization: Basic {Credential of UUID/password}' + +# blob.export.linshare.technical.account.uuid=Technical_Account_UUID +# blob.export.linshare.technical.account.password=password diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java index 54554aa3fe1..2c2b41c3aff 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java @@ -25,6 +25,7 @@ import org.apache.james.data.UsersRepositoryModuleChooser; import org.apache.james.filesystem.api.FileSystem; import org.apache.james.filesystem.api.JamesDirectoriesProvider; +import org.apache.james.modules.blobstore.BlobStoreConfiguration; import org.apache.james.server.core.JamesServerResourceLoader; import org.apache.james.server.core.MissingArgumentException; import org.apache.james.server.core.configuration.Configuration; @@ -33,19 +34,24 @@ import org.apache.james.utils.PropertiesProvider; import com.github.fge.lambdas.Throwing; +import com.google.common.base.Preconditions; public class PostgresJamesConfiguration implements Configuration { + private static BlobStoreConfiguration.BlobStoreImplName DEFAULT_BLOB_STORE = BlobStoreConfiguration.BlobStoreImplName.FILE; + public static class Builder { private Optional rootDirectory; private Optional configurationPath; private Optional usersRepositoryImplementation; private Optional searchConfiguration; + private Optional blobStoreConfiguration; private Builder() { searchConfiguration = Optional.empty(); rootDirectory = Optional.empty(); configurationPath = Optional.empty(); usersRepositoryImplementation = Optional.empty(); + blobStoreConfiguration = Optional.empty(); } public Builder workingDirectory(String path) { @@ -86,6 +92,11 @@ public Builder searchConfiguration(SearchConfiguration searchConfiguration) { return this; } + public Builder blobStore(BlobStoreConfiguration blobStoreConfiguration) { + this.blobStoreConfiguration = Optional.of(blobStoreConfiguration); + return this; + } + public PostgresJamesConfiguration build() { ConfigurationPath configurationPath = this.configurationPath.orElse(new ConfigurationPath(FileSystem.FILE_PROTOCOL_AND_CONF)); JamesServerResourceLoader directories = new JamesServerResourceLoader(rootDirectory @@ -97,6 +108,11 @@ public PostgresJamesConfiguration build() { SearchConfiguration searchConfiguration = this.searchConfiguration.orElseGet(Throwing.supplier( () -> SearchConfiguration.parse(propertiesProvider))); + BlobStoreConfiguration blobStoreConfiguration = this.blobStoreConfiguration.orElseGet(Throwing.supplier( + () -> BlobStoreConfiguration.parse(propertiesProvider, DEFAULT_BLOB_STORE))); + Preconditions.checkState(!blobStoreConfiguration.getImplementation().equals(BlobStoreConfiguration.BlobStoreImplName.CASSANDRA), "Cassandra BlobStore is not supported by postgres-app."); + Preconditions.checkState(!blobStoreConfiguration.cacheEnabled(), "BlobStore caching is not supported by postgres-app."); + FileConfigurationProvider configurationProvider = new FileConfigurationProvider(fileSystem, Basic.builder() .configurationPath(configurationPath) .workingDirectory(directories.getRootDirectory()) @@ -108,7 +124,8 @@ public PostgresJamesConfiguration build() { configurationPath, directories, searchConfiguration, - usersRepositoryChoice); + usersRepositoryChoice, + blobStoreConfiguration); } } @@ -120,13 +137,18 @@ public static Builder builder() { private final JamesDirectoriesProvider directories; private final SearchConfiguration searchConfiguration; private final UsersRepositoryModuleChooser.Implementation usersRepositoryImplementation; + private final BlobStoreConfiguration blobStoreConfiguration; - public PostgresJamesConfiguration(ConfigurationPath configurationPath, JamesDirectoriesProvider directories, - SearchConfiguration searchConfiguration, UsersRepositoryModuleChooser.Implementation usersRepositoryImplementation) { + private PostgresJamesConfiguration(ConfigurationPath configurationPath, + JamesDirectoriesProvider directories, + SearchConfiguration searchConfiguration, + UsersRepositoryModuleChooser.Implementation usersRepositoryImplementation, + BlobStoreConfiguration blobStoreConfiguration) { this.configurationPath = configurationPath; this.directories = directories; this.searchConfiguration = searchConfiguration; this.usersRepositoryImplementation = usersRepositoryImplementation; + this.blobStoreConfiguration = blobStoreConfiguration; } @Override @@ -146,4 +168,8 @@ public SearchConfiguration searchConfiguration() { public UsersRepositoryModuleChooser.Implementation getUsersRepositoryImplementation() { return usersRepositoryImplementation; } + + public BlobStoreConfiguration blobStoreConfiguration() { + return blobStoreConfiguration; + } } diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index d346debd37c..d65badf9924 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -19,14 +19,20 @@ package org.apache.james; +import java.util.List; + +import org.apache.james.blob.api.BlobReferenceSource; import org.apache.james.data.UsersRepositoryModuleChooser; import org.apache.james.modules.MailboxModule; import org.apache.james.modules.MailetProcessingModule; import org.apache.james.modules.RunArgumentsModule; +import org.apache.james.modules.blobstore.BlobStoreCacheModulesChooser; +import org.apache.james.modules.blobstore.BlobStoreModulesChooser; import org.apache.james.modules.data.PostgresDataModule; import org.apache.james.modules.data.PostgresDelegationStoreModule; import org.apache.james.modules.data.PostgresUsersRepositoryModule; import org.apache.james.modules.data.SievePostgresRepositoryModules; +import org.apache.james.modules.eventstore.MemoryEventStoreModule; import org.apache.james.modules.mailbox.DefaultEventModule; import org.apache.james.modules.mailbox.MemoryDeadLetterModule; import org.apache.james.modules.mailbox.PostgresMailboxModule; @@ -52,8 +58,11 @@ import org.apache.james.modules.server.TaskManagerModule; import org.apache.james.modules.server.WebAdminReIndexingTaskSerializationModule; import org.apache.james.modules.server.WebAdminServerModule; +import org.apache.james.server.blob.deduplication.StorageStrategy; +import com.google.common.collect.ImmutableList; import com.google.inject.Module; +import com.google.inject.multibindings.Multibinder; import com.google.inject.util.Modules; public class PostgresJamesServerMain implements JamesServerMain { @@ -91,6 +100,7 @@ public class PostgresJamesServerMain implements JamesServerMain { new DefaultEventModule(), new TaskManagerModule(), new MemoryDeadLetterModule(), + new MemoryEventStoreModule(), new TikaMailboxModule()); private static final Module POSTGRES_MODULE_AGGREGATE = Modules.combine( @@ -118,6 +128,20 @@ static GuiceJamesServer createServer(PostgresJamesConfiguration configuration) { .combineWith(SearchModuleChooser.chooseModules(searchConfiguration)) .combineWith(new UsersRepositoryModuleChooser(new PostgresUsersRepositoryModule()) .chooseModules(configuration.getUsersRepositoryImplementation())) + .combineWith(chooseBlobStoreModules(configuration)) .combineWith(POSTGRES_MODULE_AGGREGATE); } + + private static List chooseBlobStoreModules(PostgresJamesConfiguration configuration) { + ImmutableList.Builder builder = ImmutableList.builder() + .addAll(BlobStoreModulesChooser.chooseModules(configuration.blobStoreConfiguration())) + .add(new BlobStoreCacheModulesChooser.CacheDisabledModule()); + + // should remove this after https://github.com/linagora/james-project/issues/4998 + if (configuration.blobStoreConfiguration().storageStrategy().equals(StorageStrategy.DEDUPLICATION)) { + builder.add(binder -> Multibinder.newSetBinder(binder, BlobReferenceSource.class)); + } + + return builder.build(); + } } diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/DistributedPostgresJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/DistributedPostgresJamesServerTest.java new file mode 100644 index 00000000000..ce037588d09 --- /dev/null +++ b/server/apps/postgres-app/src/test/java/org/apache/james/DistributedPostgresJamesServerTest.java @@ -0,0 +1,111 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Durations.FIVE_HUNDRED_MILLISECONDS; +import static org.awaitility.Durations.ONE_MINUTE; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.core.quota.QuotaSizeLimit; +import org.apache.james.modules.AwsS3BlobStoreExtension; +import org.apache.james.modules.QuotaProbesImpl; +import org.apache.james.modules.blobstore.BlobStoreConfiguration; +import org.apache.james.modules.protocols.ImapGuiceProbe; +import org.apache.james.modules.protocols.SmtpGuiceProbe; +import org.apache.james.utils.DataProbeImpl; +import org.apache.james.utils.SMTPMessageSender; +import org.apache.james.utils.TestIMAPClient; +import org.awaitility.Awaitility; +import org.awaitility.core.ConditionFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.google.common.base.Strings; + +class DistributedPostgresJamesServerTest implements JamesServerConcreteContract { + static PostgresExtension postgresExtension = PostgresExtension.empty(); + + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .usersRepository(DEFAULT) + .blobStore(BlobStoreConfiguration.builder() + .s3() + .disableCache() + .deduplication() + .noCryptoConfig()) + .searchConfiguration(SearchConfiguration.openSearch()) + .build()) + .server(PostgresJamesServerMain::createServer) + .extension(postgresExtension) + .extension(new AwsS3BlobStoreExtension()) + .extension(new DockerOpenSearchExtension()) + .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) + .build(); + + private static final ConditionFactory AWAIT = Awaitility.await() + .atMost(ONE_MINUTE) + .with() + .pollInterval(FIVE_HUNDRED_MILLISECONDS); + static final String DOMAIN = "james.local"; + private static final String USER = "toto@" + DOMAIN; + private static final String PASSWORD = "123456"; + + private TestIMAPClient testIMAPClient; + private SMTPMessageSender smtpMessageSender; + + @BeforeEach + void setUp() { + this.testIMAPClient = new TestIMAPClient(); + this.smtpMessageSender = new SMTPMessageSender(DOMAIN); + } + + @Test + void guiceServerShouldUpdateQuota(GuiceJamesServer jamesServer) throws Exception { + jamesServer.getProbe(DataProbeImpl.class) + .fluent() + .addDomain(DOMAIN) + .addUser(USER, PASSWORD); + jamesServer.getProbe(QuotaProbesImpl.class).setGlobalMaxStorage(QuotaSizeLimit.size(50 * 1024)); + + // ~ 12 KB email + int imapPort = jamesServer.getProbe(ImapGuiceProbe.class).getImapPort(); + smtpMessageSender.connect(JAMES_SERVER_HOST, jamesServer.getProbe(SmtpGuiceProbe.class).getSmtpPort()) + .authenticate(USER, PASSWORD) + .sendMessageWithHeaders(USER, USER, "header: toto\\r\\n\\r\\n" + Strings.repeat("0123456789\n", 1024)); + AWAIT.until(() -> testIMAPClient.connect(JAMES_SERVER_HOST, imapPort) + .login(USER, PASSWORD) + .select(TestIMAPClient.INBOX) + .hasAMessage()); + + assertThat( + testIMAPClient.connect(JAMES_SERVER_HOST, imapPort) + .login(USER, PASSWORD) + .getQuotaRoot(TestIMAPClient.INBOX)) + .startsWith("* QUOTAROOT \"INBOX\" #private&toto@james.local\r\n" + + "* QUOTA #private&toto@james.local (STORAGE 12 50)\r\n") + .endsWith("OK GETQUOTAROOT completed.\r\n"); + } +} diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java index b40620638a6..7ac41df803e 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java @@ -48,12 +48,11 @@ private static MailboxManager mailboxManager() { PostgresJamesConfiguration.builder() .workingDirectory(tmpDir) .configurationFromClasspath() - .searchConfiguration(SearchConfiguration.openSearch()) + .searchConfiguration(SearchConfiguration.scanning()) .usersRepository(DEFAULT) .build()) .server(configuration -> PostgresJamesServerMain.createServer(configuration) .overrideWith(binder -> binder.bind(MailboxManager.class).toInstance(mailboxManager()))) - .extension(new DockerOpenSearchExtension()) .extension(postgresExtension) .build(); diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java index cdadef140b5..b2466066569 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java @@ -48,11 +48,10 @@ class PostgresJamesServerTest implements JamesServerConcreteContract { PostgresJamesConfiguration.builder() .workingDirectory(tmpDir) .configurationFromClasspath() - .searchConfiguration(SearchConfiguration.openSearch()) + .searchConfiguration(SearchConfiguration.scanning()) .usersRepository(DEFAULT) .build()) .server(PostgresJamesServerMain::createServer) - .extension(new DockerOpenSearchExtension()) .extension(postgresExtension) .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) .build(); diff --git a/server/container/guice/distributed/src/main/java/org/apache/james/modules/blobstore/BlobStoreCacheModulesChooser.java b/server/container/guice/distributed/src/main/java/org/apache/james/modules/blobstore/BlobStoreCacheModulesChooser.java index 8789aac7ee8..5d6d9736695 100644 --- a/server/container/guice/distributed/src/main/java/org/apache/james/modules/blobstore/BlobStoreCacheModulesChooser.java +++ b/server/container/guice/distributed/src/main/java/org/apache/james/modules/blobstore/BlobStoreCacheModulesChooser.java @@ -53,7 +53,7 @@ public class BlobStoreCacheModulesChooser { private static final Logger LOGGER = LoggerFactory.getLogger(BlobStoreCacheModulesChooser.class); - static class CacheDisabledModule extends AbstractModule { + public static class CacheDisabledModule extends AbstractModule { @Provides @Named(MetricableBlobStore.BLOB_STORE_IMPLEMENTATION) @Singleton diff --git a/server/container/guice/distributed/src/main/java/org/apache/james/modules/blobstore/BlobStoreConfiguration.java b/server/container/guice/distributed/src/main/java/org/apache/james/modules/blobstore/BlobStoreConfiguration.java index 5b344994e6b..09ec3a7e6e3 100644 --- a/server/container/guice/distributed/src/main/java/org/apache/james/modules/blobstore/BlobStoreConfiguration.java +++ b/server/container/guice/distributed/src/main/java/org/apache/james/modules/blobstore/BlobStoreConfiguration.java @@ -151,13 +151,17 @@ public static BlobStoreConfiguration parse(org.apache.james.server.core.configur } public static BlobStoreConfiguration parse(PropertiesProvider propertiesProvider) throws ConfigurationException { + return parse(propertiesProvider, BlobStoreImplName.CASSANDRA); + } + + public static BlobStoreConfiguration parse(PropertiesProvider propertiesProvider, BlobStoreImplName defaultBlobStore) throws ConfigurationException { try { Configuration configuration = propertiesProvider.getConfigurations(ConfigurationComponent.NAMES); return BlobStoreConfiguration.from(configuration); } catch (FileNotFoundException e) { - LOGGER.warn("Could not find " + ConfigurationComponent.NAME + " configuration file, using cassandra blobstore as the default"); + LOGGER.warn("Could not find " + ConfigurationComponent.NAME + " configuration file, using " + defaultBlobStore.getName() + " blobstore as the default"); return BlobStoreConfiguration.builder() - .cassandra() + .implementation(defaultBlobStore) .disableCache() .passthrough() .noCryptoConfig(); @@ -238,7 +242,7 @@ public StorageStrategy storageStrategy() { return storageStrategy; } - BlobStoreImplName getImplementation() { + public BlobStoreImplName getImplementation() { return implementation; } diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java index c0fc44a29f9..bd4609e01a2 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java @@ -61,7 +61,6 @@ import org.apache.james.mailbox.store.mail.NaiveThreadIdGuessingAlgorithm; import org.apache.james.mailbox.store.mail.ThreadIdGuessingAlgorithm; import org.apache.james.mailbox.store.user.SubscriptionMapperFactory; -import org.apache.james.modules.BlobMemoryModule; import org.apache.james.modules.data.PostgresCommonModule; import org.apache.james.user.api.DeleteUserDataTaskStep; import org.apache.james.user.api.UsernameChangeTaskStep; @@ -79,7 +78,6 @@ public class PostgresMailboxModule extends AbstractModule { @Override protected void configure() { install(new PostgresCommonModule()); - install(new BlobMemoryModule()); Multibinder postgresDataDefinitions = Multibinder.newSetBinder(binder(), PostgresModule.class); postgresDataDefinitions.addBinding().toInstance(PostgresMailboxAggregateModule.MODULE); diff --git a/server/container/guice/postgres-common/pom.xml b/server/container/guice/postgres-common/pom.xml index 503c7864332..3ccde23bd96 100644 --- a/server/container/guice/postgres-common/pom.xml +++ b/server/container/guice/postgres-common/pom.xml @@ -62,6 +62,10 @@ test-jar test + + ${james.groupId} + james-server-guice-distributed + ${james.groupId} james-server-mailbox-adapter From 7088db7c4a41bd67e208dbdcded71fe71c0c2dcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20H=E1=BB=93ng=20Qu=C3=A2n?= <55171818+quantranhong1999@users.noreply.github.com> Date: Thu, 21 Dec 2023 19:21:49 +0700 Subject: [PATCH 147/341] JAMES-2586 Message body deduplication (#1873) --- .../postgres/mail/PostgresMessageMapper.java | 56 ++++---- .../postgres/mail/PostgresMessageModule.java | 4 +- .../mail/dao/PostgresMailboxMessageDAO.java | 17 ++- .../PostgresMailboxMessageFetchStrategy.java | 2 +- .../postgres/mail/dao/PostgresMessageDAO.java | 6 +- .../BodyDeduplicationIntegrationTest.java | 124 ++++++++++++++++++ .../src/test/resources/mailetcontainer.xml | 1 + 7 files changed, 164 insertions(+), 46 deletions(-) create mode 100644 server/apps/postgres-app/src/test/java/org/apache/james/BodyDeduplicationIntegrationTest.java diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java index f158744c381..faa785ab793 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java @@ -21,8 +21,9 @@ import static org.apache.james.blob.api.BlobStore.StoragePolicy.LOW_COST; import static org.apache.james.blob.api.BlobStore.StoragePolicy.SIZE_BASED; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.BODY_BLOB_ID; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.HEADER_CONTENT; -import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.time.Clock; @@ -48,6 +49,7 @@ import org.apache.james.mailbox.model.ComposedMessageId; import org.apache.james.mailbox.model.ComposedMessageIdWithMetaData; import org.apache.james.mailbox.model.Content; +import org.apache.james.mailbox.model.HeaderAndBodyByteContent; import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MailboxCounters; import org.apache.james.mailbox.model.MessageMetaData; @@ -63,6 +65,7 @@ import org.apache.james.mailbox.store.mail.model.MailboxMessage; import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; import org.apache.james.util.streams.Limit; +import org.jooq.Record; import com.google.common.io.ByteSource; @@ -71,11 +74,11 @@ public class PostgresMessageMapper implements MessageMapper { - private static final Function MESSAGE_FULL_CONTENT_LOADER = (mailboxMessage) -> new ByteSource() { + private static final Function MESSAGE_BODY_CONTENT_LOADER = (mailboxMessage) -> new ByteSource() { @Override public InputStream openStream() { try { - return mailboxMessage.getFullContent(); + return mailboxMessage.getBodyContent(); } catch (IOException e) { throw new RuntimeException(e); } @@ -83,7 +86,7 @@ public InputStream openStream() { @Override public long size() { - return mailboxMessage.metaData().getSize(); + return mailboxMessage.getBodyOctets(); } }; @@ -128,14 +131,13 @@ public Flux listMessagesMetadata(Mailbox mailbox, @Override public Flux findInMailboxReactive(Mailbox mailbox, MessageRange messageRange, FetchType fetchType, int limitAsInt) { - Flux> fetchMessageWithoutFullContentPublisher = fetchMessageWithoutFullContent(mailbox, messageRange, fetchType, limitAsInt); + Flux> fetchMessageWithoutFullContentPublisher = fetchMessageWithoutFullContent(mailbox, messageRange, fetchType, limitAsInt); if (fetchType == FetchType.FULL) { return fetchMessageWithoutFullContentPublisher - .flatMap(messageBuilderAndBlobId -> { - SimpleMailboxMessage.Builder messageBuilder = messageBuilderAndBlobId.getLeft(); - String blobIdAsString = messageBuilderAndBlobId.getRight(); - return retrieveFullContent(blobIdAsString) - .map(content -> messageBuilder.content(content).build()); + .flatMap(messageBuilderAndRecord -> { + SimpleMailboxMessage.Builder messageBuilder = messageBuilderAndRecord.getLeft(); + return retrieveFullContent(messageBuilderAndRecord.getRight()) + .map(headerAndBodyContent -> messageBuilder.content(headerAndBodyContent).build()); }) .sort(Comparator.comparing(MailboxMessage::getUid)) .map(message -> message); @@ -145,7 +147,7 @@ public Flux findInMailboxReactive(Mailbox mailbox, MessageRange } } - private Flux> fetchMessageWithoutFullContent(Mailbox mailbox, MessageRange messageRange, FetchType fetchType, int limitAsInt) { + private Flux> fetchMessageWithoutFullContent(Mailbox mailbox, MessageRange messageRange, FetchType fetchType, int limitAsInt) { return Mono.just(messageRange) .flatMapMany(range -> { Limit limit = Limit.from(limitAsInt); @@ -165,19 +167,12 @@ private Flux> fetchMessageWithoutFull }); } - private Mono retrieveFullContent(String blobIdString) { - return Mono.from(blobStore.readBytes(blobStore.getDefaultBucketName(), blobIdFactory.from(blobIdString), SIZE_BASED)) - .map(contentAsBytes -> new Content() { - @Override - public InputStream getInputStream() { - return new ByteArrayInputStream(contentAsBytes); - } - - @Override - public long size() { - return contentAsBytes.length; - } - }); + private Mono retrieveFullContent(Record messageRecord) { + byte[] headerBytes = messageRecord.get(HEADER_CONTENT); + return Mono.from(blobStore.readBytes(blobStore.getDefaultBucketName(), + blobIdFactory.from(messageRecord.get(BODY_BLOB_ID)), + SIZE_BASED)) + .map(bodyBytes -> new HeaderAndBodyByteContent(headerBytes, bodyBytes)); } @Override @@ -282,14 +277,14 @@ public Mono addReactive(Mailbox mailbox, MailboxMessage message return message; }) .flatMap(this::setNewUidAndModSeq) - .then(saveFullContent(message) - .flatMap(blobId -> messageDAO.insert(message, blobId.asString()))) + .then(saveBodyContent(message) + .flatMap(bodyBlobId -> messageDAO.insert(message, bodyBlobId.asString()))) .then(Mono.defer(() -> mailboxMessageDAO.insert(message))) .then(Mono.fromCallable(message::metaData)); } - private Mono saveFullContent(MailboxMessage message) { - return Mono.fromCallable(() -> MESSAGE_FULL_CONTENT_LOADER.apply(message)) + private Mono saveBodyContent(MailboxMessage message) { + return Mono.fromCallable(() -> MESSAGE_BODY_CONTENT_LOADER.apply(message)) .flatMap(bodyByteSource -> Mono.from(blobStore.save(blobStore.getDefaultBucketName(), bodyByteSource, LOW_COST))); } @@ -345,7 +340,7 @@ private Mono updateFlags(ComposedMessageIdWithMetaData currentMeta case REPLACE: return mailboxMessageDAO.replaceFlags((PostgresMailboxId) composedMessageId.getMailboxId(), composedMessageId.getUid(), flagsUpdateCalculator.providedFlags(), newModSeq); default: - throw new RuntimeException("Unknown MessageRange type " + mode); + return Mono.error(() -> new RuntimeException("Unknown MessageRange type " + mode)); } }).map(updatedFlags -> UpdatedFlags.builder() .messageId(composedMessageId.getMessageId()) @@ -417,8 +412,7 @@ public Mono copyReactive(Mailbox mailbox, MailboxMessage origin @Override public MessageMetaData move(Mailbox mailbox, MailboxMessage original) { - var t = moveReactive(mailbox, original).block(); - return t; + return moveReactive(mailbox, original).block(); } @Override diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java index 01baef4ed7d..eca81fec550 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java @@ -44,7 +44,7 @@ public interface PostgresMessageModule { interface MessageTable { Table TABLE_NAME = DSL.table("message"); Field MESSAGE_ID = PostgresMessageModule.MESSAGE_ID; - Field BLOB_ID = DSL.field("blob_id", SQLDataType.VARCHAR(200).notNull()); + Field BODY_BLOB_ID = DSL.field("body_blob_id", SQLDataType.VARCHAR(200).notNull()); Field MIME_TYPE = DSL.field("mime_type", SQLDataType.VARCHAR(200)); Field MIME_SUBTYPE = DSL.field("mime_subtype", SQLDataType.VARCHAR(200)); Field INTERNAL_DATE = PostgresMessageModule.INTERNAL_DATE; @@ -66,7 +66,7 @@ interface MessageTable { PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) .column(MESSAGE_ID) - .column(BLOB_ID) + .column(BODY_BLOB_ID) .column(MIME_TYPE) .column(MIME_SUBTYPE) .column(INTERNAL_DATE) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java index 168e0ff670c..c8d4f287a56 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java @@ -24,7 +24,6 @@ import static org.apache.james.backends.postgres.PostgresCommons.IN_CLAUSE_MAX_SIZE; import static org.apache.james.backends.postgres.PostgresCommons.UNNEST_FIELD; import static org.apache.james.backends.postgres.PostgresCommons.tableField; -import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.BLOB_ID; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.INTERNAL_DATE; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.SIZE; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageToMailboxTable.IS_ANSWERED; @@ -171,7 +170,7 @@ public Mono> countTotalAndUnseenMessagesByMailboxId(Postg .map(record -> Pair.of(record.get(totalCount, Integer.class), record.get(unSeenCount, Integer.class))); } - public Flux> findMessagesByMailboxId(PostgresMailboxId mailboxId, Limit limit, MessageMapper.FetchType fetchType) { + public Flux> findMessagesByMailboxId(PostgresMailboxId mailboxId, Limit limit, MessageMapper.FetchType fetchType) { PostgresMailboxMessageFetchStrategy fetchStrategy = FETCH_TYPE_TO_FETCH_STRATEGY.apply(fetchType); Function> queryWithoutLimit = dslContext -> dslContext.select(fetchStrategy.fetchFields()) .from(MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP) @@ -181,10 +180,10 @@ public Flux> findMessagesByMailboxId( return postgresExecutor.executeRows(dslContext -> limit.getLimit() .map(limitValue -> Flux.from(queryWithoutLimit.andThen(step -> step.limit(limitValue)).apply(dslContext))) .orElse(Flux.from(queryWithoutLimit.apply(dslContext)))) - .map(record -> Pair.of(fetchStrategy.toMessageBuilder().apply(record), record.get(BLOB_ID))); + .map(record -> Pair.of(fetchStrategy.toMessageBuilder().apply(record), record)); } - public Flux> findMessagesByMailboxIdAndBetweenUIDs(PostgresMailboxId mailboxId, MessageUid from, MessageUid to, Limit limit, FetchType fetchType) { + public Flux> findMessagesByMailboxIdAndBetweenUIDs(PostgresMailboxId mailboxId, MessageUid from, MessageUid to, Limit limit, FetchType fetchType) { PostgresMailboxMessageFetchStrategy fetchStrategy = FETCH_TYPE_TO_FETCH_STRATEGY.apply(fetchType); Function> queryWithoutLimit = dslContext -> dslContext.select(fetchStrategy.fetchFields()) .from(MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP) @@ -196,19 +195,19 @@ public Flux> findMessagesByMailboxIdA return postgresExecutor.executeRows(dslContext -> limit.getLimit() .map(limitValue -> Flux.from(queryWithoutLimit.andThen(step -> step.limit(limitValue)).apply(dslContext))) .orElse(Flux.from(queryWithoutLimit.apply(dslContext)))) - .map(record -> Pair.of(fetchStrategy.toMessageBuilder().apply(record), record.get(BLOB_ID))); + .map(record -> Pair.of(fetchStrategy.toMessageBuilder().apply(record), record)); } - public Mono> findMessageByMailboxIdAndUid(PostgresMailboxId mailboxId, MessageUid uid, FetchType fetchType) { + public Mono> findMessageByMailboxIdAndUid(PostgresMailboxId mailboxId, MessageUid uid, FetchType fetchType) { PostgresMailboxMessageFetchStrategy fetchStrategy = FETCH_TYPE_TO_FETCH_STRATEGY.apply(fetchType); return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select(fetchStrategy.fetchFields()) .from(MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP) .where(MAILBOX_ID.eq(mailboxId.asUuid())) .and(MESSAGE_UID.eq(uid.asLong())))) - .map(record -> Pair.of(fetchStrategy.toMessageBuilder().apply(record), record.get(BLOB_ID))); + .map(record -> Pair.of(fetchStrategy.toMessageBuilder().apply(record), record)); } - public Flux> findMessagesByMailboxIdAndAfterUID(PostgresMailboxId mailboxId, MessageUid from, Limit limit, FetchType fetchType) { + public Flux> findMessagesByMailboxIdAndAfterUID(PostgresMailboxId mailboxId, MessageUid from, Limit limit, FetchType fetchType) { PostgresMailboxMessageFetchStrategy fetchStrategy = FETCH_TYPE_TO_FETCH_STRATEGY.apply(fetchType); Function> queryWithoutLimit = dslContext -> dslContext.select(fetchStrategy.fetchFields()) .from(MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP) @@ -219,7 +218,7 @@ public Flux> findMessagesByMailboxIdA return postgresExecutor.executeRows(dslContext -> limit.getLimit() .map(limitValue -> Flux.from(queryWithoutLimit.andThen(step -> step.limit(limitValue)).apply(dslContext))) .orElse(Flux.from(queryWithoutLimit.apply(dslContext)))) - .map(record -> Pair.of(fetchStrategy.toMessageBuilder().apply(record), record.get(BLOB_ID))); + .map(record -> Pair.of(fetchStrategy.toMessageBuilder().apply(record), record)); } public Flux findMessagesByMailboxIdAndUIDs(PostgresMailboxId mailboxId, List uids) { diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageFetchStrategy.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageFetchStrategy.java index 4aef7b69ef9..f6cc82d4ca4 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageFetchStrategy.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageFetchStrategy.java @@ -76,7 +76,7 @@ static Field[] fetchFieldsMetadata() { tableField(MessageTable.TABLE_NAME, MessageTable.MESSAGE_ID).as(MessageTable.MESSAGE_ID), tableField(MessageTable.TABLE_NAME, MessageTable.INTERNAL_DATE).as(MessageTable.INTERNAL_DATE), tableField(MessageTable.TABLE_NAME, MessageTable.SIZE).as(MessageTable.SIZE), - MessageTable.BLOB_ID, + MessageTable.BODY_BLOB_ID, MessageTable.MIME_TYPE, MessageTable.MIME_SUBTYPE, MessageTable.BODY_START_OCTET, diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java index 54373077889..ecd8634cc1c 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java @@ -20,7 +20,7 @@ package org.apache.james.mailbox.postgres.mail.dao; import static org.apache.james.backends.postgres.PostgresCommons.DATE_TO_LOCAL_DATE_TIME; -import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.BLOB_ID; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.BODY_BLOB_ID; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.BODY_START_OCTET; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_DESCRIPTION; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_DISPOSITION_PARAMETERS; @@ -59,12 +59,12 @@ public PostgresMessageDAO(PostgresExecutor postgresExecutor) { this.postgresExecutor = postgresExecutor; } - public Mono insert(MailboxMessage message, String blobId) { + public Mono insert(MailboxMessage message, String bodyBlobId) { return Mono.fromCallable(() -> IOUtils.toByteArray(message.getHeaderContent(), message.getHeaderOctets())) .subscribeOn(Schedulers.boundedElastic()) .flatMap(headerContentAsByte -> postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME) .set(MESSAGE_ID, ((PostgresMessageId) message.getMessageId()).asUuid()) - .set(BLOB_ID, blobId) + .set(BODY_BLOB_ID, bodyBlobId) .set(MIME_TYPE, message.getMediaType()) .set(MIME_SUBTYPE, message.getSubType()) .set(INTERNAL_DATE, DATE_TO_LOCAL_DATE_TIME.apply(message.getInternalDate())) diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/BodyDeduplicationIntegrationTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/BodyDeduplicationIntegrationTest.java new file mode 100644 index 00000000000..3637e5653a2 --- /dev/null +++ b/server/apps/postgres-app/src/test/java/org/apache/james/BodyDeduplicationIntegrationTest.java @@ -0,0 +1,124 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.mailbox.DefaultMailboxes; +import org.apache.james.mailbox.postgres.mail.PostgresMessageModule; +import org.apache.james.modules.MailboxProbeImpl; +import org.apache.james.modules.blobstore.BlobStoreConfiguration; +import org.apache.james.modules.protocols.ImapGuiceProbe; +import org.apache.james.modules.protocols.SmtpGuiceProbe; +import org.apache.james.util.Port; +import org.apache.james.utils.DataProbeImpl; +import org.apache.james.utils.SMTPMessageSender; +import org.apache.james.utils.SpoolerProbe; +import org.apache.james.utils.TestIMAPClient; +import org.jooq.impl.DSL; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.google.common.collect.ImmutableList; +import com.google.common.io.Resources; + +import reactor.core.publisher.Mono; + +class BodyDeduplicationIntegrationTest implements MailsShouldBeWellReceivedConcreteContract { + static PostgresExtension postgresExtension = PostgresExtension.empty(); + + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.scanning()) + .blobStore(BlobStoreConfiguration.builder() + .file() + .disableCache() + .deduplication() + .noCryptoConfig()) + .usersRepository(DEFAULT) + .build()) + .server(PostgresJamesServerMain::createServer) + .extension(postgresExtension) + .build(); + + private static final String PASSWORD = "123456"; + private static final String YET_ANOTHER_USER = "yet-another-user@" + DOMAIN; + + private TestIMAPClient testIMAPClient; + private SMTPMessageSender smtpMessageSender; + + @BeforeEach + void setUp() { + this.testIMAPClient = new TestIMAPClient(); + this.smtpMessageSender = new SMTPMessageSender(DOMAIN); + } + + @Test + void bodyBlobsShouldBeDeDeduplicated(GuiceJamesServer server) throws Exception { + server.getProbe(DataProbeImpl.class).fluent() + .addDomain(DOMAIN) + .addUser(JAMES_USER, PASSWORD) + .addUser(OTHER_USER, PASSWORD_OTHER) + .addUser(YET_ANOTHER_USER, PASSWORD); + + MailboxProbeImpl mailboxProbe = server.getProbe(MailboxProbeImpl.class); + mailboxProbe.createMailbox("#private", JAMES_USER, DefaultMailboxes.INBOX); + mailboxProbe.createMailbox("#private", OTHER_USER, DefaultMailboxes.INBOX); + mailboxProbe.createMailbox("#private", YET_ANOTHER_USER, DefaultMailboxes.INBOX); + + Port smtpPort = server.getProbe(SmtpGuiceProbe.class).getSmtpPort(); + String message = Resources.toString(Resources.getResource("eml/htmlMail.eml"), StandardCharsets.UTF_8); + + // Given a mail sent to 3 recipients + smtpMessageSender.connect(JAMES_SERVER_HOST, smtpPort); + sendUniqueMessageToUsers(smtpMessageSender, message, ImmutableList.of(JAMES_USER, OTHER_USER, YET_ANOTHER_USER)); + CALMLY_AWAIT.untilAsserted(() -> assertThat(server.getProbe(SpoolerProbe.class).processingFinished()).isTrue()); + + // When 3 mails are received + testIMAPClient.connect(JAMES_SERVER_HOST, server.getProbe(ImapGuiceProbe.class).getImapPort()) + .login(JAMES_USER, PASSWORD) + .select(TestIMAPClient.INBOX) + .awaitMessageCount(CALMLY_AWAIT, 1); + testIMAPClient.connect(JAMES_SERVER_HOST, server.getProbe(ImapGuiceProbe.class).getImapPort()) + .login(OTHER_USER, PASSWORD_OTHER) + .select(TestIMAPClient.INBOX) + .awaitMessageCount(CALMLY_AWAIT, 1); + testIMAPClient.connect(JAMES_SERVER_HOST, server.getProbe(ImapGuiceProbe.class).getImapPort()) + .login(YET_ANOTHER_USER, PASSWORD) + .select(TestIMAPClient.INBOX) + .awaitMessageCount(CALMLY_AWAIT, 1); + + // Then the body blobs are deduplicated + int distinctBlobCount = postgresExtension.getPostgresExecutor() + .executeCount(dslContext -> Mono.from(dslContext.select(DSL.countDistinct(PostgresMessageModule.MessageTable.BODY_BLOB_ID)) + .from(PostgresMessageModule.MessageTable.TABLE_NAME))) + .block(); + + assertThat(distinctBlobCount).isEqualTo(1); + } +} diff --git a/server/apps/postgres-app/src/test/resources/mailetcontainer.xml b/server/apps/postgres-app/src/test/resources/mailetcontainer.xml index d152c1b1137..d03783d1b3e 100644 --- a/server/apps/postgres-app/src/test/resources/mailetcontainer.xml +++ b/server/apps/postgres-app/src/test/resources/mailetcontainer.xml @@ -62,6 +62,7 @@ rrt-error + local-address-error From 75048b6ed64ef2815a0632798d51148f4c6295be Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Fri, 22 Dec 2023 21:30:16 +0100 Subject: [PATCH 148/341] JAMES-2586 Remove james-server-cassandra-app direct dependency (#1875) --- .../cassandra-rabbitmq-object-storage/pom.xml | 7 ++++++ mpt/impl/smtp/cassandra/pom.xml | 7 ++++++ server/apps/cassandra-app/pom.xml | 6 +++++ server/apps/distributed-app/pom.xml | 6 +++++ server/apps/distributed-pop3-app/pom.xml | 6 +++++ server/apps/postgres-app/pom.xml | 24 +++++-------------- .../BodyDeduplicationIntegrationTest.java | 12 +++++++++- .../PostgresWithOpenSearchDisabledTest.java | 14 ++++++++++- .../james/WithScanningSearchMutableTest.java | 14 ++++++++++- .../modules/AwsS3BlobStoreExtension.java | 0 server/container/guice/opensearch/pom.xml | 18 ++++++++++++++ .../james/DockerOpenSearchExtension.java | 0 .../apache/james/DockerOpenSearchRule.java | 2 +- .../java/org/apache/james/TikaExtension.java | 0 .../apache/james/modules/TestTikaModule.java | 0 .../mailbox}/TestDockerOpenSearchModule.java | 2 +- server/container/guice/pom.xml | 6 +++++ .../pom.xml | 0 .../pom.xml | 7 ++++++ .../pom.xml | 7 ++++++ 20 files changed, 115 insertions(+), 23 deletions(-) rename server/{apps/distributed-app => container/guice/blob/s3}/src/test/java/org/apache/james/modules/AwsS3BlobStoreExtension.java (100%) rename server/{apps/cassandra-app => container/guice/opensearch}/src/test/java/org/apache/james/DockerOpenSearchExtension.java (100%) rename server/{apps/cassandra-app => container/guice/opensearch}/src/test/java/org/apache/james/DockerOpenSearchRule.java (96%) rename server/{apps/cassandra-app => container/guice/opensearch}/src/test/java/org/apache/james/TikaExtension.java (100%) rename server/{apps/cassandra-app => container/guice/opensearch}/src/test/java/org/apache/james/modules/TestTikaModule.java (100%) rename server/{apps/cassandra-app/src/test/java/org/apache/james/modules => container/guice/opensearch/src/test/java/org/apache/james/modules/mailbox}/TestDockerOpenSearchModule.java (98%) create mode 100644 server/protocols/jmap-draft-integration-testing/rabbitmq-jmap-draft-integration-testing/pom.xml diff --git a/mpt/impl/smtp/cassandra-rabbitmq-object-storage/pom.xml b/mpt/impl/smtp/cassandra-rabbitmq-object-storage/pom.xml index 7f7e3fe7abd..bca08801b2d 100644 --- a/mpt/impl/smtp/cassandra-rabbitmq-object-storage/pom.xml +++ b/mpt/impl/smtp/cassandra-rabbitmq-object-storage/pom.xml @@ -97,6 +97,13 @@ test-jar test + + ${james.groupId} + james-server-guice-opensearch + ${project.version} + test-jar + test + ${james.groupId} james-server-util diff --git a/mpt/impl/smtp/cassandra/pom.xml b/mpt/impl/smtp/cassandra/pom.xml index 454e359ef7a..fc5675ecb4d 100644 --- a/mpt/impl/smtp/cassandra/pom.xml +++ b/mpt/impl/smtp/cassandra/pom.xml @@ -69,6 +69,13 @@ test-jar test + + ${james.groupId} + james-server-guice-opensearch + ${project.version} + test-jar + test + ${james.groupId} james-server-util diff --git a/server/apps/cassandra-app/pom.xml b/server/apps/cassandra-app/pom.xml index 4acde23f713..a68ed2d66fd 100644 --- a/server/apps/cassandra-app/pom.xml +++ b/server/apps/cassandra-app/pom.xml @@ -171,6 +171,12 @@ ${james.groupId} james-server-guice-opensearch + + ${james.groupId} + james-server-guice-opensearch + test-jar + test + ${james.groupId} james-server-guice-pop diff --git a/server/apps/distributed-app/pom.xml b/server/apps/distributed-app/pom.xml index aec693e9bd6..505138d11d3 100644 --- a/server/apps/distributed-app/pom.xml +++ b/server/apps/distributed-app/pom.xml @@ -216,6 +216,12 @@ ${james.groupId} james-server-guice-opensearch + + ${james.groupId} + james-server-guice-opensearch + test-jar + test + ${james.groupId} james-server-guice-pop diff --git a/server/apps/distributed-pop3-app/pom.xml b/server/apps/distributed-pop3-app/pom.xml index 73dcdff5f45..1095197a12e 100644 --- a/server/apps/distributed-pop3-app/pom.xml +++ b/server/apps/distributed-pop3-app/pom.xml @@ -209,6 +209,12 @@ ${james.groupId} james-server-guice-opensearch + + ${james.groupId} + james-server-guice-opensearch + test-jar + test + ${james.groupId} james-server-guice-pop diff --git a/server/apps/postgres-app/pom.xml b/server/apps/postgres-app/pom.xml index 3e0dab7b67a..f361239063d 100644 --- a/server/apps/postgres-app/pom.xml +++ b/server/apps/postgres-app/pom.xml @@ -90,12 +90,6 @@ test-jar test - - ${james.groupId} - james-server-cassandra-app - test-jar - test - ${james.groupId} james-server-cli @@ -111,18 +105,6 @@ ${james.groupId} james-server-data-postgres - - ${james.groupId} - james-server-distributed-app - test-jar - test - - - ${james.groupId} - james-server-guice-distributed - - - ${james.groupId} james-server-guice-common @@ -175,6 +157,12 @@ ${james.groupId} james-server-guice-opensearch + + ${james.groupId} + james-server-guice-opensearch + test-jar + test + ${james.groupId} james-server-guice-pop diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/BodyDeduplicationIntegrationTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/BodyDeduplicationIntegrationTest.java index 3637e5653a2..ec50a572844 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/BodyDeduplicationIntegrationTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/BodyDeduplicationIntegrationTest.java @@ -46,7 +46,7 @@ import reactor.core.publisher.Mono; -class BodyDeduplicationIntegrationTest implements MailsShouldBeWellReceivedConcreteContract { +class BodyDeduplicationIntegrationTest implements MailsShouldBeWellReceived { static PostgresExtension postgresExtension = PostgresExtension.empty(); @RegisterExtension @@ -78,6 +78,16 @@ void setUp() { this.smtpMessageSender = new SMTPMessageSender(DOMAIN); } + @Override + public int imapPort(GuiceJamesServer server) { + return server.getProbe(ImapGuiceProbe.class).getImapPort(); + } + + @Override + public int smtpPort(GuiceJamesServer server) { + return server.getProbe(SmtpGuiceProbe.class).getSmtpPort().getValue(); + } + @Test void bodyBlobsShouldBeDeDeduplicated(GuiceJamesServer server) throws Exception { server.getProbe(DataProbeImpl.class).fluent() diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithOpenSearchDisabledTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithOpenSearchDisabledTest.java index 4412dc39d99..e00ecf2fd87 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithOpenSearchDisabledTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithOpenSearchDisabledTest.java @@ -31,6 +31,8 @@ import org.apache.james.mailbox.opensearch.events.OpenSearchListeningMessageSearchIndex; import org.apache.james.modules.EventDeadLettersProbe; import org.apache.james.modules.MailboxProbeImpl; +import org.apache.james.modules.protocols.ImapGuiceProbe; +import org.apache.james.modules.protocols.SmtpGuiceProbe; import org.apache.james.util.Host; import org.apache.james.util.Port; import org.apache.james.utils.DataProbeImpl; @@ -42,7 +44,7 @@ import com.google.common.io.Resources; -public class PostgresWithOpenSearchDisabledTest implements MailsShouldBeWellReceivedConcreteContract { +public class PostgresWithOpenSearchDisabledTest implements MailsShouldBeWellReceived { static PostgresExtension postgresExtension = PostgresExtension.empty(); @RegisterExtension @@ -61,6 +63,16 @@ public class PostgresWithOpenSearchDisabledTest implements MailsShouldBeWellRece .extension(postgresExtension) .build(); + @Override + public int imapPort(GuiceJamesServer server) { + return server.getProbe(ImapGuiceProbe.class).getImapPort(); + } + + @Override + public int smtpPort(GuiceJamesServer server) { + return server.getProbe(SmtpGuiceProbe.class).getSmtpPort().getValue(); + } + @Test void mailsShouldBeKeptInDeadLetterForLaterIndexing(GuiceJamesServer server) throws Exception { server.getProbe(DataProbeImpl.class).fluent() diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/WithScanningSearchMutableTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/WithScanningSearchMutableTest.java index 7f1e84a3cc0..7696431aafd 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/WithScanningSearchMutableTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/WithScanningSearchMutableTest.java @@ -22,9 +22,11 @@ import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.modules.protocols.ImapGuiceProbe; +import org.apache.james.modules.protocols.SmtpGuiceProbe; import org.junit.jupiter.api.extension.RegisterExtension; -public class WithScanningSearchMutableTest implements MailsShouldBeWellReceivedConcreteContract { +public class WithScanningSearchMutableTest implements MailsShouldBeWellReceived { static PostgresExtension postgresExtension = PostgresExtension.empty(); @RegisterExtension @@ -39,4 +41,14 @@ public class WithScanningSearchMutableTest implements MailsShouldBeWellReceivedC .extension(postgresExtension) .lifeCycle(JamesServerExtension.Lifecycle.PER_TEST) .build(); + + @Override + public int imapPort(GuiceJamesServer server) { + return server.getProbe(ImapGuiceProbe.class).getImapPort(); + } + + @Override + public int smtpPort(GuiceJamesServer server) { + return server.getProbe(SmtpGuiceProbe.class).getSmtpPort().getValue(); + } } diff --git a/server/apps/distributed-app/src/test/java/org/apache/james/modules/AwsS3BlobStoreExtension.java b/server/container/guice/blob/s3/src/test/java/org/apache/james/modules/AwsS3BlobStoreExtension.java similarity index 100% rename from server/apps/distributed-app/src/test/java/org/apache/james/modules/AwsS3BlobStoreExtension.java rename to server/container/guice/blob/s3/src/test/java/org/apache/james/modules/AwsS3BlobStoreExtension.java diff --git a/server/container/guice/opensearch/pom.xml b/server/container/guice/opensearch/pom.xml index 2f12db9c703..f927cbad3bc 100644 --- a/server/container/guice/opensearch/pom.xml +++ b/server/container/guice/opensearch/pom.xml @@ -39,6 +39,12 @@
    + + ${james.groupId} + apache-james-backends-opensearch + test-jar + test + ${james.groupId} apache-james-mailbox-opensearch @@ -55,6 +61,12 @@ ${james.groupId} apache-james-mailbox-tika + + ${james.groupId} + apache-james-mailbox-tika + test-jar + test + ${james.groupId} james-server-filesystem-api @@ -65,6 +77,12 @@ ${james.groupId} james-server-guice-common + + ${james.groupId} + james-server-guice-common + test-jar + test + ${james.groupId} james-server-guice-webadmin-mailbox diff --git a/server/apps/cassandra-app/src/test/java/org/apache/james/DockerOpenSearchExtension.java b/server/container/guice/opensearch/src/test/java/org/apache/james/DockerOpenSearchExtension.java similarity index 100% rename from server/apps/cassandra-app/src/test/java/org/apache/james/DockerOpenSearchExtension.java rename to server/container/guice/opensearch/src/test/java/org/apache/james/DockerOpenSearchExtension.java diff --git a/server/apps/cassandra-app/src/test/java/org/apache/james/DockerOpenSearchRule.java b/server/container/guice/opensearch/src/test/java/org/apache/james/DockerOpenSearchRule.java similarity index 96% rename from server/apps/cassandra-app/src/test/java/org/apache/james/DockerOpenSearchRule.java rename to server/container/guice/opensearch/src/test/java/org/apache/james/DockerOpenSearchRule.java index d8abc842012..84c18b4b4d0 100644 --- a/server/apps/cassandra-app/src/test/java/org/apache/james/DockerOpenSearchRule.java +++ b/server/container/guice/opensearch/src/test/java/org/apache/james/DockerOpenSearchRule.java @@ -21,7 +21,7 @@ import org.apache.james.backends.opensearch.DockerOpenSearch; import org.apache.james.backends.opensearch.DockerOpenSearchSingleton; -import org.apache.james.modules.TestDockerOpenSearchModule; +import org.apache.james.modules.mailbox.TestDockerOpenSearchModule; import org.junit.runner.Description; import org.junit.runners.model.Statement; diff --git a/server/apps/cassandra-app/src/test/java/org/apache/james/TikaExtension.java b/server/container/guice/opensearch/src/test/java/org/apache/james/TikaExtension.java similarity index 100% rename from server/apps/cassandra-app/src/test/java/org/apache/james/TikaExtension.java rename to server/container/guice/opensearch/src/test/java/org/apache/james/TikaExtension.java diff --git a/server/apps/cassandra-app/src/test/java/org/apache/james/modules/TestTikaModule.java b/server/container/guice/opensearch/src/test/java/org/apache/james/modules/TestTikaModule.java similarity index 100% rename from server/apps/cassandra-app/src/test/java/org/apache/james/modules/TestTikaModule.java rename to server/container/guice/opensearch/src/test/java/org/apache/james/modules/TestTikaModule.java diff --git a/server/apps/cassandra-app/src/test/java/org/apache/james/modules/TestDockerOpenSearchModule.java b/server/container/guice/opensearch/src/test/java/org/apache/james/modules/mailbox/TestDockerOpenSearchModule.java similarity index 98% rename from server/apps/cassandra-app/src/test/java/org/apache/james/modules/TestDockerOpenSearchModule.java rename to server/container/guice/opensearch/src/test/java/org/apache/james/modules/mailbox/TestDockerOpenSearchModule.java index 466647f6e84..850ccd7c5df 100644 --- a/server/apps/cassandra-app/src/test/java/org/apache/james/modules/TestDockerOpenSearchModule.java +++ b/server/container/guice/opensearch/src/test/java/org/apache/james/modules/mailbox/TestDockerOpenSearchModule.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.modules; +package org.apache.james.modules.mailbox; import org.apache.james.CleanupTasksPerformer; import org.apache.james.backends.opensearch.DockerOpenSearch; diff --git a/server/container/guice/pom.xml b/server/container/guice/pom.xml index 96b681617a3..3f090097382 100644 --- a/server/container/guice/pom.xml +++ b/server/container/guice/pom.xml @@ -178,6 +178,12 @@ james-server-guice-opensearch ${project.version} + + ${james.groupId} + james-server-guice-opensearch + ${project.version} + test-jar + ${james.groupId} james-server-guice-pop diff --git a/server/protocols/jmap-draft-integration-testing/rabbitmq-jmap-draft-integration-testing/pom.xml b/server/protocols/jmap-draft-integration-testing/rabbitmq-jmap-draft-integration-testing/pom.xml new file mode 100644 index 00000000000..e69de29bb2d diff --git a/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/pom.xml b/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/pom.xml index f58f929d68f..1fa7a09b0c9 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/pom.xml +++ b/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/pom.xml @@ -110,6 +110,13 @@ test-jar test + + ${james.groupId} + james-server-guice-opensearch + ${project.version} + test-jar + test + ${james.groupId} james-server-testing diff --git a/server/protocols/webadmin-integration-test/distributed-webadmin-integration-test/pom.xml b/server/protocols/webadmin-integration-test/distributed-webadmin-integration-test/pom.xml index e2f36b1d573..cf8b4d9a27e 100644 --- a/server/protocols/webadmin-integration-test/distributed-webadmin-integration-test/pom.xml +++ b/server/protocols/webadmin-integration-test/distributed-webadmin-integration-test/pom.xml @@ -80,6 +80,13 @@ test-jar test + + ${james.groupId} + james-server-guice-opensearch + ${project.version} + test-jar + test + ${james.groupId} james-server-webadmin-cassandra-data From 39a909e21134255e8a9d57cc74fa26b73d80262b Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Thu, 21 Dec 2023 17:33:45 +0700 Subject: [PATCH 149/341] JAMES-2586 Implement AllSearchOverride for Postgresql --- .../postgres/search/AllSearchOverride.java | 70 +++++++ .../search/AllSearchOverrideTest.java | 188 ++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/AllSearchOverride.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/AllSearchOverrideTest.java diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/AllSearchOverride.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/AllSearchOverride.java new file mode 100644 index 00000000000..f073ae061b8 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/AllSearchOverride.java @@ -0,0 +1,70 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.search; + +import javax.inject.Inject; + +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.SearchQuery; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; +import org.apache.james.mailbox.store.search.ListeningMessageSearchIndex; + +import reactor.core.publisher.Flux; + +public class AllSearchOverride implements ListeningMessageSearchIndex.SearchOverride { + private final PostgresMailboxMessageDAO dao; + + @Inject + public AllSearchOverride(PostgresMailboxMessageDAO dao) { + this.dao = dao; + } + + @Override + public boolean applicable(SearchQuery searchQuery, MailboxSession session) { + return isAll(searchQuery) + || isFromOne(searchQuery) + || isEmpty(searchQuery); + } + + private boolean isAll(SearchQuery searchQuery) { + return searchQuery.getCriteria().size() == 1 + && searchQuery.getCriteria().get(0).equals(SearchQuery.uid(new SearchQuery.UidRange(MessageUid.MIN_VALUE, MessageUid.MAX_VALUE))) + && searchQuery.getSorts().equals(SearchQuery.DEFAULT_SORTS); + } + + private boolean isFromOne(SearchQuery searchQuery) { + return searchQuery.getCriteria().size() == 1 + && searchQuery.getCriteria().get(0).equals(SearchQuery.all()) + && searchQuery.getSorts().equals(SearchQuery.DEFAULT_SORTS); + } + + private boolean isEmpty(SearchQuery searchQuery) { + return searchQuery.getCriteria().isEmpty() + && searchQuery.getSorts().equals(SearchQuery.DEFAULT_SORTS); + } + + @Override + public Flux search(MailboxSession session, Mailbox mailbox, SearchQuery searchQuery) { + return dao.listAllMessageUid((PostgresMailboxId) mailbox.getMailboxId()); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/AllSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/AllSearchOverrideTest.java new file mode 100644 index 00000000000..a96348eb563 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/AllSearchOverrideTest.java @@ -0,0 +1,188 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.search; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import javax.mail.Flags; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.core.Username; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.MailboxSessionUtil; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.model.ByteContent; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.SearchQuery; +import org.apache.james.mailbox.model.ThreadId; +import org.apache.james.mailbox.model.UidValidity; +import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.apache.james.mailbox.store.mail.model.MailboxMessage; +import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; +import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class AllSearchOverrideTest { + private static final MailboxSession MAILBOX_SESSION = MailboxSessionUtil.create(Username.of("benwa")); + private static final Mailbox MAILBOX = new Mailbox(MailboxPath.inbox(MAILBOX_SESSION), UidValidity.of(12), PostgresMailboxId.generate()); + private static final String BLOB_ID = "abc"; + private static final Charset MESSAGE_CHARSET = StandardCharsets.UTF_8; + private static final String MESSAGE_CONTENT = "Simple message content"; + private static final byte[] MESSAGE_CONTENT_BYTES = MESSAGE_CONTENT.getBytes(MESSAGE_CHARSET); + private static final ByteContent CONTENT_STREAM = new ByteContent(MESSAGE_CONTENT_BYTES); + private final static long SIZE = MESSAGE_CONTENT_BYTES.length; + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); + + private PostgresMailboxMessageDAO postgresMailboxMessageDAO; + private PostgresMessageDAO postgresMessageDAO; + private AllSearchOverride testee; + + @BeforeEach + void setUp() { + postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor()); + postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); + testee = new AllSearchOverride(postgresMailboxMessageDAO); + } + + @Test + void emptyQueryShouldBeApplicable() { + assertThat(testee.applicable( + SearchQuery.builder() + .build(), + MAILBOX_SESSION)) + .isTrue(); + } + + @Test + void allQueryShouldBeApplicable() { + assertThat(testee.applicable( + SearchQuery.builder() + .andCriteria(SearchQuery.all()) + .build(), + MAILBOX_SESSION)) + .isTrue(); + } + + @Test + void fromOneQueryShouldBeApplicable() { + assertThat(testee.applicable( + SearchQuery.builder() + .andCriteria(SearchQuery.uid(new SearchQuery.UidRange(MessageUid.MIN_VALUE, MessageUid.MAX_VALUE))) + .build(), + MAILBOX_SESSION)) + .isTrue(); + } + + @Test + void sizeQueryShouldNotBeApplicable() { + assertThat(testee.applicable( + SearchQuery.builder() + .andCriteria(SearchQuery.sizeEquals(12)) + .build(), + MAILBOX_SESSION)) + .isFalse(); + } + + @Test + void searchShouldReturnEmptyByDefault() { + assertThat(testee.search(MAILBOX_SESSION, MAILBOX, + SearchQuery.builder() + .andCriteria(SearchQuery.all()) + .build()).collectList().block()) + .isEmpty(); + } + + @Test + void searchShouldReturnMailboxEntries() { + MessageUid messageUid = MessageUid.of(1); + PostgresMessageId messageId = new PostgresMessageId.Factory().generate(); + MailboxMessage message1 = SimpleMailboxMessage.builder() + .messageId(messageId) + .threadId(ThreadId.fromBaseMessageId(messageId)) + .uid(messageUid) + .content(CONTENT_STREAM) + .size(SIZE) + .internalDate(new Date()) + .bodyStartOctet(18) + .flags(new Flags()) + .properties(new PropertyBuilder()) + .mailboxId(MAILBOX.getMailboxId()) + .modseq(ModSeq.of(1)) + .build(); + postgresMessageDAO.insert(message1, BLOB_ID).block(); + postgresMailboxMessageDAO.insert(message1).block(); + + MessageUid messageUid2 = MessageUid.of(2); + PostgresMessageId messageId2 = new PostgresMessageId.Factory().generate(); + MailboxMessage message2 = SimpleMailboxMessage.builder() + .messageId(messageId2) + .threadId(ThreadId.fromBaseMessageId(messageId2)) + .uid(messageUid2) + .content(CONTENT_STREAM) + .size(SIZE) + .internalDate(new Date()) + .bodyStartOctet(18) + .flags(new Flags()) + .properties(new PropertyBuilder()) + .mailboxId(MAILBOX.getMailboxId()) + .modseq(ModSeq.of(1)) + .build(); + postgresMessageDAO.insert(message2, BLOB_ID).block(); + postgresMailboxMessageDAO.insert(message2).block(); + + MessageUid messageUid3 = MessageUid.of(3); + PostgresMessageId messageId3 = new PostgresMessageId.Factory().generate(); + MailboxMessage message3 = SimpleMailboxMessage.builder() + .messageId(messageId3) + .threadId(ThreadId.fromBaseMessageId(messageId3)) + .uid(messageUid3) + .content(CONTENT_STREAM) + .size(SIZE) + .internalDate(new Date()) + .bodyStartOctet(18) + .flags(new Flags()) + .properties(new PropertyBuilder()) + .mailboxId(PostgresMailboxId.generate()) + .modseq(ModSeq.of(1)) + .build(); + postgresMessageDAO.insert(message3, BLOB_ID).block(); + postgresMailboxMessageDAO.insert(message3).block(); + + assertThat(testee.search(MAILBOX_SESSION, MAILBOX, + SearchQuery.builder() + .andCriteria(SearchQuery.all()) + .build()).collectList().block()) + .containsOnly(messageUid, messageUid2); + } +} From b329cfde62d61f7b90e57e60ec7dea43359942a4 Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Fri, 22 Dec 2023 13:56:51 +0700 Subject: [PATCH 150/341] JAMES-2586 Implement DeletedSearchOverride for Postgresql --- .../search/DeletedSearchOverride.java | 54 ++++++ .../search/DeletedSearchOverrideTest.java | 171 ++++++++++++++++++ 2 files changed, 225 insertions(+) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverride.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverrideTest.java diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverride.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverride.java new file mode 100644 index 00000000000..48f365274e2 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverride.java @@ -0,0 +1,54 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.search; + +import javax.inject.Inject; +import javax.mail.Flags; + +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.SearchQuery; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; +import org.apache.james.mailbox.store.search.ListeningMessageSearchIndex; + +import reactor.core.publisher.Flux; + +public class DeletedSearchOverride implements ListeningMessageSearchIndex.SearchOverride { + private final PostgresMailboxMessageDAO dao; + + @Inject + public DeletedSearchOverride(PostgresMailboxMessageDAO dao) { + this.dao = dao; + } + + @Override + public boolean applicable(SearchQuery searchQuery, MailboxSession session) { + return searchQuery.getCriteria().size() == 1 + && searchQuery.getCriteria().get(0).equals(SearchQuery.flagIsSet(Flags.Flag.DELETED)) + && searchQuery.getSorts().equals(SearchQuery.DEFAULT_SORTS); + } + + @Override + public Flux search(MailboxSession session, Mailbox mailbox, SearchQuery searchQuery) { + return dao.findDeletedMessagesByMailboxId((PostgresMailboxId) mailbox.getMailboxId()); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverrideTest.java new file mode 100644 index 00000000000..29e68fdfd54 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverrideTest.java @@ -0,0 +1,171 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.search; + +import static javax.mail.Flags.Flag.DELETED; +import static javax.mail.Flags.Flag.SEEN; +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import javax.mail.Flags; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.core.Username; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.MailboxSessionUtil; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.model.ByteContent; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.SearchQuery; +import org.apache.james.mailbox.model.ThreadId; +import org.apache.james.mailbox.model.UidValidity; +import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.apache.james.mailbox.store.mail.model.MailboxMessage; +import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; +import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class DeletedSearchOverrideTest { + private static final MailboxSession MAILBOX_SESSION = MailboxSessionUtil.create(Username.of("benwa")); + private static final Mailbox MAILBOX = new Mailbox(MailboxPath.inbox(MAILBOX_SESSION), UidValidity.of(12), PostgresMailboxId.generate()); + private static final String BLOB_ID = "abc"; + private static final Charset MESSAGE_CHARSET = StandardCharsets.UTF_8; + private static final String MESSAGE_CONTENT = "Simple message content"; + private static final byte[] MESSAGE_CONTENT_BYTES = MESSAGE_CONTENT.getBytes(MESSAGE_CHARSET); + private static final ByteContent CONTENT_STREAM = new ByteContent(MESSAGE_CONTENT_BYTES); + private final static long SIZE = MESSAGE_CONTENT_BYTES.length; + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); + + private PostgresMailboxMessageDAO postgresMailboxMessageDAO; + private PostgresMessageDAO postgresMessageDAO; + private DeletedSearchOverride testee; + + @BeforeEach + void setUp() { + postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor()); + postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); + testee = new DeletedSearchOverride(postgresMailboxMessageDAO); + } + + @Test + void deletedQueryShouldBeApplicable() { + assertThat(testee.applicable( + SearchQuery.builder() + .andCriteria(SearchQuery.flagIsSet(DELETED)) + .build(), + MAILBOX_SESSION)) + .isTrue(); + } + + @Test + void sizeQueryShouldNotBeApplicable() { + assertThat(testee.applicable( + SearchQuery.builder() + .andCriteria(SearchQuery.sizeEquals(12)) + .build(), + MAILBOX_SESSION)) + .isFalse(); + } + + @Test + void searchShouldReturnEmptyByDefault() { + assertThat(testee.search(MAILBOX_SESSION, MAILBOX, + SearchQuery.builder() + .andCriteria(SearchQuery.flagIsUnSet(SEEN)) + .build()).collectList().block()) + .isEmpty(); + } + + @Test + void searchShouldReturnMailboxEntries() { + MessageUid messageUid = MessageUid.of(1); + PostgresMessageId messageId = new PostgresMessageId.Factory().generate(); + MailboxMessage message1 = SimpleMailboxMessage.builder() + .messageId(messageId) + .threadId(ThreadId.fromBaseMessageId(messageId)) + .uid(messageUid) + .content(CONTENT_STREAM) + .size(SIZE) + .internalDate(new Date()) + .bodyStartOctet(18) + .flags(new Flags(DELETED)) + .properties(new PropertyBuilder()) + .mailboxId(MAILBOX.getMailboxId()) + .modseq(ModSeq.of(1)) + .build(); + postgresMessageDAO.insert(message1, BLOB_ID).block(); + postgresMailboxMessageDAO.insert(message1).block(); + + MessageUid messageUid2 = MessageUid.of(2); + PostgresMessageId messageId2 = new PostgresMessageId.Factory().generate(); + MailboxMessage message2 = SimpleMailboxMessage.builder() + .messageId(messageId2) + .threadId(ThreadId.fromBaseMessageId(messageId2)) + .uid(messageUid2) + .content(CONTENT_STREAM) + .size(SIZE) + .internalDate(new Date()) + .bodyStartOctet(18) + .flags(new Flags(DELETED)) + .properties(new PropertyBuilder()) + .mailboxId(MAILBOX.getMailboxId()) + .modseq(ModSeq.of(1)) + .build(); + postgresMessageDAO.insert(message2, BLOB_ID).block(); + postgresMailboxMessageDAO.insert(message2).block(); + + MessageUid messageUid3 = MessageUid.of(3); + PostgresMessageId messageId3 = new PostgresMessageId.Factory().generate(); + MailboxMessage message3 = SimpleMailboxMessage.builder() + .messageId(messageId3) + .threadId(ThreadId.fromBaseMessageId(messageId3)) + .uid(messageUid3) + .content(CONTENT_STREAM) + .size(SIZE) + .internalDate(new Date()) + .bodyStartOctet(18) + .flags(new Flags()) + .properties(new PropertyBuilder()) + .mailboxId(MAILBOX.getMailboxId()) + .modseq(ModSeq.of(1)) + .build(); + postgresMessageDAO.insert(message3, BLOB_ID).block(); + postgresMailboxMessageDAO.insert(message3).block(); + + assertThat(testee.search(MAILBOX_SESSION, MAILBOX, + SearchQuery.builder() + .andCriteria(SearchQuery.flagIsSet(DELETED)) + .build()).collectList().block()) + .containsOnly(messageUid, messageUid2); + } +} From ae7216ec139892afc5d47196a2ef17d165c6c0d3 Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Fri, 22 Dec 2023 14:12:28 +0700 Subject: [PATCH 151/341] JAMES-2586 Implement DeletedWithRangeSearchOverride for Postgresql --- .../DeletedWithRangeSearchOverride.java | 68 ++++++ .../DeletedWithRangeSearchOverrideTest.java | 220 ++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverride.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverrideTest.java diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverride.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverride.java new file mode 100644 index 00000000000..c463d561041 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverride.java @@ -0,0 +1,68 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.search; + +import javax.inject.Inject; +import javax.mail.Flags; + +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.SearchQuery; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; +import org.apache.james.mailbox.store.search.ListeningMessageSearchIndex; + +import com.google.common.collect.ImmutableList; + +import reactor.core.publisher.Flux; + +public class DeletedWithRangeSearchOverride implements ListeningMessageSearchIndex.SearchOverride { + private final PostgresMailboxMessageDAO dao; + + @Inject + public DeletedWithRangeSearchOverride(PostgresMailboxMessageDAO dao) { + this.dao = dao; + } + + @Override + public boolean applicable(SearchQuery searchQuery, MailboxSession session) { + return searchQuery.getCriteria().size() == 2 + && searchQuery.getCriteria().contains(SearchQuery.flagIsSet(Flags.Flag.DELETED)) + && searchQuery.getCriteria().stream() + .anyMatch(criterion -> criterion instanceof SearchQuery.UidCriterion) + && searchQuery.getSorts().equals(SearchQuery.DEFAULT_SORTS); + } + + @Override + public Flux search(MailboxSession session, Mailbox mailbox, SearchQuery searchQuery) { + SearchQuery.UidCriterion uidArgument = searchQuery.getCriteria().stream() + .filter(criterion -> criterion instanceof SearchQuery.UidCriterion) + .map(SearchQuery.UidCriterion.class::cast) + .findAny() + .orElseThrow(() -> new RuntimeException("Missing Uid argument")); + + SearchQuery.UidRange[] uidRanges = uidArgument.getOperator().getRange(); + + return Flux.fromIterable(ImmutableList.copyOf(uidRanges)) + .concatMap(range -> dao.findDeletedMessagesByMailboxIdAndBetweenUIDs((PostgresMailboxId) mailbox.getMailboxId(), + range.getLowValue(), range.getHighValue())); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverrideTest.java new file mode 100644 index 00000000000..d7df1955bcf --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverrideTest.java @@ -0,0 +1,220 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.search; + +import static javax.mail.Flags.Flag.DELETED; +import static javax.mail.Flags.Flag.SEEN; +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import javax.mail.Flags; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.core.Username; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.MailboxSessionUtil; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.model.ByteContent; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.SearchQuery; +import org.apache.james.mailbox.model.ThreadId; +import org.apache.james.mailbox.model.UidValidity; +import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.apache.james.mailbox.store.mail.model.MailboxMessage; +import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; +import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class DeletedWithRangeSearchOverrideTest { + private static final MailboxSession MAILBOX_SESSION = MailboxSessionUtil.create(Username.of("benwa")); + private static final Mailbox MAILBOX = new Mailbox(MailboxPath.inbox(MAILBOX_SESSION), UidValidity.of(12), PostgresMailboxId.generate()); + private static final String BLOB_ID = "abc"; + private static final Charset MESSAGE_CHARSET = StandardCharsets.UTF_8; + private static final String MESSAGE_CONTENT = "Simple message content"; + private static final byte[] MESSAGE_CONTENT_BYTES = MESSAGE_CONTENT.getBytes(MESSAGE_CHARSET); + private static final ByteContent CONTENT_STREAM = new ByteContent(MESSAGE_CONTENT_BYTES); + private final static long SIZE = MESSAGE_CONTENT_BYTES.length; + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); + + private PostgresMailboxMessageDAO postgresMailboxMessageDAO; + private PostgresMessageDAO postgresMessageDAO; + private DeletedWithRangeSearchOverride testee; + + @BeforeEach + void setUp() { + postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor()); + postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); + testee = new DeletedWithRangeSearchOverride(postgresMailboxMessageDAO); + } + + @Test + void deletedWithRangeQueryShouldBeApplicable() { + assertThat(testee.applicable( + SearchQuery.builder() + .andCriteria(SearchQuery.flagIsSet(DELETED)) + .andCriteria(SearchQuery.uid(new SearchQuery.UidRange(MessageUid.of(4), MessageUid.of(45)))) + .build(), + MAILBOX_SESSION)) + .isTrue(); + } + + @Test + void deletedQueryShouldNotBeApplicable() { + assertThat(testee.applicable( + SearchQuery.builder() + .andCriteria(SearchQuery.flagIsSet(DELETED)) + .build(), + MAILBOX_SESSION)) + .isFalse(); + } + + @Test + void sizeQueryShouldNotBeApplicable() { + assertThat(testee.applicable( + SearchQuery.builder() + .andCriteria(SearchQuery.sizeEquals(12)) + .build(), + MAILBOX_SESSION)) + .isFalse(); + } + + @Test + void searchShouldReturnEmptyByDefault() { + assertThat(testee.search(MAILBOX_SESSION, MAILBOX, + SearchQuery.builder() + .andCriteria(SearchQuery.flagIsUnSet(SEEN)) + .andCriteria(SearchQuery.uid(new SearchQuery.UidRange(MessageUid.MIN_VALUE, MessageUid.of(45)))) + .build()).collectList().block()) + .isEmpty(); + } + + @Test + void searchShouldReturnMailboxEntries() { + MessageUid messageUid = MessageUid.of(1); + PostgresMessageId messageId = new PostgresMessageId.Factory().generate(); + MailboxMessage message1 = SimpleMailboxMessage.builder() + .messageId(messageId) + .threadId(ThreadId.fromBaseMessageId(messageId)) + .uid(messageUid) + .content(CONTENT_STREAM) + .size(SIZE) + .internalDate(new Date()) + .bodyStartOctet(18) + .flags(new Flags(DELETED)) + .properties(new PropertyBuilder()) + .mailboxId(MAILBOX.getMailboxId()) + .modseq(ModSeq.of(1)) + .build(); + postgresMessageDAO.insert(message1, BLOB_ID).block(); + postgresMailboxMessageDAO.insert(message1).block(); + + MessageUid messageUid2 = MessageUid.of(2); + PostgresMessageId messageId2 = new PostgresMessageId.Factory().generate(); + MailboxMessage message2 = SimpleMailboxMessage.builder() + .messageId(messageId2) + .threadId(ThreadId.fromBaseMessageId(messageId2)) + .uid(messageUid2) + .content(CONTENT_STREAM) + .size(SIZE) + .internalDate(new Date()) + .bodyStartOctet(18) + .flags(new Flags(DELETED)) + .properties(new PropertyBuilder()) + .mailboxId(MAILBOX.getMailboxId()) + .modseq(ModSeq.of(1)) + .build(); + postgresMessageDAO.insert(message2, BLOB_ID).block(); + postgresMailboxMessageDAO.insert(message2).block(); + + MessageUid messageUid3 = MessageUid.of(3); + PostgresMessageId messageId3 = new PostgresMessageId.Factory().generate(); + MailboxMessage message3 = SimpleMailboxMessage.builder() + .messageId(messageId3) + .threadId(ThreadId.fromBaseMessageId(messageId3)) + .uid(messageUid3) + .content(CONTENT_STREAM) + .size(SIZE) + .internalDate(new Date()) + .bodyStartOctet(18) + .flags(new Flags(DELETED)) + .properties(new PropertyBuilder()) + .mailboxId(MAILBOX.getMailboxId()) + .modseq(ModSeq.of(1)) + .build(); + postgresMessageDAO.insert(message3, BLOB_ID).block(); + postgresMailboxMessageDAO.insert(message3).block(); + + MessageUid messageUid4 = MessageUid.of(4); + PostgresMessageId messageId4 = new PostgresMessageId.Factory().generate(); + MailboxMessage message4 = SimpleMailboxMessage.builder() + .messageId(messageId4) + .threadId(ThreadId.fromBaseMessageId(messageId4)) + .uid(messageUid4) + .content(CONTENT_STREAM) + .size(SIZE) + .internalDate(new Date()) + .bodyStartOctet(18) + .flags(new Flags(DELETED)) + .properties(new PropertyBuilder()) + .mailboxId(MAILBOX.getMailboxId()) + .modseq(ModSeq.of(1)) + .build(); + postgresMessageDAO.insert(message4, BLOB_ID).block(); + postgresMailboxMessageDAO.insert(message4).block(); + + MessageUid messageUid5 = MessageUid.of(5); + PostgresMessageId messageId5 = new PostgresMessageId.Factory().generate(); + MailboxMessage message5 = SimpleMailboxMessage.builder() + .messageId(messageId5) + .threadId(ThreadId.fromBaseMessageId(messageId5)) + .uid(messageUid5) + .content(CONTENT_STREAM) + .size(SIZE) + .internalDate(new Date()) + .bodyStartOctet(18) + .flags(new Flags(DELETED)) + .properties(new PropertyBuilder()) + .mailboxId(MAILBOX.getMailboxId()) + .modseq(ModSeq.of(1)) + .build(); + postgresMessageDAO.insert(message5, BLOB_ID).block(); + postgresMailboxMessageDAO.insert(message5).block(); + + assertThat(testee.search(MAILBOX_SESSION, MAILBOX, + SearchQuery.builder() + .andCriteria(SearchQuery.flagIsSet(DELETED)) + .andCriteria(SearchQuery.uid(new SearchQuery.UidRange(messageUid2, messageUid4))) + .build()).collectList().block()) + .containsOnly(messageUid2, messageUid3, messageUid4); + } +} From 6b35fbe634914943212c9098130bdcce629ff69f Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Fri, 22 Dec 2023 14:57:50 +0700 Subject: [PATCH 152/341] JAMES-2586 Implement NotDeletedWithRangeSearchOverride for Postgresql --- .../mail/dao/PostgresMailboxMessageDAO.java | 11 ++ .../NotDeletedWithRangeSearchOverride.java | 79 +++++++++ ...NotDeletedWithRangeSearchOverrideTest.java | 163 ++++++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverride.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverrideTest.java diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java index c8d4f287a56..049cd40f7b5 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java @@ -278,6 +278,17 @@ public Mono findDeletedMessageByMailboxIdAndUid(PostgresMailboxId ma .map(RECORD_TO_MESSAGE_UID_FUNCTION); } + public Flux listNotDeletedUids(PostgresMailboxId mailboxId, MessageRange range) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(MESSAGE_UID, IS_DELETED) + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(MESSAGE_UID.greaterOrEqual(range.getUidFrom().asLong())) + .and(MESSAGE_UID.lessOrEqual(range.getUidTo().asLong())) + .orderBy(DEFAULT_SORT_ORDER_BY))) + .filter(record -> !record.get(IS_DELETED)) + .map(RECORD_TO_MESSAGE_UID_FUNCTION); + } + public Flux findMessagesMetadata(PostgresMailboxId mailboxId, MessageRange range) { return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select() .from(TABLE_NAME) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverride.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverride.java new file mode 100644 index 00000000000..5e0e342dbac --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverride.java @@ -0,0 +1,79 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.search; + +import javax.inject.Inject; +import javax.mail.Flags; + +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MessageRange; +import org.apache.james.mailbox.model.SearchQuery; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; +import org.apache.james.mailbox.store.search.ListeningMessageSearchIndex; + +import reactor.core.publisher.Flux; + +public class NotDeletedWithRangeSearchOverride implements ListeningMessageSearchIndex.SearchOverride { + private final PostgresMailboxMessageDAO dao; + + @Inject + public NotDeletedWithRangeSearchOverride(PostgresMailboxMessageDAO dao) { + this.dao = dao; + } + + @Override + public boolean applicable(SearchQuery searchQuery, MailboxSession session) { + return isDeletedUnset(searchQuery) || isDeletedNotSet(searchQuery); + } + + private boolean isDeletedUnset(SearchQuery searchQuery) { + return searchQuery.getCriteria().size() == 2 + && searchQuery.getCriteria().contains(SearchQuery.flagIsUnSet(Flags.Flag.DELETED)) + && searchQuery.getCriteria().stream() + .anyMatch(criterion -> criterion instanceof SearchQuery.UidCriterion) + && searchQuery.getSorts().equals(SearchQuery.DEFAULT_SORTS); + } + + private boolean isDeletedNotSet(SearchQuery searchQuery) { + return searchQuery.getCriteria().size() == 2 + && searchQuery.getCriteria().contains(SearchQuery.not(SearchQuery.flagIsSet(Flags.Flag.DELETED))) + && searchQuery.getCriteria().stream() + .anyMatch(criterion -> criterion instanceof SearchQuery.UidCriterion) + && searchQuery.getSorts().equals(SearchQuery.DEFAULT_SORTS); + } + + @Override + public Flux search(MailboxSession session, Mailbox mailbox, SearchQuery searchQuery) { + SearchQuery.UidCriterion uidArgument = searchQuery.getCriteria().stream() + .filter(criterion -> criterion instanceof SearchQuery.UidCriterion) + .map(SearchQuery.UidCriterion.class::cast) + .findAny() + .orElseThrow(() -> new RuntimeException("Missing Uid argument")); + + SearchQuery.UidRange[] uidRanges = uidArgument.getOperator().getRange(); + + return Flux.fromArray(uidRanges) + .concatMap(range -> dao.listNotDeletedUids((PostgresMailboxId) mailbox.getMailboxId(), + MessageRange.range(range.getLowValue(), range.getHighValue()))); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverrideTest.java new file mode 100644 index 00000000000..7ee4d9f9cc5 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverrideTest.java @@ -0,0 +1,163 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.search; + +import static javax.mail.Flags.Flag.DELETED; +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import javax.mail.Flags; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.core.Username; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.MailboxSessionUtil; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.model.ByteContent; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.SearchQuery; +import org.apache.james.mailbox.model.ThreadId; +import org.apache.james.mailbox.model.UidValidity; +import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.apache.james.mailbox.store.mail.model.MailboxMessage; +import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; +import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class NotDeletedWithRangeSearchOverrideTest { + private static final MailboxSession MAILBOX_SESSION = MailboxSessionUtil.create(Username.of("benwa")); + private static final Mailbox MAILBOX = new Mailbox(MailboxPath.inbox(MAILBOX_SESSION), UidValidity.of(12), PostgresMailboxId.generate()); + private static final String BLOB_ID = "abc"; + private static final Charset MESSAGE_CHARSET = StandardCharsets.UTF_8; + private static final String MESSAGE_CONTENT = "Simple message content"; + private static final byte[] MESSAGE_CONTENT_BYTES = MESSAGE_CONTENT.getBytes(MESSAGE_CHARSET); + private static final ByteContent CONTENT_STREAM = new ByteContent(MESSAGE_CONTENT_BYTES); + private final static long SIZE = MESSAGE_CONTENT_BYTES.length; + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); + + private PostgresMailboxMessageDAO postgresMailboxMessageDAO; + private PostgresMessageDAO postgresMessageDAO; + private NotDeletedWithRangeSearchOverride testee; + + @BeforeEach + void setUp() { + postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor()); + postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); + testee = new NotDeletedWithRangeSearchOverride(postgresMailboxMessageDAO); + } + + @Test + void undeletedRangeQueryShouldBeApplicable() { + assertThat(testee.applicable( + SearchQuery.builder() + .andCriteria(SearchQuery.flagIsUnSet(DELETED)) + .andCriteria(SearchQuery.uid(new SearchQuery.UidRange(MessageUid.of(4), MessageUid.of(45)))) + .build(), + MAILBOX_SESSION)) + .isTrue(); + } + + @Test + void notDeletedRangeQueryShouldBeApplicable() { + assertThat(testee.applicable( + SearchQuery.builder() + .andCriteria(SearchQuery.not(SearchQuery.flagIsSet(DELETED))) + .andCriteria(SearchQuery.uid(new SearchQuery.UidRange(MessageUid.of(4), MessageUid.of(45)))) + .build(), + MAILBOX_SESSION)) + .isTrue(); + } + + @Test + void sizeQueryShouldNotBeApplicable() { + assertThat(testee.applicable( + SearchQuery.builder() + .andCriteria(SearchQuery.sizeEquals(12)) + .build(), + MAILBOX_SESSION)) + .isFalse(); + } + + @Test + void searchShouldReturnEmptyByDefault() { + assertThat(testee.search(MAILBOX_SESSION, MAILBOX, + SearchQuery.builder() + .andCriteria(SearchQuery.not(SearchQuery.flagIsSet(DELETED))) + .andCriteria(SearchQuery.uid(new SearchQuery.UidRange(MessageUid.of(34), MessageUid.of(345)))) + .build()).collectList().block()) + .isEmpty(); + } + + @Test + void searchShouldReturnMailboxEntries() { + MessageUid messageUid = MessageUid.of(1); + insert(messageUid, MAILBOX.getMailboxId(), new Flags()); + MessageUid messageUid2 = MessageUid.of(2); + insert(messageUid2, MAILBOX.getMailboxId(), new Flags()); + MessageUid messageUid3 = MessageUid.of(3); + insert(messageUid3, MAILBOX.getMailboxId(), new Flags(DELETED)); + MessageUid messageUid4 = MessageUid.of(4); + insert(messageUid4, MAILBOX.getMailboxId(), new Flags()); + MessageUid messageUid5 = MessageUid.of(5); + insert(messageUid5, MAILBOX.getMailboxId(), new Flags()); + MessageUid messageUid6 = MessageUid.of(6); + insert(messageUid6, PostgresMailboxId.generate(), new Flags(DELETED)); + + assertThat(testee.search(MAILBOX_SESSION, MAILBOX, + SearchQuery.builder() + .andCriteria(SearchQuery.not(SearchQuery.flagIsSet(DELETED))) + .andCriteria(SearchQuery.uid(new SearchQuery.UidRange(messageUid2, messageUid4))) + .build()).collectList().block()) + .containsOnly(messageUid2, messageUid4); + } + + private void insert(MessageUid messageUid, MailboxId mailboxId, Flags flags) { + PostgresMessageId messageId = new PostgresMessageId.Factory().generate(); + MailboxMessage message = SimpleMailboxMessage.builder() + .messageId(messageId) + .threadId(ThreadId.fromBaseMessageId(messageId)) + .uid(messageUid) + .content(CONTENT_STREAM) + .size(SIZE) + .internalDate(new Date()) + .bodyStartOctet(18) + .flags(flags) + .properties(new PropertyBuilder()) + .mailboxId(mailboxId) + .modseq(ModSeq.of(1)) + .build(); + postgresMessageDAO.insert(message, BLOB_ID).block(); + postgresMailboxMessageDAO.insert(message).block(); + } +} From 60e611587359f52d80cbdf70766b7b30df9956b9 Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Fri, 22 Dec 2023 15:17:28 +0700 Subject: [PATCH 153/341] JAMES-2586 Implement UidSearchOverride for Postgresql --- .../mail/dao/PostgresMailboxMessageDAO.java | 17 ++ .../postgres/search/UidSearchOverride.java | 66 ++++++++ .../search/UidSearchOverrideTest.java | 148 ++++++++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UidSearchOverride.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UidSearchOverrideTest.java diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java index 049cd40f7b5..1c8f032c41f 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java @@ -130,6 +130,23 @@ public Flux listAllMessageUid(PostgresMailboxId mailboxId) { .map(RECORD_TO_MESSAGE_UID_FUNCTION); } + public Flux listUids(PostgresMailboxId mailboxId, MessageRange range) { + if (range.getType() == MessageRange.Type.ALL) { + return listAllMessageUid(mailboxId); + } + return doListUids(mailboxId, range); + } + + private Flux doListUids(PostgresMailboxId mailboxId, MessageRange range) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(MESSAGE_UID) + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(MESSAGE_UID.greaterOrEqual(range.getUidFrom().asLong())) + .and(MESSAGE_UID.lessOrEqual(range.getUidTo().asLong())) + .orderBy(DEFAULT_SORT_ORDER_BY))) + .map(RECORD_TO_MESSAGE_UID_FUNCTION); + } + public Mono deleteByMailboxIdAndMessageUid(PostgresMailboxId mailboxId, MessageUid messageUid) { return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.deleteFrom(TABLE_NAME) .where(MAILBOX_ID.eq(mailboxId.asUuid())) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UidSearchOverride.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UidSearchOverride.java new file mode 100644 index 00000000000..9827bc65f85 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UidSearchOverride.java @@ -0,0 +1,66 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.search; + +import javax.inject.Inject; + +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MessageRange; +import org.apache.james.mailbox.model.SearchQuery; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; +import org.apache.james.mailbox.store.search.ListeningMessageSearchIndex; + +import com.google.common.collect.ImmutableList; + +import reactor.core.publisher.Flux; + +public class UidSearchOverride implements ListeningMessageSearchIndex.SearchOverride { + private final PostgresMailboxMessageDAO dao; + + @Inject + public UidSearchOverride(PostgresMailboxMessageDAO dao) { + this.dao = dao; + } + + @Override + public boolean applicable(SearchQuery searchQuery, MailboxSession session) { + return searchQuery.getCriteria().size() == 1 + && searchQuery.getCriteria().get(0) instanceof SearchQuery.UidCriterion + && searchQuery.getSorts().equals(SearchQuery.DEFAULT_SORTS); + } + + @Override + public Flux search(MailboxSession session, Mailbox mailbox, SearchQuery searchQuery) { + SearchQuery.UidCriterion uidArgument = searchQuery.getCriteria().stream() + .filter(criterion -> criterion instanceof SearchQuery.UidCriterion) + .map(SearchQuery.UidCriterion.class::cast) + .findAny() + .orElseThrow(() -> new RuntimeException("Missing Uid argument")); + + SearchQuery.UidRange[] uidRanges = uidArgument.getOperator().getRange(); + + return Flux.fromIterable(ImmutableList.copyOf(uidRanges)) + .concatMap(range -> dao.listUids((PostgresMailboxId) mailbox.getMailboxId(), + MessageRange.range(range.getLowValue(), range.getHighValue()))); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UidSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UidSearchOverrideTest.java new file mode 100644 index 00000000000..aa7e4a0993d --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UidSearchOverrideTest.java @@ -0,0 +1,148 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.search; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import javax.mail.Flags; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.core.Username; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.MailboxSessionUtil; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.model.ByteContent; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.SearchQuery; +import org.apache.james.mailbox.model.ThreadId; +import org.apache.james.mailbox.model.UidValidity; +import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.apache.james.mailbox.store.mail.model.MailboxMessage; +import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; +import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class UidSearchOverrideTest { + private static final MailboxSession MAILBOX_SESSION = MailboxSessionUtil.create(Username.of("benwa")); + private static final Mailbox MAILBOX = new Mailbox(MailboxPath.inbox(MAILBOX_SESSION), UidValidity.of(12), PostgresMailboxId.generate()); + private static final String BLOB_ID = "abc"; + private static final Charset MESSAGE_CHARSET = StandardCharsets.UTF_8; + private static final String MESSAGE_CONTENT = "Simple message content"; + private static final byte[] MESSAGE_CONTENT_BYTES = MESSAGE_CONTENT.getBytes(MESSAGE_CHARSET); + private static final ByteContent CONTENT_STREAM = new ByteContent(MESSAGE_CONTENT_BYTES); + private final static long SIZE = MESSAGE_CONTENT_BYTES.length; + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); + + private PostgresMailboxMessageDAO postgresMailboxMessageDAO; + private PostgresMessageDAO postgresMessageDAO; + private UidSearchOverride testee; + + @BeforeEach + void setUp() { + postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor()); + postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); + testee = new UidSearchOverride(postgresMailboxMessageDAO); + } + + @Test + void rangeQueryShouldBeApplicable() { + assertThat(testee.applicable( + SearchQuery.builder() + .andCriteria(SearchQuery.uid(new SearchQuery.UidRange(MessageUid.of(4), MessageUid.of(45)))) + .build(), + MAILBOX_SESSION)) + .isTrue(); + } + + @Test + void sizeQueryShouldNotBeApplicable() { + assertThat(testee.applicable( + SearchQuery.builder() + .andCriteria(SearchQuery.sizeEquals(12)) + .build(), + MAILBOX_SESSION)) + .isFalse(); + } + + @Test + void searchShouldReturnEmptyByDefault() { + assertThat(testee.search(MAILBOX_SESSION, MAILBOX, + SearchQuery.builder() + .andCriteria(SearchQuery.uid(new SearchQuery.UidRange(MessageUid.of(34), MessageUid.of(345)))) + .build()).collectList().block()) + .isEmpty(); + } + + @Test + void searchShouldReturnMailboxEntries() { + MessageUid messageUid = MessageUid.of(1); + insert(messageUid, MAILBOX.getMailboxId()); + MessageUid messageUid2 = MessageUid.of(2); + insert(messageUid2, MAILBOX.getMailboxId()); + MessageUid messageUid3 = MessageUid.of(3); + insert(messageUid3, MAILBOX.getMailboxId()); + MessageUid messageUid4 = MessageUid.of(4); + insert(messageUid4, MAILBOX.getMailboxId()); + MessageUid messageUid5 = MessageUid.of(5); + insert(messageUid5, MAILBOX.getMailboxId()); + MessageUid messageUid6 = MessageUid.of(6); + insert(messageUid6, PostgresMailboxId.generate()); + + assertThat(testee.search(MAILBOX_SESSION, MAILBOX, + SearchQuery.builder() + .andCriteria(SearchQuery.uid(new SearchQuery.UidRange(messageUid2, messageUid4))) + .build()).collectList().block()) + .containsOnly(messageUid2, messageUid3, messageUid4); + } + + private void insert(MessageUid messageUid, MailboxId mailboxId) { + PostgresMessageId messageId = new PostgresMessageId.Factory().generate(); + MailboxMessage message = SimpleMailboxMessage.builder() + .messageId(messageId) + .threadId(ThreadId.fromBaseMessageId(messageId)) + .uid(messageUid) + .content(CONTENT_STREAM) + .size(SIZE) + .internalDate(new Date()) + .bodyStartOctet(18) + .flags(new Flags()) + .properties(new PropertyBuilder()) + .mailboxId(mailboxId) + .modseq(ModSeq.of(1)) + .build(); + postgresMessageDAO.insert(message, BLOB_ID).block(); + postgresMailboxMessageDAO.insert(message).block(); + } +} From aacc42ed8b28d342cee757277b0774805b829b1a Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Fri, 22 Dec 2023 16:13:43 +0700 Subject: [PATCH 154/341] JAMES-2586 Implement UnseenSearchOverrideTest for Postgresql --- .../mail/dao/PostgresMailboxMessageDAO.java | 40 ++++ .../postgres/search/UnseenSearchOverride.java | 93 ++++++++ .../search/UnseenSearchOverrideTest.java | 216 ++++++++++++++++++ 3 files changed, 349 insertions(+) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverride.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverrideTest.java diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java index 1c8f032c41f..61ff87a7641 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java @@ -118,6 +118,46 @@ public Mono findFirstUnseenMessageUid(PostgresMailboxId mailboxId) { .map(RECORD_TO_MESSAGE_UID_FUNCTION); } + public Flux listUnseen(PostgresMailboxId mailboxId) { + return postgresExecutor.executeRows(dslContext -> Flux.from(selectMessageUidByMailboxIdAndExtraConditionQuery(mailboxId, + IS_SEEN.eq(false), Limit.unlimited(), dslContext))) + .map(RECORD_TO_MESSAGE_UID_FUNCTION); + } + + public Flux listUnseen(PostgresMailboxId mailboxId, MessageRange range) { + switch (range.getType()) { + case ALL: + return listUnseen(mailboxId); + case FROM: + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(MESSAGE_UID) + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(IS_SEEN.eq(false)) + .and(MESSAGE_UID.greaterOrEqual(range.getUidFrom().asLong())) + .orderBy(DEFAULT_SORT_ORDER_BY))) + .map(RECORD_TO_MESSAGE_UID_FUNCTION); + case RANGE: + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(MESSAGE_UID) + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(IS_SEEN.eq(false)) + .and(MESSAGE_UID.greaterOrEqual(range.getUidFrom().asLong())) + .and(MESSAGE_UID.lessOrEqual(range.getUidTo().asLong())) + .orderBy(DEFAULT_SORT_ORDER_BY))) + .map(RECORD_TO_MESSAGE_UID_FUNCTION); + case ONE: + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(MESSAGE_UID) + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(IS_SEEN.eq(false)) + .and(MESSAGE_UID.eq(range.getUidFrom().asLong())) + .orderBy(DEFAULT_SORT_ORDER_BY))) + .map(RECORD_TO_MESSAGE_UID_FUNCTION); + default: + throw new RuntimeException("Unsupported range type " + range.getType()); + } + } + public Flux findAllRecentMessageUid(PostgresMailboxId mailboxId) { return postgresExecutor.executeRows(dslContext -> Flux.from(selectMessageUidByMailboxIdAndExtraConditionQuery(mailboxId, IS_RECENT.eq(true), Limit.unlimited(), dslContext))) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverride.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverride.java new file mode 100644 index 00000000000..2bef17a9f9b --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverride.java @@ -0,0 +1,93 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.search; + +import java.util.Optional; + +import javax.inject.Inject; +import javax.mail.Flags; + +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MessageRange; +import org.apache.james.mailbox.model.SearchQuery; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; +import org.apache.james.mailbox.store.search.ListeningMessageSearchIndex; + +import com.google.common.collect.ImmutableList; + +import reactor.core.publisher.Flux; + +public class UnseenSearchOverride implements ListeningMessageSearchIndex.SearchOverride { + private final PostgresMailboxMessageDAO dao; + + @Inject + public UnseenSearchOverride(PostgresMailboxMessageDAO dao) { + this.dao = dao; + } + + @Override + public boolean applicable(SearchQuery searchQuery, MailboxSession session) { + return isUnseenWithAll(searchQuery) + || isNotSeenWithAll(searchQuery); + } + + private boolean isUnseenWithAll(SearchQuery searchQuery) { + return searchQuery.getCriteria().contains(SearchQuery.flagIsUnSet(Flags.Flag.SEEN)) + && allMessages(searchQuery) + && searchQuery.getSorts().equals(SearchQuery.DEFAULT_SORTS); + } + + private boolean isNotSeenWithAll(SearchQuery searchQuery) { + return searchQuery.getCriteria().contains(SearchQuery.not(SearchQuery.flagIsSet(Flags.Flag.SEEN))) + && allMessages(searchQuery) + && searchQuery.getSorts().equals(SearchQuery.DEFAULT_SORTS); + } + + private boolean allMessages(SearchQuery searchQuery) { + if (searchQuery.getCriteria().size() == 1) { + // Only the unseen critrion + return true; + } + if (searchQuery.getCriteria().size() == 2) { + return searchQuery.getCriteria().stream() + .anyMatch(criterion -> criterion instanceof SearchQuery.UidCriterion) || + searchQuery.getCriteria().stream() + .anyMatch(criterion -> criterion instanceof SearchQuery.AllCriterion); + } + return false; + } + + @Override + public Flux search(MailboxSession session, Mailbox mailbox, SearchQuery searchQuery) { + final Optional maybeUidCriterion = searchQuery.getCriteria().stream() + .filter(criterion -> criterion instanceof SearchQuery.UidCriterion) + .map(SearchQuery.UidCriterion.class::cast) + .findFirst(); + + return maybeUidCriterion + .map(uidCriterion -> Flux.fromIterable(ImmutableList.copyOf(uidCriterion.getOperator().getRange())) + .concatMap(range -> dao.listUnseen((PostgresMailboxId) mailbox.getMailboxId(), + MessageRange.range(range.getLowValue(), range.getHighValue())))) + .orElseGet(() -> dao.listUnseen((PostgresMailboxId) mailbox.getMailboxId())); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverrideTest.java new file mode 100644 index 00000000000..51d8825096e --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverrideTest.java @@ -0,0 +1,216 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.search; + +import static javax.mail.Flags.Flag.SEEN; +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import javax.mail.Flags; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.core.Username; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.MailboxSessionUtil; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.model.ByteContent; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.SearchQuery; +import org.apache.james.mailbox.model.ThreadId; +import org.apache.james.mailbox.model.UidValidity; +import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.apache.james.mailbox.store.mail.model.MailboxMessage; +import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; +import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class UnseenSearchOverrideTest { + private static final MailboxSession MAILBOX_SESSION = MailboxSessionUtil.create(Username.of("benwa")); + private static final Mailbox MAILBOX = new Mailbox(MailboxPath.inbox(MAILBOX_SESSION), UidValidity.of(12), PostgresMailboxId.generate()); + private static final String BLOB_ID = "abc"; + private static final Charset MESSAGE_CHARSET = StandardCharsets.UTF_8; + private static final String MESSAGE_CONTENT = "Simple message content"; + private static final byte[] MESSAGE_CONTENT_BYTES = MESSAGE_CONTENT.getBytes(MESSAGE_CHARSET); + private static final ByteContent CONTENT_STREAM = new ByteContent(MESSAGE_CONTENT_BYTES); + private final static long SIZE = MESSAGE_CONTENT_BYTES.length; + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); + + private PostgresMailboxMessageDAO postgresMailboxMessageDAO; + private PostgresMessageDAO postgresMessageDAO; + private UnseenSearchOverride testee; + + @BeforeEach + void setUp() { + postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor()); + postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); + testee = new UnseenSearchOverride(postgresMailboxMessageDAO); + } + + @Test + void unseenQueryShouldBeApplicable() { + assertThat(testee.applicable( + SearchQuery.builder() + .andCriteria(SearchQuery.flagIsUnSet(SEEN)) + .build(), + MAILBOX_SESSION)) + .isTrue(); + } + + @Test + void notSeenQueryShouldBeApplicable() { + assertThat(testee.applicable( + SearchQuery.builder() + .andCriteria(SearchQuery.not(SearchQuery.flagIsSet(SEEN))) + .build(), + MAILBOX_SESSION)) + .isTrue(); + } + + @Test + void unseenAndAllQueryShouldBeApplicable() { + assertThat(testee.applicable( + SearchQuery.builder() + .andCriteria(SearchQuery.flagIsUnSet(SEEN)) + .andCriteria(SearchQuery.all()) + .build(), + MAILBOX_SESSION)) + .isTrue(); + } + + @Test + void notSeenAndAllQueryShouldBeApplicable() { + assertThat(testee.applicable( + SearchQuery.builder() + .andCriteria(SearchQuery.not(SearchQuery.flagIsSet(SEEN))) + .andCriteria(SearchQuery.all()) + .build(), + MAILBOX_SESSION)) + .isTrue(); + } + + @Test + void unseenAndFromOneQueryShouldBeApplicable() { + assertThat(testee.applicable( + SearchQuery.builder() + .andCriteria(SearchQuery.flagIsUnSet(SEEN)) + .andCriteria(SearchQuery.uid(new SearchQuery.UidRange(MessageUid.MIN_VALUE, MessageUid.MAX_VALUE))) + .build(), + MAILBOX_SESSION)) + .isTrue(); + } + + @Test + void notSeenFromOneQueryShouldBeApplicable() { + assertThat(testee.applicable( + SearchQuery.builder() + .andCriteria(SearchQuery.not(SearchQuery.flagIsSet(SEEN))) + .andCriteria(SearchQuery.uid(new SearchQuery.UidRange(MessageUid.MIN_VALUE, MessageUid.MAX_VALUE))) + .build(), + MAILBOX_SESSION)) + .isTrue(); + } + + @Test + void sizeQueryShouldNotBeApplicable() { + assertThat(testee.applicable( + SearchQuery.builder() + .andCriteria(SearchQuery.sizeEquals(12)) + .build(), + MAILBOX_SESSION)) + .isFalse(); + } + + @Test + void searchShouldReturnEmptyByDefault() { + assertThat(testee.search(MAILBOX_SESSION, MAILBOX, + SearchQuery.builder() + .andCriteria(SearchQuery.flagIsUnSet(SEEN)) + .build()).collectList().block()) + .isEmpty(); + } + + @Test + void searchShouldReturnMailboxEntries() { + MessageUid messageUid = MessageUid.of(1); + insert(messageUid, MAILBOX.getMailboxId(), new Flags()); + MessageUid messageUid2 = MessageUid.of(2); + insert(messageUid2, MAILBOX.getMailboxId(), new Flags()); + MessageUid messageUid3 = MessageUid.of(3); + insert(messageUid3, MAILBOX.getMailboxId(), new Flags(SEEN)); + + assertThat(testee.search(MAILBOX_SESSION, MAILBOX, + SearchQuery.builder() + .andCriteria(SearchQuery.flagIsUnSet(SEEN)) + .build()).collectList().block()) + .containsOnly(messageUid, messageUid2); + } + + @Test + void searchShouldSupportRanges() { + MessageUid messageUid = MessageUid.of(1); + insert(messageUid, MAILBOX.getMailboxId(), new Flags()); + MessageUid messageUid2 = MessageUid.of(2); + insert(messageUid2, MAILBOX.getMailboxId(), new Flags()); + MessageUid messageUid3 = MessageUid.of(3); + insert(messageUid3, MAILBOX.getMailboxId(), new Flags(SEEN)); + MessageUid messageUid4 = MessageUid.of(4); + insert(messageUid4, MAILBOX.getMailboxId(), new Flags()); + + assertThat(testee.search(MAILBOX_SESSION, MAILBOX, + SearchQuery.builder() + .andCriteria(SearchQuery.flagIsUnSet(SEEN)) + .andCriterion(SearchQuery.uid(new SearchQuery.UidRange(MessageUid.of(2), MessageUid.of(4)))) + .build()).collectList().block()) + .containsOnly(messageUid2, messageUid4); + } + + private void insert(MessageUid messageUid, MailboxId mailboxId, Flags flags) { + PostgresMessageId messageId = new PostgresMessageId.Factory().generate(); + MailboxMessage message = SimpleMailboxMessage.builder() + .messageId(messageId) + .threadId(ThreadId.fromBaseMessageId(messageId)) + .uid(messageUid) + .content(CONTENT_STREAM) + .size(SIZE) + .internalDate(new Date()) + .bodyStartOctet(18) + .flags(flags) + .properties(new PropertyBuilder()) + .mailboxId(mailboxId) + .modseq(ModSeq.of(1)) + .build(); + postgresMessageDAO.insert(message, BLOB_ID).block(); + postgresMailboxMessageDAO.insert(message).block(); + } +} From 8eee5a8c9e6328560bb0c1b79b0f861ba4d3e1fb Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Fri, 22 Dec 2023 16:14:50 +0700 Subject: [PATCH 155/341] JAMES-2586 Correct search overrides documentation in opensearch.properties --- .../opensearch.properties | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/server/apps/postgres-app/sample-configuration-distributed/opensearch.properties b/server/apps/postgres-app/sample-configuration-distributed/opensearch.properties index 7b48defef84..df261c5dee6 100644 --- a/server/apps/postgres-app/sample-configuration-distributed/opensearch.properties +++ b/server/apps/postgres-app/sample-configuration-distributed/opensearch.properties @@ -80,23 +80,21 @@ opensearch.indexAttachments=false # are simple enough to be resolved against Cassandra. # # Possible values are: -# - `org.apache.james.mailbox.cassandra.search.AllSearchOverride` Some IMAP clients uses SEARCH ALL to fully list messages in +# - `org.apache.james.mailbox.postgres.search.AllSearchOverride` Some IMAP clients uses SEARCH ALL to fully list messages in # a mailbox and detect deletions. This is typically done by clients not supporting QRESYNC and from an IMAP perspective # is considered an optimisation as less data is transmitted compared to a FETCH command. Resolving such requests against -# Cassandra is enabled by this search override and likely desirable. -# - `org.apache.james.mailbox.cassandra.search.UidSearchOverride`. Same as above but restricted by ranges. -# - `org.apache.james.mailbox.cassandra.search.DeletedSearchOverride`. Find deleted messages by looking up in the relevant Cassandra +# Postgresql is enabled by this search override and likely desirable. +# - `org.apache.james.mailbox.postgres.search.UidSearchOverride`. Same as above but restricted by ranges. +# - `org.apache.james.mailbox.postgres.search.DeletedSearchOverride`. Find deleted messages by looking up in the relevant Postgresql # table. -# - `org.apache.james.mailbox.cassandra.search.DeletedWithRangeSearchOverride`. Same as above but limited by ranges. -# - `org.apache.james.mailbox.cassandra.search.NotDeletedWithRangeSearchOverride`. List non deleted messages in a given range. +# - `org.apache.james.mailbox.postgres.search.DeletedWithRangeSearchOverride`. Same as above but limited by ranges. +# - `org.apache.james.mailbox.postgres.search.NotDeletedWithRangeSearchOverride`. List non deleted messages in a given range. # Lists all messages and filters out deleted message thus this is based on the following heuristic: most messages are not marked as deleted. -# - `org.apache.james.mailbox.cassandra.search.UnseenSearchOverride`. List unseen messages in the corresponding cassandra projection. +# - `org.apache.james.mailbox.postgres.search.UnseenSearchOverride`. List unseen messages in the corresponding Postgresql index. # # Please note that custom overrides can be defined here. # -# Note: Search overrides not implemented yet for postgresql. -# -# opensearch.search.overrides=org.apache.james.mailbox.cassandra.search.AllSearchOverride,org.apache.james.mailbox.cassandra.search.DeletedSearchOverride, org.apache.james.mailbox.cassandra.search.DeletedWithRangeSearchOverride,org.apache.james.mailbox.cassandra.search.NotDeletedWithRangeSearchOverride,org.apache.james.mailbox.cassandra.search.UidSearchOverride,org.apache.james.mailbox.cassandra.search.UnseenSearchOverride +# opensearch.search.overrides=org.apache.james.mailbox.postgres.search.AllSearchOverride,org.apache.james.mailbox.postgres.search.DeletedSearchOverride, org.apache.james.mailbox.postgres.search.DeletedWithRangeSearchOverride,org.apache.james.mailbox.postgres.search.NotDeletedWithRangeSearchOverride,org.apache.james.mailbox.postgres.search.UidSearchOverride,org.apache.james.mailbox.postgres.search.UnseenSearchOverride # Optional. Default is `false` # When set to true, James will attempt to reindex from the indexed message when moved. If the message is not found, it will fall back to the old behavior (The message will be indexed from the blobStore source) From 5770faaab44fc329051b7b792365b96ab8a26e48 Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Fri, 22 Dec 2023 16:33:34 +0700 Subject: [PATCH 156/341] JAMES-2586 Refactor search overrides tests for postgresql --- .../search/AllSearchOverrideTest.java | 86 ++---------- .../search/DeletedSearchOverrideTest.java | 87 ++----------- .../DeletedWithRangeSearchOverrideTest.java | 123 +++--------------- ...NotDeletedWithRangeSearchOverrideTest.java | 48 ++----- .../search/SearchOverrideFixture.java | 71 ++++++++++ .../search/UidSearchOverrideTest.java | 48 ++----- .../search/UnseenSearchOverrideTest.java | 47 +------ 7 files changed, 138 insertions(+), 372 deletions(-) create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/SearchOverrideFixture.java diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/AllSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/AllSearchOverrideTest.java index a96348eb563..821c1ce05e8 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/AllSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/AllSearchOverrideTest.java @@ -19,48 +19,27 @@ package org.apache.james.mailbox.postgres.search; +import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.BLOB_ID; +import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.MAILBOX; +import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.MAILBOX_SESSION; import static org.assertj.core.api.Assertions.assertThat; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; -import java.util.Date; - import javax.mail.Flags; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.core.Username; -import org.apache.james.mailbox.MailboxSession; -import org.apache.james.mailbox.MailboxSessionUtil; import org.apache.james.mailbox.MessageUid; -import org.apache.james.mailbox.ModSeq; -import org.apache.james.mailbox.model.ByteContent; -import org.apache.james.mailbox.model.Mailbox; -import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.SearchQuery; -import org.apache.james.mailbox.model.ThreadId; -import org.apache.james.mailbox.model.UidValidity; import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; import org.apache.james.mailbox.postgres.PostgresMailboxId; -import org.apache.james.mailbox.postgres.PostgresMessageId; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.store.mail.model.MailboxMessage; -import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; -import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; public class AllSearchOverrideTest { - private static final MailboxSession MAILBOX_SESSION = MailboxSessionUtil.create(Username.of("benwa")); - private static final Mailbox MAILBOX = new Mailbox(MailboxPath.inbox(MAILBOX_SESSION), UidValidity.of(12), PostgresMailboxId.generate()); - private static final String BLOB_ID = "abc"; - private static final Charset MESSAGE_CHARSET = StandardCharsets.UTF_8; - private static final String MESSAGE_CONTENT = "Simple message content"; - private static final byte[] MESSAGE_CONTENT_BYTES = MESSAGE_CONTENT.getBytes(MESSAGE_CHARSET); - private static final ByteContent CONTENT_STREAM = new ByteContent(MESSAGE_CONTENT_BYTES); - private final static long SIZE = MESSAGE_CONTENT_BYTES.length; - @RegisterExtension static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); @@ -126,58 +105,13 @@ void searchShouldReturnEmptyByDefault() { @Test void searchShouldReturnMailboxEntries() { MessageUid messageUid = MessageUid.of(1); - PostgresMessageId messageId = new PostgresMessageId.Factory().generate(); - MailboxMessage message1 = SimpleMailboxMessage.builder() - .messageId(messageId) - .threadId(ThreadId.fromBaseMessageId(messageId)) - .uid(messageUid) - .content(CONTENT_STREAM) - .size(SIZE) - .internalDate(new Date()) - .bodyStartOctet(18) - .flags(new Flags()) - .properties(new PropertyBuilder()) - .mailboxId(MAILBOX.getMailboxId()) - .modseq(ModSeq.of(1)) - .build(); - postgresMessageDAO.insert(message1, BLOB_ID).block(); - postgresMailboxMessageDAO.insert(message1).block(); + insert(messageUid, MAILBOX.getMailboxId()); MessageUid messageUid2 = MessageUid.of(2); - PostgresMessageId messageId2 = new PostgresMessageId.Factory().generate(); - MailboxMessage message2 = SimpleMailboxMessage.builder() - .messageId(messageId2) - .threadId(ThreadId.fromBaseMessageId(messageId2)) - .uid(messageUid2) - .content(CONTENT_STREAM) - .size(SIZE) - .internalDate(new Date()) - .bodyStartOctet(18) - .flags(new Flags()) - .properties(new PropertyBuilder()) - .mailboxId(MAILBOX.getMailboxId()) - .modseq(ModSeq.of(1)) - .build(); - postgresMessageDAO.insert(message2, BLOB_ID).block(); - postgresMailboxMessageDAO.insert(message2).block(); + insert(messageUid2, MAILBOX.getMailboxId()); MessageUid messageUid3 = MessageUid.of(3); - PostgresMessageId messageId3 = new PostgresMessageId.Factory().generate(); - MailboxMessage message3 = SimpleMailboxMessage.builder() - .messageId(messageId3) - .threadId(ThreadId.fromBaseMessageId(messageId3)) - .uid(messageUid3) - .content(CONTENT_STREAM) - .size(SIZE) - .internalDate(new Date()) - .bodyStartOctet(18) - .flags(new Flags()) - .properties(new PropertyBuilder()) - .mailboxId(PostgresMailboxId.generate()) - .modseq(ModSeq.of(1)) - .build(); - postgresMessageDAO.insert(message3, BLOB_ID).block(); - postgresMailboxMessageDAO.insert(message3).block(); + insert(messageUid3, PostgresMailboxId.generate()); assertThat(testee.search(MAILBOX_SESSION, MAILBOX, SearchQuery.builder() @@ -185,4 +119,10 @@ void searchShouldReturnMailboxEntries() { .build()).collectList().block()) .containsOnly(messageUid, messageUid2); } + + private void insert(MessageUid messageUid, MailboxId mailboxId) { + MailboxMessage message = SearchOverrideFixture.createMessage(messageUid, mailboxId, new Flags()); + postgresMessageDAO.insert(message, BLOB_ID).block(); + postgresMailboxMessageDAO.insert(message).block(); + } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverrideTest.java index 29e68fdfd54..0bd37d2dc96 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverrideTest.java @@ -21,48 +21,26 @@ import static javax.mail.Flags.Flag.DELETED; import static javax.mail.Flags.Flag.SEEN; +import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.BLOB_ID; +import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.MAILBOX; +import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.MAILBOX_SESSION; import static org.assertj.core.api.Assertions.assertThat; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; -import java.util.Date; - import javax.mail.Flags; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.core.Username; -import org.apache.james.mailbox.MailboxSession; -import org.apache.james.mailbox.MailboxSessionUtil; import org.apache.james.mailbox.MessageUid; -import org.apache.james.mailbox.ModSeq; -import org.apache.james.mailbox.model.ByteContent; -import org.apache.james.mailbox.model.Mailbox; -import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.SearchQuery; -import org.apache.james.mailbox.model.ThreadId; -import org.apache.james.mailbox.model.UidValidity; import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; -import org.apache.james.mailbox.postgres.PostgresMailboxId; -import org.apache.james.mailbox.postgres.PostgresMessageId; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.store.mail.model.MailboxMessage; -import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; -import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; public class DeletedSearchOverrideTest { - private static final MailboxSession MAILBOX_SESSION = MailboxSessionUtil.create(Username.of("benwa")); - private static final Mailbox MAILBOX = new Mailbox(MailboxPath.inbox(MAILBOX_SESSION), UidValidity.of(12), PostgresMailboxId.generate()); - private static final String BLOB_ID = "abc"; - private static final Charset MESSAGE_CHARSET = StandardCharsets.UTF_8; - private static final String MESSAGE_CONTENT = "Simple message content"; - private static final byte[] MESSAGE_CONTENT_BYTES = MESSAGE_CONTENT.getBytes(MESSAGE_CHARSET); - private static final ByteContent CONTENT_STREAM = new ByteContent(MESSAGE_CONTENT_BYTES); - private final static long SIZE = MESSAGE_CONTENT_BYTES.length; - @RegisterExtension static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); @@ -109,58 +87,13 @@ void searchShouldReturnEmptyByDefault() { @Test void searchShouldReturnMailboxEntries() { MessageUid messageUid = MessageUid.of(1); - PostgresMessageId messageId = new PostgresMessageId.Factory().generate(); - MailboxMessage message1 = SimpleMailboxMessage.builder() - .messageId(messageId) - .threadId(ThreadId.fromBaseMessageId(messageId)) - .uid(messageUid) - .content(CONTENT_STREAM) - .size(SIZE) - .internalDate(new Date()) - .bodyStartOctet(18) - .flags(new Flags(DELETED)) - .properties(new PropertyBuilder()) - .mailboxId(MAILBOX.getMailboxId()) - .modseq(ModSeq.of(1)) - .build(); - postgresMessageDAO.insert(message1, BLOB_ID).block(); - postgresMailboxMessageDAO.insert(message1).block(); + insert(messageUid, MAILBOX.getMailboxId(), new Flags(DELETED)); MessageUid messageUid2 = MessageUid.of(2); - PostgresMessageId messageId2 = new PostgresMessageId.Factory().generate(); - MailboxMessage message2 = SimpleMailboxMessage.builder() - .messageId(messageId2) - .threadId(ThreadId.fromBaseMessageId(messageId2)) - .uid(messageUid2) - .content(CONTENT_STREAM) - .size(SIZE) - .internalDate(new Date()) - .bodyStartOctet(18) - .flags(new Flags(DELETED)) - .properties(new PropertyBuilder()) - .mailboxId(MAILBOX.getMailboxId()) - .modseq(ModSeq.of(1)) - .build(); - postgresMessageDAO.insert(message2, BLOB_ID).block(); - postgresMailboxMessageDAO.insert(message2).block(); + insert(messageUid2, MAILBOX.getMailboxId(), new Flags(DELETED)); MessageUid messageUid3 = MessageUid.of(3); - PostgresMessageId messageId3 = new PostgresMessageId.Factory().generate(); - MailboxMessage message3 = SimpleMailboxMessage.builder() - .messageId(messageId3) - .threadId(ThreadId.fromBaseMessageId(messageId3)) - .uid(messageUid3) - .content(CONTENT_STREAM) - .size(SIZE) - .internalDate(new Date()) - .bodyStartOctet(18) - .flags(new Flags()) - .properties(new PropertyBuilder()) - .mailboxId(MAILBOX.getMailboxId()) - .modseq(ModSeq.of(1)) - .build(); - postgresMessageDAO.insert(message3, BLOB_ID).block(); - postgresMailboxMessageDAO.insert(message3).block(); + insert(messageUid3, MAILBOX.getMailboxId(), new Flags()); assertThat(testee.search(MAILBOX_SESSION, MAILBOX, SearchQuery.builder() @@ -168,4 +101,10 @@ void searchShouldReturnMailboxEntries() { .build()).collectList().block()) .containsOnly(messageUid, messageUid2); } + + private void insert(MessageUid messageUid, MailboxId mailboxId, Flags flags) { + MailboxMessage message = SearchOverrideFixture.createMessage(messageUid, mailboxId, flags); + postgresMessageDAO.insert(message, BLOB_ID).block(); + postgresMailboxMessageDAO.insert(message).block(); + } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverrideTest.java index d7df1955bcf..74d97339c1a 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverrideTest.java @@ -21,48 +21,26 @@ import static javax.mail.Flags.Flag.DELETED; import static javax.mail.Flags.Flag.SEEN; +import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.BLOB_ID; +import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.MAILBOX; +import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.MAILBOX_SESSION; import static org.assertj.core.api.Assertions.assertThat; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; -import java.util.Date; - import javax.mail.Flags; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.core.Username; -import org.apache.james.mailbox.MailboxSession; -import org.apache.james.mailbox.MailboxSessionUtil; import org.apache.james.mailbox.MessageUid; -import org.apache.james.mailbox.ModSeq; -import org.apache.james.mailbox.model.ByteContent; -import org.apache.james.mailbox.model.Mailbox; -import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.SearchQuery; -import org.apache.james.mailbox.model.ThreadId; -import org.apache.james.mailbox.model.UidValidity; import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; -import org.apache.james.mailbox.postgres.PostgresMailboxId; -import org.apache.james.mailbox.postgres.PostgresMessageId; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.store.mail.model.MailboxMessage; -import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; -import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; public class DeletedWithRangeSearchOverrideTest { - private static final MailboxSession MAILBOX_SESSION = MailboxSessionUtil.create(Username.of("benwa")); - private static final Mailbox MAILBOX = new Mailbox(MailboxPath.inbox(MAILBOX_SESSION), UidValidity.of(12), PostgresMailboxId.generate()); - private static final String BLOB_ID = "abc"; - private static final Charset MESSAGE_CHARSET = StandardCharsets.UTF_8; - private static final String MESSAGE_CONTENT = "Simple message content"; - private static final byte[] MESSAGE_CONTENT_BYTES = MESSAGE_CONTENT.getBytes(MESSAGE_CHARSET); - private static final ByteContent CONTENT_STREAM = new ByteContent(MESSAGE_CONTENT_BYTES); - private final static long SIZE = MESSAGE_CONTENT_BYTES.length; - @RegisterExtension static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); @@ -121,100 +99,31 @@ void searchShouldReturnEmptyByDefault() { @Test void searchShouldReturnMailboxEntries() { MessageUid messageUid = MessageUid.of(1); - PostgresMessageId messageId = new PostgresMessageId.Factory().generate(); - MailboxMessage message1 = SimpleMailboxMessage.builder() - .messageId(messageId) - .threadId(ThreadId.fromBaseMessageId(messageId)) - .uid(messageUid) - .content(CONTENT_STREAM) - .size(SIZE) - .internalDate(new Date()) - .bodyStartOctet(18) - .flags(new Flags(DELETED)) - .properties(new PropertyBuilder()) - .mailboxId(MAILBOX.getMailboxId()) - .modseq(ModSeq.of(1)) - .build(); - postgresMessageDAO.insert(message1, BLOB_ID).block(); - postgresMailboxMessageDAO.insert(message1).block(); + insert(messageUid, MAILBOX.getMailboxId(), new Flags(DELETED)); MessageUid messageUid2 = MessageUid.of(2); - PostgresMessageId messageId2 = new PostgresMessageId.Factory().generate(); - MailboxMessage message2 = SimpleMailboxMessage.builder() - .messageId(messageId2) - .threadId(ThreadId.fromBaseMessageId(messageId2)) - .uid(messageUid2) - .content(CONTENT_STREAM) - .size(SIZE) - .internalDate(new Date()) - .bodyStartOctet(18) - .flags(new Flags(DELETED)) - .properties(new PropertyBuilder()) - .mailboxId(MAILBOX.getMailboxId()) - .modseq(ModSeq.of(1)) - .build(); - postgresMessageDAO.insert(message2, BLOB_ID).block(); - postgresMailboxMessageDAO.insert(message2).block(); + insert(messageUid2, MAILBOX.getMailboxId(), new Flags(DELETED)); MessageUid messageUid3 = MessageUid.of(3); - PostgresMessageId messageId3 = new PostgresMessageId.Factory().generate(); - MailboxMessage message3 = SimpleMailboxMessage.builder() - .messageId(messageId3) - .threadId(ThreadId.fromBaseMessageId(messageId3)) - .uid(messageUid3) - .content(CONTENT_STREAM) - .size(SIZE) - .internalDate(new Date()) - .bodyStartOctet(18) - .flags(new Flags(DELETED)) - .properties(new PropertyBuilder()) - .mailboxId(MAILBOX.getMailboxId()) - .modseq(ModSeq.of(1)) - .build(); - postgresMessageDAO.insert(message3, BLOB_ID).block(); - postgresMailboxMessageDAO.insert(message3).block(); + insert(messageUid3, MAILBOX.getMailboxId(), new Flags()); MessageUid messageUid4 = MessageUid.of(4); - PostgresMessageId messageId4 = new PostgresMessageId.Factory().generate(); - MailboxMessage message4 = SimpleMailboxMessage.builder() - .messageId(messageId4) - .threadId(ThreadId.fromBaseMessageId(messageId4)) - .uid(messageUid4) - .content(CONTENT_STREAM) - .size(SIZE) - .internalDate(new Date()) - .bodyStartOctet(18) - .flags(new Flags(DELETED)) - .properties(new PropertyBuilder()) - .mailboxId(MAILBOX.getMailboxId()) - .modseq(ModSeq.of(1)) - .build(); - postgresMessageDAO.insert(message4, BLOB_ID).block(); - postgresMailboxMessageDAO.insert(message4).block(); + insert(messageUid4, MAILBOX.getMailboxId(), new Flags(DELETED)); MessageUid messageUid5 = MessageUid.of(5); - PostgresMessageId messageId5 = new PostgresMessageId.Factory().generate(); - MailboxMessage message5 = SimpleMailboxMessage.builder() - .messageId(messageId5) - .threadId(ThreadId.fromBaseMessageId(messageId5)) - .uid(messageUid5) - .content(CONTENT_STREAM) - .size(SIZE) - .internalDate(new Date()) - .bodyStartOctet(18) - .flags(new Flags(DELETED)) - .properties(new PropertyBuilder()) - .mailboxId(MAILBOX.getMailboxId()) - .modseq(ModSeq.of(1)) - .build(); - postgresMessageDAO.insert(message5, BLOB_ID).block(); - postgresMailboxMessageDAO.insert(message5).block(); + insert(messageUid5, MAILBOX.getMailboxId(), new Flags(DELETED)); assertThat(testee.search(MAILBOX_SESSION, MAILBOX, SearchQuery.builder() .andCriteria(SearchQuery.flagIsSet(DELETED)) .andCriteria(SearchQuery.uid(new SearchQuery.UidRange(messageUid2, messageUid4))) .build()).collectList().block()) - .containsOnly(messageUid2, messageUid3, messageUid4); + .containsOnly(messageUid2, messageUid4); + } + + private void insert(MessageUid messageUid, MailboxId mailboxId, Flags flags) { + MailboxMessage message = SearchOverrideFixture.createMessage(messageUid, mailboxId, flags); + postgresMessageDAO.insert(message, BLOB_ID).block(); + postgresMailboxMessageDAO.insert(message).block(); } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverrideTest.java index 7ee4d9f9cc5..dcbdb7401cc 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverrideTest.java @@ -20,49 +20,27 @@ package org.apache.james.mailbox.postgres.search; import static javax.mail.Flags.Flag.DELETED; +import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.BLOB_ID; +import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.MAILBOX; +import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.MAILBOX_SESSION; import static org.assertj.core.api.Assertions.assertThat; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; -import java.util.Date; - import javax.mail.Flags; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.core.Username; -import org.apache.james.mailbox.MailboxSession; -import org.apache.james.mailbox.MailboxSessionUtil; import org.apache.james.mailbox.MessageUid; -import org.apache.james.mailbox.ModSeq; -import org.apache.james.mailbox.model.ByteContent; -import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MailboxId; -import org.apache.james.mailbox.model.MailboxPath; import org.apache.james.mailbox.model.SearchQuery; -import org.apache.james.mailbox.model.ThreadId; -import org.apache.james.mailbox.model.UidValidity; import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; import org.apache.james.mailbox.postgres.PostgresMailboxId; -import org.apache.james.mailbox.postgres.PostgresMessageId; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.store.mail.model.MailboxMessage; -import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; -import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; public class NotDeletedWithRangeSearchOverrideTest { - private static final MailboxSession MAILBOX_SESSION = MailboxSessionUtil.create(Username.of("benwa")); - private static final Mailbox MAILBOX = new Mailbox(MailboxPath.inbox(MAILBOX_SESSION), UidValidity.of(12), PostgresMailboxId.generate()); - private static final String BLOB_ID = "abc"; - private static final Charset MESSAGE_CHARSET = StandardCharsets.UTF_8; - private static final String MESSAGE_CONTENT = "Simple message content"; - private static final byte[] MESSAGE_CONTENT_BYTES = MESSAGE_CONTENT.getBytes(MESSAGE_CHARSET); - private static final ByteContent CONTENT_STREAM = new ByteContent(MESSAGE_CONTENT_BYTES); - private final static long SIZE = MESSAGE_CONTENT_BYTES.length; - @RegisterExtension static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); @@ -123,14 +101,19 @@ void searchShouldReturnEmptyByDefault() { void searchShouldReturnMailboxEntries() { MessageUid messageUid = MessageUid.of(1); insert(messageUid, MAILBOX.getMailboxId(), new Flags()); + MessageUid messageUid2 = MessageUid.of(2); insert(messageUid2, MAILBOX.getMailboxId(), new Flags()); + MessageUid messageUid3 = MessageUid.of(3); insert(messageUid3, MAILBOX.getMailboxId(), new Flags(DELETED)); + MessageUid messageUid4 = MessageUid.of(4); insert(messageUid4, MAILBOX.getMailboxId(), new Flags()); + MessageUid messageUid5 = MessageUid.of(5); insert(messageUid5, MAILBOX.getMailboxId(), new Flags()); + MessageUid messageUid6 = MessageUid.of(6); insert(messageUid6, PostgresMailboxId.generate(), new Flags(DELETED)); @@ -143,20 +126,7 @@ void searchShouldReturnMailboxEntries() { } private void insert(MessageUid messageUid, MailboxId mailboxId, Flags flags) { - PostgresMessageId messageId = new PostgresMessageId.Factory().generate(); - MailboxMessage message = SimpleMailboxMessage.builder() - .messageId(messageId) - .threadId(ThreadId.fromBaseMessageId(messageId)) - .uid(messageUid) - .content(CONTENT_STREAM) - .size(SIZE) - .internalDate(new Date()) - .bodyStartOctet(18) - .flags(flags) - .properties(new PropertyBuilder()) - .mailboxId(mailboxId) - .modseq(ModSeq.of(1)) - .build(); + MailboxMessage message = SearchOverrideFixture.createMessage(messageUid, mailboxId, flags); postgresMessageDAO.insert(message, BLOB_ID).block(); postgresMailboxMessageDAO.insert(message).block(); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/SearchOverrideFixture.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/SearchOverrideFixture.java new file mode 100644 index 00000000000..b64043d7b35 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/SearchOverrideFixture.java @@ -0,0 +1,71 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.search; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import javax.mail.Flags; + +import org.apache.james.core.Username; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.MailboxSessionUtil; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.model.ByteContent; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.ThreadId; +import org.apache.james.mailbox.model.UidValidity; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.mailbox.store.mail.model.MailboxMessage; +import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; +import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; + +interface SearchOverrideFixture { + MailboxSession MAILBOX_SESSION = MailboxSessionUtil.create(Username.of("benwa")); + Mailbox MAILBOX = new Mailbox(MailboxPath.inbox(MAILBOX_SESSION), UidValidity.of(12), PostgresMailboxId.generate()); + String BLOB_ID = "abc"; + Charset MESSAGE_CHARSET = StandardCharsets.UTF_8; + String MESSAGE_CONTENT = "Simple message content"; + byte[] MESSAGE_CONTENT_BYTES = MESSAGE_CONTENT.getBytes(MESSAGE_CHARSET); + ByteContent CONTENT_STREAM = new ByteContent(MESSAGE_CONTENT_BYTES); + long SIZE = MESSAGE_CONTENT_BYTES.length; + + static MailboxMessage createMessage(MessageUid messageUid, MailboxId mailboxId, Flags flags) { + PostgresMessageId messageId = new PostgresMessageId.Factory().generate(); + return SimpleMailboxMessage.builder() + .messageId(messageId) + .threadId(ThreadId.fromBaseMessageId(messageId)) + .uid(messageUid) + .content(CONTENT_STREAM) + .size(SIZE) + .internalDate(new Date()) + .bodyStartOctet(18) + .flags(flags) + .properties(new PropertyBuilder()) + .mailboxId(mailboxId) + .modseq(ModSeq.of(1)) + .build(); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UidSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UidSearchOverrideTest.java index aa7e4a0993d..a42a3300d08 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UidSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UidSearchOverrideTest.java @@ -19,49 +19,27 @@ package org.apache.james.mailbox.postgres.search; +import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.BLOB_ID; +import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.MAILBOX; +import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.MAILBOX_SESSION; import static org.assertj.core.api.Assertions.assertThat; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; -import java.util.Date; - import javax.mail.Flags; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.core.Username; -import org.apache.james.mailbox.MailboxSession; -import org.apache.james.mailbox.MailboxSessionUtil; import org.apache.james.mailbox.MessageUid; -import org.apache.james.mailbox.ModSeq; -import org.apache.james.mailbox.model.ByteContent; -import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MailboxId; -import org.apache.james.mailbox.model.MailboxPath; import org.apache.james.mailbox.model.SearchQuery; -import org.apache.james.mailbox.model.ThreadId; -import org.apache.james.mailbox.model.UidValidity; import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; import org.apache.james.mailbox.postgres.PostgresMailboxId; -import org.apache.james.mailbox.postgres.PostgresMessageId; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.store.mail.model.MailboxMessage; -import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; -import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; public class UidSearchOverrideTest { - private static final MailboxSession MAILBOX_SESSION = MailboxSessionUtil.create(Username.of("benwa")); - private static final Mailbox MAILBOX = new Mailbox(MailboxPath.inbox(MAILBOX_SESSION), UidValidity.of(12), PostgresMailboxId.generate()); - private static final String BLOB_ID = "abc"; - private static final Charset MESSAGE_CHARSET = StandardCharsets.UTF_8; - private static final String MESSAGE_CONTENT = "Simple message content"; - private static final byte[] MESSAGE_CONTENT_BYTES = MESSAGE_CONTENT.getBytes(MESSAGE_CHARSET); - private static final ByteContent CONTENT_STREAM = new ByteContent(MESSAGE_CONTENT_BYTES); - private final static long SIZE = MESSAGE_CONTENT_BYTES.length; - @RegisterExtension static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); @@ -109,14 +87,19 @@ void searchShouldReturnEmptyByDefault() { void searchShouldReturnMailboxEntries() { MessageUid messageUid = MessageUid.of(1); insert(messageUid, MAILBOX.getMailboxId()); + MessageUid messageUid2 = MessageUid.of(2); insert(messageUid2, MAILBOX.getMailboxId()); + MessageUid messageUid3 = MessageUid.of(3); insert(messageUid3, MAILBOX.getMailboxId()); + MessageUid messageUid4 = MessageUid.of(4); insert(messageUid4, MAILBOX.getMailboxId()); + MessageUid messageUid5 = MessageUid.of(5); insert(messageUid5, MAILBOX.getMailboxId()); + MessageUid messageUid6 = MessageUid.of(6); insert(messageUid6, PostgresMailboxId.generate()); @@ -128,20 +111,7 @@ void searchShouldReturnMailboxEntries() { } private void insert(MessageUid messageUid, MailboxId mailboxId) { - PostgresMessageId messageId = new PostgresMessageId.Factory().generate(); - MailboxMessage message = SimpleMailboxMessage.builder() - .messageId(messageId) - .threadId(ThreadId.fromBaseMessageId(messageId)) - .uid(messageUid) - .content(CONTENT_STREAM) - .size(SIZE) - .internalDate(new Date()) - .bodyStartOctet(18) - .flags(new Flags()) - .properties(new PropertyBuilder()) - .mailboxId(mailboxId) - .modseq(ModSeq.of(1)) - .build(); + MailboxMessage message = SearchOverrideFixture.createMessage(messageUid, mailboxId, new Flags()); postgresMessageDAO.insert(message, BLOB_ID).block(); postgresMailboxMessageDAO.insert(message).block(); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverrideTest.java index 51d8825096e..84bd658a4e4 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverrideTest.java @@ -20,49 +20,26 @@ package org.apache.james.mailbox.postgres.search; import static javax.mail.Flags.Flag.SEEN; +import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.BLOB_ID; +import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.MAILBOX; +import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.MAILBOX_SESSION; import static org.assertj.core.api.Assertions.assertThat; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; -import java.util.Date; - import javax.mail.Flags; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.core.Username; -import org.apache.james.mailbox.MailboxSession; -import org.apache.james.mailbox.MailboxSessionUtil; import org.apache.james.mailbox.MessageUid; -import org.apache.james.mailbox.ModSeq; -import org.apache.james.mailbox.model.ByteContent; -import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MailboxId; -import org.apache.james.mailbox.model.MailboxPath; import org.apache.james.mailbox.model.SearchQuery; -import org.apache.james.mailbox.model.ThreadId; -import org.apache.james.mailbox.model.UidValidity; import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; -import org.apache.james.mailbox.postgres.PostgresMailboxId; -import org.apache.james.mailbox.postgres.PostgresMessageId; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.store.mail.model.MailboxMessage; -import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; -import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; public class UnseenSearchOverrideTest { - private static final MailboxSession MAILBOX_SESSION = MailboxSessionUtil.create(Username.of("benwa")); - private static final Mailbox MAILBOX = new Mailbox(MailboxPath.inbox(MAILBOX_SESSION), UidValidity.of(12), PostgresMailboxId.generate()); - private static final String BLOB_ID = "abc"; - private static final Charset MESSAGE_CHARSET = StandardCharsets.UTF_8; - private static final String MESSAGE_CONTENT = "Simple message content"; - private static final byte[] MESSAGE_CONTENT_BYTES = MESSAGE_CONTENT.getBytes(MESSAGE_CHARSET); - private static final ByteContent CONTENT_STREAM = new ByteContent(MESSAGE_CONTENT_BYTES); - private final static long SIZE = MESSAGE_CONTENT_BYTES.length; - @RegisterExtension static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); @@ -180,10 +157,13 @@ void searchShouldReturnMailboxEntries() { void searchShouldSupportRanges() { MessageUid messageUid = MessageUid.of(1); insert(messageUid, MAILBOX.getMailboxId(), new Flags()); + MessageUid messageUid2 = MessageUid.of(2); insert(messageUid2, MAILBOX.getMailboxId(), new Flags()); + MessageUid messageUid3 = MessageUid.of(3); insert(messageUid3, MAILBOX.getMailboxId(), new Flags(SEEN)); + MessageUid messageUid4 = MessageUid.of(4); insert(messageUid4, MAILBOX.getMailboxId(), new Flags()); @@ -196,20 +176,7 @@ void searchShouldSupportRanges() { } private void insert(MessageUid messageUid, MailboxId mailboxId, Flags flags) { - PostgresMessageId messageId = new PostgresMessageId.Factory().generate(); - MailboxMessage message = SimpleMailboxMessage.builder() - .messageId(messageId) - .threadId(ThreadId.fromBaseMessageId(messageId)) - .uid(messageUid) - .content(CONTENT_STREAM) - .size(SIZE) - .internalDate(new Date()) - .bodyStartOctet(18) - .flags(flags) - .properties(new PropertyBuilder()) - .mailboxId(mailboxId) - .modseq(ModSeq.of(1)) - .build(); + MailboxMessage message = SearchOverrideFixture.createMessage(messageUid, mailboxId, flags); postgresMessageDAO.insert(message, BLOB_ID).block(); postgresMailboxMessageDAO.insert(message).block(); } From 8029cb530713752e1fd3074c7a75ea4bca2639d1 Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Fri, 22 Dec 2023 16:40:24 +0700 Subject: [PATCH 157/341] JAMES-2586 Unnecessary join on deleted uid search queries in postgresql --- .../postgres/mail/dao/PostgresMailboxMessageDAO.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java index 61ff87a7641..01dd1c752a6 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java @@ -298,7 +298,7 @@ public Flux findMessagesByMailboxIdAndUIDs(Postgre public Flux findDeletedMessagesByMailboxId(PostgresMailboxId mailboxId) { return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(MESSAGE_UID) - .from(MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP) + .from(TABLE_NAME) .where(MAILBOX_ID.eq(mailboxId.asUuid())) .and(IS_DELETED.eq(true)) .orderBy(DEFAULT_SORT_ORDER_BY))) @@ -307,7 +307,7 @@ public Flux findDeletedMessagesByMailboxId(PostgresMailboxId mailbox public Flux findDeletedMessagesByMailboxIdAndBetweenUIDs(PostgresMailboxId mailboxId, MessageUid from, MessageUid to) { return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(MESSAGE_UID) - .from(MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP) + .from(TABLE_NAME) .where(MAILBOX_ID.eq(mailboxId.asUuid())) .and(IS_DELETED.eq(true)) .and(MESSAGE_UID.greaterOrEqual(from.asLong())) @@ -318,7 +318,7 @@ public Flux findDeletedMessagesByMailboxIdAndBetweenUIDs(PostgresMai public Flux findDeletedMessagesByMailboxIdAndAfterUID(PostgresMailboxId mailboxId, MessageUid from) { return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(MESSAGE_UID) - .from(MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP) + .from(TABLE_NAME) .where(MAILBOX_ID.eq(mailboxId.asUuid())) .and(IS_DELETED.eq(true)) .and(MESSAGE_UID.greaterOrEqual(from.asLong())) @@ -328,7 +328,7 @@ public Flux findDeletedMessagesByMailboxIdAndAfterUID(PostgresMailbo public Mono findDeletedMessageByMailboxIdAndUid(PostgresMailboxId mailboxId, MessageUid uid) { return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select(MESSAGE_UID) - .from(MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP) + .from(TABLE_NAME) .where(MAILBOX_ID.eq(mailboxId.asUuid())) .and(IS_DELETED.eq(true)) .and(MESSAGE_UID.eq(uid.asLong())))) From 34926cc1a6f9fc9f6dc15da494738cecfb83c97a Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 25 Dec 2023 11:22:17 +0700 Subject: [PATCH 158/341] JAMES-2586 Moving RabbitMQExtension from distributed-app to queue-rabbitmq-guice --- server/apps/distributed-app/pom.xml | 6 ++++++ server/apps/distributed-pop3-app/pom.xml | 6 ++++++ server/container/guice/pom.xml | 6 ++++++ server/container/guice/queue/rabbitmq/pom.xml | 17 ++++++++++++++++ .../james/modules/DockerRabbitMQRule.java | 0 .../james/modules/RabbitMQExtension.java | 2 +- .../james/modules/TestRabbitMQModule.java | 3 ++- .../pom.xml | 18 +++++++++++++++++ .../pom.xml | 20 ++++++++++++++++++- 9 files changed, 75 insertions(+), 3 deletions(-) rename server/{apps/distributed-app => container/guice/queue/rabbitmq}/src/test/java/org/apache/james/modules/DockerRabbitMQRule.java (100%) rename server/{apps/distributed-app => container/guice/queue/rabbitmq}/src/test/java/org/apache/james/modules/RabbitMQExtension.java (99%) rename server/{apps/distributed-app => container/guice/queue/rabbitmq}/src/test/java/org/apache/james/modules/TestRabbitMQModule.java (98%) diff --git a/server/apps/distributed-app/pom.xml b/server/apps/distributed-app/pom.xml index 505138d11d3..f3fcf06e247 100644 --- a/server/apps/distributed-app/pom.xml +++ b/server/apps/distributed-app/pom.xml @@ -293,6 +293,12 @@ ${james.groupId} james-server-webadmin-rabbitmq + + ${james.groupId} + queue-rabbitmq-guice + test-jar + test + ${james.groupId} queue-rabbitmq-guice diff --git a/server/apps/distributed-pop3-app/pom.xml b/server/apps/distributed-pop3-app/pom.xml index 1095197a12e..6fbeddbd526 100644 --- a/server/apps/distributed-pop3-app/pom.xml +++ b/server/apps/distributed-pop3-app/pom.xml @@ -291,6 +291,12 @@ ${james.groupId} james-server-webadmin-rabbitmq + + ${james.groupId} + queue-rabbitmq-guice + test-jar + test + ${james.groupId} queue-rabbitmq-guice diff --git a/server/container/guice/pom.xml b/server/container/guice/pom.xml index 3f090097382..10a863ee767 100644 --- a/server/container/guice/pom.xml +++ b/server/container/guice/pom.xml @@ -311,6 +311,12 @@ queue-rabbitmq-guice ${project.version} + + ${james.groupId} + queue-rabbitmq-guice + ${project.version} + test-jar + com.linagora logback-elasticsearch-appender diff --git a/server/container/guice/queue/rabbitmq/pom.xml b/server/container/guice/queue/rabbitmq/pom.xml index 0fc3035c5a9..d2e6e480cf3 100644 --- a/server/container/guice/queue/rabbitmq/pom.xml +++ b/server/container/guice/queue/rabbitmq/pom.xml @@ -36,6 +36,23 @@ ${james.groupId} apache-james-backends-rabbitmq + + ${james.groupId} + apache-james-backends-rabbitmq + test-jar + test + + + ${james.groupId} + james-server-guice-common + test + + + ${james.groupId} + james-server-guice-common + test-jar + test + ${james.groupId} james-server-guice-configuration diff --git a/server/apps/distributed-app/src/test/java/org/apache/james/modules/DockerRabbitMQRule.java b/server/container/guice/queue/rabbitmq/src/test/java/org/apache/james/modules/DockerRabbitMQRule.java similarity index 100% rename from server/apps/distributed-app/src/test/java/org/apache/james/modules/DockerRabbitMQRule.java rename to server/container/guice/queue/rabbitmq/src/test/java/org/apache/james/modules/DockerRabbitMQRule.java diff --git a/server/apps/distributed-app/src/test/java/org/apache/james/modules/RabbitMQExtension.java b/server/container/guice/queue/rabbitmq/src/test/java/org/apache/james/modules/RabbitMQExtension.java similarity index 99% rename from server/apps/distributed-app/src/test/java/org/apache/james/modules/RabbitMQExtension.java rename to server/container/guice/queue/rabbitmq/src/test/java/org/apache/james/modules/RabbitMQExtension.java index 2da7a4105c1..743371f4b77 100644 --- a/server/apps/distributed-app/src/test/java/org/apache/james/modules/RabbitMQExtension.java +++ b/server/container/guice/queue/rabbitmq/src/test/java/org/apache/james/modules/RabbitMQExtension.java @@ -59,4 +59,4 @@ public boolean supportsParameter(ParameterContext parameterContext, ExtensionCon public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { return dockerRabbitMQ(); } -} +} \ No newline at end of file diff --git a/server/apps/distributed-app/src/test/java/org/apache/james/modules/TestRabbitMQModule.java b/server/container/guice/queue/rabbitmq/src/test/java/org/apache/james/modules/TestRabbitMQModule.java similarity index 98% rename from server/apps/distributed-app/src/test/java/org/apache/james/modules/TestRabbitMQModule.java rename to server/container/guice/queue/rabbitmq/src/test/java/org/apache/james/modules/TestRabbitMQModule.java index 60835d933b7..e068482b5ba 100644 --- a/server/apps/distributed-app/src/test/java/org/apache/james/modules/TestRabbitMQModule.java +++ b/server/container/guice/queue/rabbitmq/src/test/java/org/apache/james/modules/TestRabbitMQModule.java @@ -33,6 +33,7 @@ import org.apache.james.queue.rabbitmq.RabbitMQMailQueueManagement; import org.apache.james.queue.rabbitmq.view.RabbitMQMailQueueConfiguration; import org.apache.james.queue.rabbitmq.view.cassandra.configuration.CassandraMailQueueViewConfiguration; +import org.apache.james.task.Task; import com.google.inject.AbstractModule; import com.google.inject.Provides; @@ -102,7 +103,7 @@ public QueueCleanUp(RabbitMQMailQueueManagement api) { public Result run() { api.deleteAllQueues(); - return Result.COMPLETED; + return Task.Result.COMPLETED; } } } diff --git a/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/pom.xml b/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/pom.xml index 1fa7a09b0c9..91208a25f46 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/pom.xml +++ b/server/protocols/jmap-rfc-8621-integration-tests/distributed-jmap-rfc-8621-integration-tests/pom.xml @@ -30,6 +30,18 @@ Apache James :: Server :: JMAP RFC-8621 :: Distributed Integration Testing Distributed Integration testing for JMAP RFC-8621 + + + + ${james.groupId} + james-server-guice + ${project.version} + pom + import + + + + ${james.groupId} @@ -127,6 +139,12 @@ jmap-rfc-8621-integration-tests-common test + + ${james.groupId} + queue-rabbitmq-guice + test-jar + test + commons-codec commons-codec diff --git a/server/protocols/webadmin-integration-test/distributed-webadmin-integration-test/pom.xml b/server/protocols/webadmin-integration-test/distributed-webadmin-integration-test/pom.xml index cf8b4d9a27e..5274ec86d52 100644 --- a/server/protocols/webadmin-integration-test/distributed-webadmin-integration-test/pom.xml +++ b/server/protocols/webadmin-integration-test/distributed-webadmin-integration-test/pom.xml @@ -32,6 +32,18 @@ Apache James :: Server :: Web Admin server integration tests :: Distributed + + + + ${james.groupId} + james-server-guice + ${project.version} + pom + import + + + + ${james.groupId} @@ -97,6 +109,12 @@ james-server-webadmin-integration-test-common test + + ${james.groupId} + queue-rabbitmq-guice + test-jar + test + @@ -130,7 +148,7 @@ org.apache.maven.plugins maven-surefire-plugin - + unstable From 7346bd3c5f273a350e8100080a48891427855741 Mon Sep 17 00:00:00 2001 From: vttran Date: Thu, 21 Dec 2023 16:01:45 +0700 Subject: [PATCH 159/341] JAMES-2586 Plug RabbitMQ EventBus into Postgres-app --- server/apps/postgres-app/README.adoc | 15 ++- .../docker-compose-distributed.yml | 11 +++ server/apps/postgres-app/pom.xml | 12 +++ .../rabbitmq.properties | 95 +++++++++++++++++++ .../james/PostgresJamesConfiguration.java | 46 ++++++++- .../apache/james/PostgresJamesServerMain.java | 16 +++- .../DistributedPostgresJamesServerTest.java | 7 ++ .../james/JamesCapabilitiesServerTest.java | 4 +- .../apache/james/PostgresJamesServerTest.java | 4 +- .../PostgresWithLDAPJamesServerTest.java | 3 +- .../PostgresWithOpenSearchDisabledTest.java | 3 + .../apache/james/PostgresWithTikaTest.java | 2 + .../WithScanningSearchImmutableTest.java | 2 + .../james/WithScanningSearchMutableTest.java | 2 + 14 files changed, 215 insertions(+), 7 deletions(-) create mode 100644 server/apps/postgres-app/sample-configuration-distributed/rabbitmq.properties diff --git a/server/apps/postgres-app/README.adoc b/server/apps/postgres-app/README.adoc index 4d1c90fa343..3ab88b00ffd 100644 --- a/server/apps/postgres-app/README.adoc +++ b/server/apps/postgres-app/README.adoc @@ -41,6 +41,13 @@ $ docker run -d --network james -p 9200:9200 --name=opensearch --env 'discovery. $ docker run -d --network james --env 'REMOTE_MANAGEMENT_DISABLE=1' --env 'SCALITY_ACCESS_KEY_ID=accessKey1' --env 'SCALITY_SECRET_ACCESS_KEY=secretKey1' --name=s3 registry.scality.com/cloudserver/cloudserver:8.7.25 ---- +* RabbitMQ 3.12.1 + +[source] +---- +$ docker run -d --network james -p 5672:5672 -p 15672:15672 --name=rabbitmq rabbitmq:3.12.1-management +---- + == Running manually === Running with Postgresql only @@ -106,7 +113,13 @@ docker compose up -d === Distributed -We also have a distributed version of the James postgresql app (with OpenSearch as a search indexer and S3 as the object storage). To run it, simply type: +We also have a distributed version of the James postgresql app with: + +- OpenSearch as a search indexer +- S3 as the object storage +- RabbitMQ as the event bus + +To run it, simply type: .... docker compose -f docker-compose-distributed.yml up -d diff --git a/server/apps/postgres-app/docker-compose-distributed.yml b/server/apps/postgres-app/docker-compose-distributed.yml index de6a3d3f630..95bc9b03900 100644 --- a/server/apps/postgres-app/docker-compose-distributed.yml +++ b/server/apps/postgres-app/docker-compose-distributed.yml @@ -10,6 +10,8 @@ services: condition: service_healthy s3: condition: service_started + rabbitmq: + condition: service_started image: apache/james:postgres-latest container_name: james hostname: james.local @@ -27,6 +29,7 @@ services: volumes: - ./sample-configuration-distributed/opensearch.properties:/root/conf/opensearch.properties - ./sample-configuration-distributed/blob.properties:/root/conf/blob.properties + - ./sample-configuration-distributed/rabbitmq.properties:/root/conf/rabbitmq.properties networks: - james @@ -68,5 +71,13 @@ services: networks: - james + rabbitmq: + image: rabbitmq:3.12.1-management + ports: + - "5672:5672" + - "15672:15672" + networks: + - james + networks: james: \ No newline at end of file diff --git a/server/apps/postgres-app/pom.xml b/server/apps/postgres-app/pom.xml index f361239063d..44151c09569 100644 --- a/server/apps/postgres-app/pom.xml +++ b/server/apps/postgres-app/pom.xml @@ -56,6 +56,12 @@ test-jar test + + ${james.groupId} + apache-james-backends-rabbitmq + test-jar + test + ${james.groupId} apache-james-mailbox-opensearch @@ -228,6 +234,12 @@ ${james.groupId} queue-activemq-guice + + ${james.groupId} + queue-rabbitmq-guice + ${project.version} + test-jar + ${james.groupId} testing-base diff --git a/server/apps/postgres-app/sample-configuration-distributed/rabbitmq.properties b/server/apps/postgres-app/sample-configuration-distributed/rabbitmq.properties new file mode 100644 index 00000000000..75a562af60a --- /dev/null +++ b/server/apps/postgres-app/sample-configuration-distributed/rabbitmq.properties @@ -0,0 +1,95 @@ +# RabbitMQ configuration + +# Read https://james.apache.org/server/config-rabbitmq.html for further details + +# Mandatory +uri=amqp://rabbitmq:5672 +# If you use a vhost, specify it as well at the end of the URI +# uri=amqp://rabbitmq:5672/vhost + +# Vhost to use for creating queues and exchanges +# Optional, only use this if you have invalid URIs containing characters like '_' +# vhost=vhost1 + +# Optional, default to the host specified as part of the URI. +# Allow creating cluster aware connections. +# hosts=ip1:5672,ip2:5672 + +# RabbitMQ Administration Management +# Mandatory +management.uri=http://rabbitmq:15672 +# Mandatory +management.user=guest +# Mandatory +management.password=guest + +# Configure retries count to retrieve a connection. Exponential backoff is performed between each retries. +# Optional integer, defaults to 10 +#connection.pool.retries=10 +# Configure initial duration (in ms) between two connection retries. Exponential backoff is performed between each retries. +# Optional integer, defaults to 100 +#connection.pool.min.delay.ms=100 +# Configure retries count to retrieve a channel. Exponential backoff is performed between each retries. +# Optional integer, defaults to 3 +#channel.pool.retries=3 +# Configure timeout duration (in ms) to obtain a rabbitmq channel. Defaults to 30 seconds. +# Optional integer, defaults to 30 seconds. +#channel.pool.max.delay.ms=30000 +# Configure the size of the channel pool. +# Optional integer, defaults to 3 +#channel.pool.size=3 + +# Boolean. Whether to activate Quorum queue usage for use cases that benefits from it (work queue). +# Quorum queues enables high availability. +# False (default value) results in the usage of classic queues. +#quorum.queues.enable=true + +# Strictly positive integer. The replication factor to use when creating quorum queues. +#quorum.queues.replication.factor + +# Parameters for the Cassandra administrative view + +# Whether the Cassandra administrative view should be activated. Boolean value defaulting to true. +# Not necessarily needed for MDA deployments, mail queue management adds significant complexity. +# cassandra.view.enabled=true + +# Period of the window. Too large values will lead to wide rows while too little values might lead to many queries. +# Use the number of mail per Cassandra row, along with your expected traffic, to determine this value +# This value can only be decreased to a value dividing the current value +# Optional, default 1h +mailqueue.view.sliceWindow=1h + +# Use to distribute the emails of a given slice within your cassandra cluster +# A good value is 2*cassandraNodeCount +# This parameter can only be increased. +# Optional, default 1 +mailqueue.view.bucketCount=1 + +# Determine the probability to update the browse start pointer +# Too little value will lead to unnecessary reads. Too big value will lead to more expensive browse. +# Choose this parameter so that it get's update one time every one-two sliceWindow +# Optional, default 1000 +mailqueue.view.updateBrowseStartPace=1000 + +# Enables or disables the gauge metric on the mail queue size +# Computing the size of the mail queue is currently implemented on top of browse operation and thus have a linear complexity +# Metrics get exported periodically as configured in opensearch.properties, thus getSize is also called periodically +# Choose to disable it when the mail queue size is getting too big +# Note that this is as well a temporary workaround until we get 'getSize' method better optimized +# Optional, default false +mailqueue.size.metricsEnabled=false + +# Whether to enable task consumption on this node. Tasks are WebAdmin triggered long running jobs. +# Disable with caution (this only makes sense in a distributed setup where other nodes consume tasks). +# Defaults to true. +task.consumption.enabled=true + +# Configure task queue consumer timeout. References: https://www.rabbitmq.com/consumers.html#acknowledgement-timeout. Required at least RabbitMQ version 3.12 to have effect. +# This is used to avoid the task queue consumer (which could run very long tasks) being disconnected by RabbitMQ after the default acknowledgement timeout 30 minutes. +# Optional. Duration (support multiple time units cf `DurationParser`), defaults to 1 day. +#task.queue.consumer.timeout=1day + +# Configure queue ttl (in ms). References: https://www.rabbitmq.com/ttl.html#queue-ttl. +# This is used only on queues used to share notification patterns, are exclusive to a node. If omitted, it will not add the TTL configure when declaring queues. +# Optional integer, defaults is 3600000. +#notification.queue.ttl=3600000 diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java index 2c2b41c3aff..26bad105be5 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java @@ -20,8 +20,10 @@ package org.apache.james; import java.io.File; +import java.io.FileNotFoundException; import java.util.Optional; +import org.apache.commons.configuration2.ex.ConfigurationException; import org.apache.james.data.UsersRepositoryModuleChooser; import org.apache.james.filesystem.api.FileSystem; import org.apache.james.filesystem.api.JamesDirectoriesProvider; @@ -32,13 +34,35 @@ import org.apache.james.server.core.configuration.FileConfigurationProvider; import org.apache.james.server.core.filesystem.FileSystemImpl; import org.apache.james.utils.PropertiesProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.github.fge.lambdas.Throwing; import com.google.common.base.Preconditions; public class PostgresJamesConfiguration implements Configuration { + + private static final Logger LOGGER = LoggerFactory.getLogger(PostgresJamesConfiguration.class); + private static BlobStoreConfiguration.BlobStoreImplName DEFAULT_BLOB_STORE = BlobStoreConfiguration.BlobStoreImplName.FILE; + public enum EventBusImpl { + IN_MEMORY, RABBITMQ; + + public static EventBusImpl from(PropertiesProvider configurationProvider) { + try { + configurationProvider.getConfiguration("rabbitmq"); + return EventBusImpl.RABBITMQ; + } catch (FileNotFoundException e) { + LOGGER.info("RabbitMQ configuration was not found, defaulting to in memory event bus"); + return EventBusImpl.IN_MEMORY; + } catch (ConfigurationException e) { + LOGGER.warn("Error reading rabbitmq.xml, defaulting to in memory event bus", e); + return EventBusImpl.IN_MEMORY; + } + } + } + public static class Builder { private Optional rootDirectory; private Optional configurationPath; @@ -46,12 +70,15 @@ public static class Builder { private Optional searchConfiguration; private Optional blobStoreConfiguration; + private Optional eventBusImpl; + private Builder() { searchConfiguration = Optional.empty(); rootDirectory = Optional.empty(); configurationPath = Optional.empty(); usersRepositoryImplementation = Optional.empty(); blobStoreConfiguration = Optional.empty(); + eventBusImpl = Optional.empty(); } public Builder workingDirectory(String path) { @@ -97,6 +124,11 @@ public Builder blobStore(BlobStoreConfiguration blobStoreConfiguration) { return this; } + public Builder eventBusImpl(EventBusImpl eventBusImpl) { + this.eventBusImpl = Optional.of(eventBusImpl); + return this; + } + public PostgresJamesConfiguration build() { ConfigurationPath configurationPath = this.configurationPath.orElse(new ConfigurationPath(FileSystem.FILE_PROTOCOL_AND_CONF)); JamesServerResourceLoader directories = new JamesServerResourceLoader(rootDirectory @@ -120,12 +152,15 @@ public PostgresJamesConfiguration build() { UsersRepositoryModuleChooser.Implementation usersRepositoryChoice = usersRepositoryImplementation.orElseGet( () -> UsersRepositoryModuleChooser.Implementation.parse(configurationProvider)); + EventBusImpl eventBusImpl = this.eventBusImpl.orElseGet(() -> EventBusImpl.from(propertiesProvider)); + return new PostgresJamesConfiguration( configurationPath, directories, searchConfiguration, usersRepositoryChoice, - blobStoreConfiguration); + blobStoreConfiguration, + eventBusImpl); } } @@ -138,17 +173,20 @@ public static Builder builder() { private final SearchConfiguration searchConfiguration; private final UsersRepositoryModuleChooser.Implementation usersRepositoryImplementation; private final BlobStoreConfiguration blobStoreConfiguration; + private final EventBusImpl eventBusImpl; + private PostgresJamesConfiguration(ConfigurationPath configurationPath, JamesDirectoriesProvider directories, SearchConfiguration searchConfiguration, UsersRepositoryModuleChooser.Implementation usersRepositoryImplementation, - BlobStoreConfiguration blobStoreConfiguration) { + BlobStoreConfiguration blobStoreConfiguration, EventBusImpl eventBusImpl) { this.configurationPath = configurationPath; this.directories = directories; this.searchConfiguration = searchConfiguration; this.usersRepositoryImplementation = usersRepositoryImplementation; this.blobStoreConfiguration = blobStoreConfiguration; + this.eventBusImpl = eventBusImpl; } @Override @@ -172,4 +210,8 @@ public UsersRepositoryModuleChooser.Implementation getUsersRepositoryImplementat public BlobStoreConfiguration blobStoreConfiguration() { return blobStoreConfiguration; } + + public EventBusImpl eventBusImpl() { + return eventBusImpl; + } } diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index d65badf9924..28434ebe96f 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -32,6 +32,7 @@ import org.apache.james.modules.data.PostgresDelegationStoreModule; import org.apache.james.modules.data.PostgresUsersRepositoryModule; import org.apache.james.modules.data.SievePostgresRepositoryModules; +import org.apache.james.modules.event.RabbitMQEventBusModule; import org.apache.james.modules.eventstore.MemoryEventStoreModule; import org.apache.james.modules.mailbox.DefaultEventModule; import org.apache.james.modules.mailbox.MemoryDeadLetterModule; @@ -44,6 +45,7 @@ import org.apache.james.modules.protocols.ProtocolHandlerModule; import org.apache.james.modules.protocols.SMTPServerModule; import org.apache.james.modules.queue.activemq.ActiveMQQueueModule; +import org.apache.james.modules.queue.rabbitmq.RabbitMQModule; import org.apache.james.modules.server.DataRoutesModules; import org.apache.james.modules.server.DefaultProcessorsConfigurationProviderModule; import org.apache.james.modules.server.InconsistencyQuotasSolvingRoutesModule; @@ -97,7 +99,6 @@ public class PostgresJamesServerMain implements JamesServerMain { new NoJwtModule(), new RawPostDequeueDecoratorModule(), new SievePostgresRepositoryModules(), - new DefaultEventModule(), new TaskManagerModule(), new MemoryDeadLetterModule(), new MemoryEventStoreModule(), @@ -129,6 +130,7 @@ static GuiceJamesServer createServer(PostgresJamesConfiguration configuration) { .combineWith(new UsersRepositoryModuleChooser(new PostgresUsersRepositoryModule()) .chooseModules(configuration.getUsersRepositoryImplementation())) .combineWith(chooseBlobStoreModules(configuration)) + .combineWith(chooseEventBusModules(configuration)) .combineWith(POSTGRES_MODULE_AGGREGATE); } @@ -144,4 +146,16 @@ private static List chooseBlobStoreModules(PostgresJamesConfiguration co return builder.build(); } + + public static List chooseEventBusModules(PostgresJamesConfiguration configuration) { + switch (configuration.eventBusImpl()) { + case IN_MEMORY: + return List.of(new DefaultEventModule()); + case RABBITMQ: + return List.of(new RabbitMQModule(), + Modules.override(new DefaultEventModule()).with(new RabbitMQEventBusModule())); + default: + throw new RuntimeException("Unsupported event-bus implementation " + configuration.eventBusImpl().name()); + } + } } diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/DistributedPostgresJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/DistributedPostgresJamesServerTest.java index ce037588d09..9856247b1e3 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/DistributedPostgresJamesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/DistributedPostgresJamesServerTest.java @@ -24,10 +24,12 @@ import static org.awaitility.Durations.FIVE_HUNDRED_MILLISECONDS; import static org.awaitility.Durations.ONE_MINUTE; +import org.apache.james.PostgresJamesConfiguration.EventBusImpl; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.core.quota.QuotaSizeLimit; import org.apache.james.modules.AwsS3BlobStoreExtension; import org.apache.james.modules.QuotaProbesImpl; +import org.apache.james.modules.RabbitMQExtension; import org.apache.james.modules.blobstore.BlobStoreConfiguration; import org.apache.james.modules.protocols.ImapGuiceProbe; import org.apache.james.modules.protocols.SmtpGuiceProbe; @@ -45,6 +47,9 @@ class DistributedPostgresJamesServerTest implements JamesServerConcreteContract { static PostgresExtension postgresExtension = PostgresExtension.empty(); + @RegisterExtension + static RabbitMQExtension rabbitMQExtension = new RabbitMQExtension(); + @RegisterExtension static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> PostgresJamesConfiguration.builder() @@ -57,11 +62,13 @@ class DistributedPostgresJamesServerTest implements JamesServerConcreteContract .deduplication() .noCryptoConfig()) .searchConfiguration(SearchConfiguration.openSearch()) + .eventBusImpl(EventBusImpl.RABBITMQ) .build()) .server(PostgresJamesServerMain::createServer) .extension(postgresExtension) .extension(new AwsS3BlobStoreExtension()) .extension(new DockerOpenSearchExtension()) + .extension(rabbitMQExtension) .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) .build(); diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java index 7ac41df803e..66204488350 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java @@ -24,6 +24,7 @@ import java.util.EnumSet; +import org.apache.james.PostgresJamesConfiguration.EventBusImpl; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.mailbox.MailboxManager; import org.junit.jupiter.api.Test; @@ -50,12 +51,13 @@ private static MailboxManager mailboxManager() { .configurationFromClasspath() .searchConfiguration(SearchConfiguration.scanning()) .usersRepository(DEFAULT) + .eventBusImpl(EventBusImpl.IN_MEMORY) .build()) .server(configuration -> PostgresJamesServerMain.createServer(configuration) .overrideWith(binder -> binder.bind(MailboxManager.class).toInstance(mailboxManager()))) .extension(postgresExtension) .build(); - + @Test void startShouldSucceedWhenRequiredCapabilities(GuiceJamesServer server) { diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java index b2466066569..ca25ff6c9e8 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java @@ -24,6 +24,7 @@ import static org.awaitility.Durations.FIVE_HUNDRED_MILLISECONDS; import static org.awaitility.Durations.ONE_MINUTE; +import org.apache.james.PostgresJamesConfiguration.EventBusImpl; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.core.quota.QuotaSizeLimit; import org.apache.james.modules.QuotaProbesImpl; @@ -50,6 +51,7 @@ class PostgresJamesServerTest implements JamesServerConcreteContract { .configurationFromClasspath() .searchConfiguration(SearchConfiguration.scanning()) .usersRepository(DEFAULT) + .eventBusImpl(EventBusImpl.IN_MEMORY) .build()) .server(PostgresJamesServerMain::createServer) .extension(postgresExtension) @@ -72,7 +74,7 @@ void setUp() { this.testIMAPClient = new TestIMAPClient(); this.smtpMessageSender = new SMTPMessageSender(DOMAIN); } - + @Test void guiceServerShouldUpdateQuota(GuiceJamesServer jamesServer) throws Exception { jamesServer.getProbe(DataProbeImpl.class) diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java index b5add8f9af5..efe1b872f64 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java @@ -32,7 +32,7 @@ import org.apache.james.user.ldap.DockerLdapSingleton; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; - +import org.apache.james.PostgresJamesConfiguration.EventBusImpl; class PostgresWithLDAPJamesServerTest { static PostgresExtension postgresExtension = PostgresExtension.empty(); @@ -43,6 +43,7 @@ class PostgresWithLDAPJamesServerTest { .configurationFromClasspath() .searchConfiguration(SearchConfiguration.openSearch()) .usersRepository(LDAP) + .eventBusImpl(EventBusImpl.IN_MEMORY) .build()) .server(PostgresJamesServerMain::createServer) .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithOpenSearchDisabledTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithOpenSearchDisabledTest.java index e00ecf2fd87..5f706b8ba38 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithOpenSearchDisabledTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithOpenSearchDisabledTest.java @@ -19,6 +19,8 @@ package org.apache.james; +import org.apache.james.PostgresJamesConfiguration.EventBusImpl; + import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; import static org.assertj.core.api.Assertions.assertThat; @@ -54,6 +56,7 @@ public class PostgresWithOpenSearchDisabledTest implements MailsShouldBeWellRece .configurationFromClasspath() .searchConfiguration(SearchConfiguration.openSearchDisabled()) .usersRepository(DEFAULT) + .eventBusImpl(EventBusImpl.IN_MEMORY) .build()) .server(configuration -> PostgresJamesServerMain.createServer(configuration) .overrideWith(binder -> binder.bind(OpenSearchConfiguration.class) diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithTikaTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithTikaTest.java index 12e5577d748..48bea4ee511 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithTikaTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithTikaTest.java @@ -21,6 +21,7 @@ import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; +import org.apache.james.PostgresJamesConfiguration.EventBusImpl; import org.apache.james.backends.postgres.PostgresExtension; import org.junit.jupiter.api.extension.RegisterExtension; @@ -34,6 +35,7 @@ public class PostgresWithTikaTest implements JamesServerConcreteContract { .configurationFromClasspath() .searchConfiguration(SearchConfiguration.openSearch()) .usersRepository(DEFAULT) + .eventBusImpl(EventBusImpl.IN_MEMORY) .build()) .server(PostgresJamesServerMain::createServer) .extension(new DockerOpenSearchExtension()) diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/WithScanningSearchImmutableTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/WithScanningSearchImmutableTest.java index 51fbd2f023f..a5e653af6d3 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/WithScanningSearchImmutableTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/WithScanningSearchImmutableTest.java @@ -21,6 +21,7 @@ import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; +import org.apache.james.PostgresJamesConfiguration.EventBusImpl; import org.apache.james.backends.postgres.PostgresExtension; import org.junit.jupiter.api.extension.RegisterExtension; @@ -34,6 +35,7 @@ public class WithScanningSearchImmutableTest implements JamesServerConcreteContr .configurationFromClasspath() .searchConfiguration(SearchConfiguration.scanning()) .usersRepository(DEFAULT) + .eventBusImpl(EventBusImpl.IN_MEMORY) .build()) .server(PostgresJamesServerMain::createServer) .extension(postgresExtension) diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/WithScanningSearchMutableTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/WithScanningSearchMutableTest.java index 7696431aafd..d9c8ef34779 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/WithScanningSearchMutableTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/WithScanningSearchMutableTest.java @@ -21,6 +21,7 @@ import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; +import org.apache.james.PostgresJamesConfiguration.EventBusImpl; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.modules.protocols.ImapGuiceProbe; import org.apache.james.modules.protocols.SmtpGuiceProbe; @@ -36,6 +37,7 @@ public class WithScanningSearchMutableTest implements MailsShouldBeWellReceived .configurationFromClasspath() .searchConfiguration(SearchConfiguration.scanning()) .usersRepository(DEFAULT) + .eventBusImpl(EventBusImpl.IN_MEMORY) .build()) .server(PostgresJamesServerMain::createServer) .extension(postgresExtension) From 7e52fe6da927a1d897721a985fc96ae7753bb67d Mon Sep 17 00:00:00 2001 From: hungphan227 <45198168+hungphan227@users.noreply.github.com> Date: Mon, 25 Dec 2023 15:43:02 +0700 Subject: [PATCH 160/341] JAMES-2586 Implement DeleteMessageListener for postgres (#1869) --- .../postgres/DeleteMessageListener.java | 114 +++++++++++++ .../PostgresMailboxSessionMapperFactory.java | 1 - .../postgres/mail/PostgresMessageMapper.java | 2 +- .../mail/dao/PostgresMailboxMessageDAO.java | 16 ++ .../postgres/mail/dao/PostgresMessageDAO.java | 15 +- .../PostgresMailboxManagerProvider.java | 28 +++- .../postgres/PostgresMailboxManagerTest.java | 157 ++++++++++++++++++ .../search/AllSearchOverrideTest.java | 3 +- .../search/DeletedSearchOverrideTest.java | 3 +- .../DeletedWithRangeSearchOverrideTest.java | 3 +- ...NotDeletedWithRangeSearchOverrideTest.java | 3 +- .../search/UidSearchOverrideTest.java | 3 +- .../search/UnseenSearchOverrideTest.java | 3 +- .../mailbox/PostgresMailboxModule.java | 9 + 14 files changed, 350 insertions(+), 10 deletions(-) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java new file mode 100644 index 00000000000..72038f2077b --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java @@ -0,0 +1,114 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres; + +import javax.inject.Inject; + +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BlobStore; +import org.apache.james.events.Event; +import org.apache.james.events.EventListener; +import org.apache.james.events.Group; +import org.apache.james.mailbox.events.MailboxEvents.Expunged; +import org.apache.james.mailbox.events.MailboxEvents.MailboxDeletion; +import org.apache.james.mailbox.model.MessageMetaData; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.reactivestreams.Publisher; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class DeleteMessageListener implements EventListener.ReactiveGroupEventListener { + public static class DeleteMessageListenerGroup extends Group { + } + + private final PostgresMessageDAO postgresMessageDAO; + private final PostgresMailboxMessageDAO postgresMailboxMessageDAO; + private final BlobStore blobStore; + + @Inject + public DeleteMessageListener(PostgresMessageDAO postgresMessageDAO, + PostgresMailboxMessageDAO postgresMailboxMessageDAO, + BlobStore blobStore) { + this.postgresMessageDAO = postgresMessageDAO; + this.postgresMailboxMessageDAO = postgresMailboxMessageDAO; + this.blobStore = blobStore; + } + + @Override + public Group getDefaultGroup() { + return new DeleteMessageListenerGroup(); + } + + @Override + public boolean isHandling(Event event) { + return event instanceof Expunged || event instanceof MailboxDeletion; + } + + @Override + public Publisher reactiveEvent(Event event) { + if (event instanceof Expunged) { + Expunged expunged = (Expunged) event; + return handleMessageDeletion(expunged); + } + if (event instanceof MailboxDeletion) { + MailboxDeletion mailboxDeletion = (MailboxDeletion) event; + PostgresMailboxId mailboxId = (PostgresMailboxId) mailboxDeletion.getMailboxId(); + return handleMailboxDeletion(mailboxId); + } + return Mono.empty(); + } + + private Mono handleMailboxDeletion(PostgresMailboxId mailboxId) { + return postgresMailboxMessageDAO.deleteByMailboxId(mailboxId) + .flatMap(this::handleMessageDeletion) + .then(); + } + + private Mono handleMessageDeletion(Expunged expunged) { + return Flux.fromIterable(expunged.getExpunged() + .values()) + .map(MessageMetaData::getMessageId) + .map(PostgresMessageId.class::cast) + .flatMap(this::handleMessageDeletion) + .then(); + } + + private Mono handleMessageDeletion(PostgresMessageId messageId) { + return Mono.just(messageId) + .filterWhen(this::isUnreferenced) + .flatMap(id -> postgresMessageDAO.getBlobId(messageId) + .flatMap(this::deleteMessageBlobs) + .then(postgresMessageDAO.deleteByMessageId(messageId))); + } + + private Mono deleteMessageBlobs(BlobId blobId) { + return Mono.from(blobStore.delete(blobStore.getDefaultBucketName(), blobId)) + .then(); + } + + private Mono isUnreferenced(PostgresMessageId id) { + return postgresMailboxMessageDAO.countByMessageId(id) + .filter(count -> count == 0) + .map(count -> true) + .defaultIfEmpty(false); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java index 0fbd9e657be..7d78d275f49 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java @@ -114,5 +114,4 @@ public AttachmentMapper createAttachmentMapper(MailboxSession session) { public AttachmentMapper getAttachmentMapper(MailboxSession session) { throw new NotImplementedException("not implemented"); } - } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java index faa785ab793..6c45e89432b 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java @@ -106,7 +106,7 @@ public PostgresMessageMapper(PostgresExecutor postgresExecutor, BlobStore blobStore, Clock clock, BlobId.Factory blobIdFactory) { - this.messageDAO = new PostgresMessageDAO(postgresExecutor); + this.messageDAO = new PostgresMessageDAO(postgresExecutor, blobIdFactory); this.mailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExecutor); this.mailboxDAO = new PostgresMailboxDAO(postgresExecutor); this.modSeqProvider = modSeqProvider; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java index 01dd1c752a6..20815d9b987 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java @@ -53,6 +53,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; +import javax.inject.Inject; import javax.mail.Flags; import org.apache.commons.lang3.tuple.Pair; @@ -108,6 +109,7 @@ private static SelectFinalStep> selectMessageUidByMailboxIdAndExtr private final PostgresExecutor postgresExecutor; + @Inject public PostgresMailboxMessageDAO(PostgresExecutor postgresExecutor) { this.postgresExecutor = postgresExecutor; } @@ -210,6 +212,13 @@ public Flux deleteByMailboxIdAndMessageUids(PostgresMailboxId m } } + public Flux deleteByMailboxId(PostgresMailboxId mailboxId) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.deleteFrom(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .returning(MESSAGE_ID))) + .map(record -> PostgresMessageId.Factory.of(record.get(MESSAGE_ID))); + } + public Mono countTotalMessagesByMailboxId(PostgresMailboxId mailboxId) { return postgresExecutor.executeCount(dslContext -> Mono.from(dslContext.selectCount() .from(TABLE_NAME) @@ -346,6 +355,13 @@ public Flux listNotDeletedUids(PostgresMailboxId mailboxId, MessageR .map(RECORD_TO_MESSAGE_UID_FUNCTION); } + public Mono countByMessageId(PostgresMessageId messageId) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.selectCount() + .from(TABLE_NAME) + .where(MESSAGE_ID.eq(messageId.asUuid())))) + .map(record -> record.get(0, Long.class)); + } + public Flux findMessagesMetadata(PostgresMailboxId mailboxId, MessageRange range) { return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select() .from(TABLE_NAME) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java index ecd8634cc1c..c2126f64d4c 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java @@ -42,8 +42,11 @@ import java.util.Optional; +import javax.inject.Inject; + import org.apache.commons.io.IOUtils; import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.blob.api.BlobId; import org.apache.james.mailbox.postgres.PostgresMessageId; import org.apache.james.mailbox.store.mail.model.MailboxMessage; import org.jooq.postgres.extensions.types.Hstore; @@ -54,9 +57,12 @@ public class PostgresMessageDAO { public static final long DEFAULT_LONG_VALUE = 0L; private final PostgresExecutor postgresExecutor; + private final BlobId.Factory blobIdFactory; - public PostgresMessageDAO(PostgresExecutor postgresExecutor) { + @Inject + public PostgresMessageDAO(PostgresExecutor postgresExecutor, BlobId.Factory blobIdFactory) { this.postgresExecutor = postgresExecutor; + this.blobIdFactory = blobIdFactory; } public Mono insert(MailboxMessage message, String bodyBlobId) { @@ -88,4 +94,11 @@ public Mono deleteByMessageId(PostgresMessageId messageId) { .where(MESSAGE_ID.eq(messageId.asUuid())))); } + public Mono getBlobId(PostgresMessageId messageId) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select(BODY_BLOB_ID) + .from(TABLE_NAME) + .where(MESSAGE_ID.eq(messageId.asUuid())))) + .map(record -> blobIdFactory.from(record.get(BODY_BLOB_ID))); + } + } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java index ac6a948d15f..ae3954a83ec 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java @@ -36,6 +36,8 @@ import org.apache.james.mailbox.acl.MailboxACLResolver; import org.apache.james.mailbox.acl.UnionMailboxACLResolver; import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.store.MailboxSessionMapperFactory; import org.apache.james.mailbox.store.SessionProviderImpl; import org.apache.james.mailbox.store.StoreMailboxAnnotationManager; @@ -55,8 +57,13 @@ public class PostgresMailboxManagerProvider { private static final int LIMIT_ANNOTATIONS = 3; private static final int LIMIT_ANNOTATION_SIZE = 30; + private static PostgresMessageDAO postgresMessageDAO; + private static PostgresMailboxMessageDAO postgresMailboxMessageDAO; + public static PostgresMailboxManager provideMailboxManager(PostgresExtension postgresExtension) { - MailboxSessionMapperFactory mf = provideMailboxSessionMapperFactory(postgresExtension); + BlobId.Factory blobIdFactory = new HashBlobId.Factory(); + DeDuplicationBlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory); + MailboxSessionMapperFactory mf = provideMailboxSessionMapperFactory(postgresExtension, blobIdFactory, blobStore); MailboxACLResolver aclResolver = new UnionMailboxACLResolver(); MessageParser messageParser = new MessageParser(); @@ -71,6 +78,11 @@ public static PostgresMailboxManager provideMailboxManager(PostgresExtension pos SessionProviderImpl sessionProvider = new SessionProviderImpl(noAuthenticator, noAuthorizator); QuotaComponents quotaComponents = QuotaComponents.disabled(sessionProvider, mf); MessageSearchIndex index = new SimpleMessageSearchIndex(mf, mf, new DefaultTextExtractor(), new PostgresAttachmentContentLoader()); + postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getExecutorFactory().create(), blobIdFactory); + postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getExecutorFactory().create()); + eventBus.register(new DeleteMessageListener(postgresMessageDAO, + postgresMailboxMessageDAO, + blobStore)); return new PostgresMailboxManager((PostgresMailboxSessionMapperFactory) mf, sessionProvider, messageParser, new PostgresMessageId.Factory(), @@ -82,10 +94,24 @@ public static MailboxSessionMapperFactory provideMailboxSessionMapperFactory(Pos BlobId.Factory blobIdFactory = new HashBlobId.Factory(); DeDuplicationBlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory); + return provideMailboxSessionMapperFactory(postgresExtension, blobIdFactory, blobStore); + } + + public static MailboxSessionMapperFactory provideMailboxSessionMapperFactory(PostgresExtension postgresExtension, + BlobId.Factory blobIdFactory, + DeDuplicationBlobStore blobStore) { return new PostgresMailboxSessionMapperFactory( postgresExtension.getExecutorFactory(), Clock.systemUTC(), blobStore, blobIdFactory); } + + public static PostgresMessageDAO providePostgresMessageDAO() { + return postgresMessageDAO; + } + + public static PostgresMailboxMessageDAO providePostgresMailboxMessageDAO() { + return postgresMailboxMessageDAO; + } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerTest.java index 537a124c969..dc6f3a0d745 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerTest.java @@ -18,17 +18,37 @@ ****************************************************************/ package org.apache.james.mailbox.postgres; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; + import java.util.Optional; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.events.EventBus; import org.apache.james.mailbox.MailboxManagerTest; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.MessageManager; import org.apache.james.mailbox.SubscriptionManager; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.MessageRange; import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.store.StoreSubscriptionManager; +import org.apache.james.util.ClassLoaderUtils; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.mockito.Mockito; + +import com.google.common.collect.ImmutableList; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; class PostgresMailboxManagerTest extends MailboxManagerTest { @@ -60,4 +80,141 @@ protected SubscriptionManager provideSubscriptionManager() { protected EventBus retrieveEventBus(PostgresMailboxManager mailboxManager) { return mailboxManager.getEventBus(); } + + @Nested + class DeletionTests { + private MailboxSession session; + private MailboxPath inbox; + private MailboxId inboxId; + private MessageManager inboxManager; + private MessageManager otherBoxManager; + private MailboxPath newPath; + private PostgresMailboxManager mailboxManager; + private PostgresMessageDAO postgresMessageDAO; + private PostgresMailboxMessageDAO postgresMailboxMessageDAO; + + @BeforeEach + void setUp() throws Exception { + mailboxManager = provideMailboxManager(); + session = mailboxManager.createSystemSession(USER_1); + inbox = MailboxPath.inbox(session); + newPath = MailboxPath.forUser(USER_1, "specialMailbox"); + + inboxId = mailboxManager.createMailbox(inbox, session).get(); + inboxManager = mailboxManager.getMailbox(inbox, session); + MailboxId otherId = mailboxManager.createMailbox(newPath, session).get(); + otherBoxManager = mailboxManager.getMailbox(otherId, session); + + postgresMessageDAO = spy(PostgresMailboxManagerProvider.providePostgresMessageDAO()); + postgresMailboxMessageDAO = spy(PostgresMailboxManagerProvider.providePostgresMailboxMessageDAO()); + } + + @Test + void deleteMailboxShouldDeleteUnreferencedMessageMetadata() throws Exception { + MessageManager.AppendResult appendResult = inboxManager.appendMessage(MessageManager.AppendCommand.builder() + .build(ClassLoaderUtils.getSystemResourceAsByteArray("eml/emailWithOnlyAttachment.eml")), session); + + mailboxManager.deleteMailbox(inbox, session); + + SoftAssertions.assertSoftly(softly -> { + PostgresMessageId messageId = (PostgresMessageId) appendResult.getId().getMessageId(); + PostgresMailboxId mailboxId = (PostgresMailboxId) appendResult.getId().getMailboxId(); + + softly.assertThat(postgresMessageDAO.getBlobId(messageId).blockOptional()) + .isEmpty(); + + softly.assertThat(postgresMailboxMessageDAO.countTotalMessagesByMailboxId(mailboxId).block()) + .isEqualTo(0); + }); + } + + @Test + void deleteMailboxShouldNotDeleteReferencedMessageMetadata() throws Exception { + MessageManager.AppendResult appendResult = inboxManager.appendMessage(MessageManager.AppendCommand.builder() + .build(ClassLoaderUtils.getSystemResourceAsByteArray("eml/emailWithOnlyAttachment.eml")), session); + mailboxManager.copyMessages(MessageRange.all(), inboxManager.getId(), otherBoxManager.getId(), session); + + mailboxManager.deleteMailbox(inbox, session); + + SoftAssertions.assertSoftly(softly -> { + PostgresMessageId messageId = (PostgresMessageId) appendResult.getId().getMessageId(); + + softly.assertThat(postgresMessageDAO.getBlobId(messageId).blockOptional()) + .isNotEmpty(); + }); + } + + @Test + void deleteMessageInMailboxShouldDeleteUnreferencedMessageMetadata() throws Exception { + MessageManager.AppendResult appendResult = inboxManager.appendMessage(MessageManager.AppendCommand.builder() + .build(ClassLoaderUtils.getSystemResourceAsByteArray("eml/emailWithOnlyAttachment.eml")), session); + + inboxManager.delete(ImmutableList.of(appendResult.getId().getUid()), session); + + SoftAssertions.assertSoftly(softly -> { + PostgresMessageId messageId = (PostgresMessageId) appendResult.getId().getMessageId(); + + softly.assertThat(postgresMessageDAO.getBlobId(messageId).blockOptional()) + .isEmpty(); + }); + } + + @Test + void deleteMessageInMailboxShouldNotDeleteReferencedMessageMetadata() throws Exception { + MessageManager.AppendResult appendResult = inboxManager.appendMessage(MessageManager.AppendCommand.builder() + .build(ClassLoaderUtils.getSystemResourceAsByteArray("eml/emailWithOnlyAttachment.eml")), session); + mailboxManager.copyMessages(MessageRange.all(), inboxManager.getId(), otherBoxManager.getId(), session); + + inboxManager.delete(ImmutableList.of(appendResult.getId().getUid()), session); + + SoftAssertions.assertSoftly(softly -> { + PostgresMessageId messageId = (PostgresMessageId) appendResult.getId().getMessageId(); + + softly.assertThat(postgresMessageDAO.getBlobId(messageId).blockOptional()) + .isNotEmpty(); + }); + } + + @Test + void deleteMailboxShouldEventuallyDeleteUnreferencedMessageMetadataWhenDeletingMailboxMessageFail() throws Exception { + doReturn(Flux.error(new RuntimeException("Fake exception"))) + .doCallRealMethod() + .when(postgresMailboxMessageDAO).deleteByMailboxId(Mockito.any()); + + MessageManager.AppendResult appendResult = inboxManager.appendMessage(MessageManager.AppendCommand.builder() + .build(ClassLoaderUtils.getSystemResourceAsByteArray("eml/emailWithOnlyAttachment.eml")), session); + + mailboxManager.deleteMailbox(inbox, session); + + SoftAssertions.assertSoftly(softly -> { + PostgresMessageId messageId = (PostgresMessageId) appendResult.getId().getMessageId(); + PostgresMailboxId mailboxId = (PostgresMailboxId) appendResult.getId().getMailboxId(); + + softly.assertThat(postgresMessageDAO.getBlobId(messageId).blockOptional()) + .isEmpty(); + + softly.assertThat(postgresMailboxMessageDAO.countTotalMessagesByMailboxId(mailboxId).block()) + .isEqualTo(0); + }); + } + + @Test + void deleteMessageInMailboxShouldEventuallyDeleteUnreferencedMessageMetadataWhenDeletingMessageFail() throws Exception { + doReturn(Mono.error(new RuntimeException("Fake exception"))) + .doCallRealMethod() + .when(postgresMessageDAO).deleteByMessageId(Mockito.any()); + + MessageManager.AppendResult appendResult = inboxManager.appendMessage(MessageManager.AppendCommand.builder() + .build(ClassLoaderUtils.getSystemResourceAsByteArray("eml/emailWithOnlyAttachment.eml")), session); + + inboxManager.delete(ImmutableList.of(appendResult.getId().getUid()), session); + + SoftAssertions.assertSoftly(softly -> { + PostgresMessageId messageId = (PostgresMessageId) appendResult.getId().getMessageId(); + + softly.assertThat(postgresMessageDAO.getBlobId(messageId).blockOptional()) + .isEmpty(); + }); + } + } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/AllSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/AllSearchOverrideTest.java index 821c1ce05e8..abe02beb2bf 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/AllSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/AllSearchOverrideTest.java @@ -27,6 +27,7 @@ import javax.mail.Flags; import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.blob.api.HashBlobId; import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.SearchQuery; @@ -49,7 +50,7 @@ public class AllSearchOverrideTest { @BeforeEach void setUp() { - postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor()); + postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), new HashBlobId.Factory()); postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); testee = new AllSearchOverride(postgresMailboxMessageDAO); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverrideTest.java index 0bd37d2dc96..8f2607d4059 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverrideTest.java @@ -29,6 +29,7 @@ import javax.mail.Flags; import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.blob.api.HashBlobId; import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.SearchQuery; @@ -50,7 +51,7 @@ public class DeletedSearchOverrideTest { @BeforeEach void setUp() { - postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor()); + postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), new HashBlobId.Factory()); postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); testee = new DeletedSearchOverride(postgresMailboxMessageDAO); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverrideTest.java index 74d97339c1a..91496cad62c 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverrideTest.java @@ -29,6 +29,7 @@ import javax.mail.Flags; import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.blob.api.HashBlobId; import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.SearchQuery; @@ -50,7 +51,7 @@ public class DeletedWithRangeSearchOverrideTest { @BeforeEach void setUp() { - postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor()); + postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), new HashBlobId.Factory()); postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); testee = new DeletedWithRangeSearchOverride(postgresMailboxMessageDAO); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverrideTest.java index dcbdb7401cc..79b919ab58e 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverrideTest.java @@ -28,6 +28,7 @@ import javax.mail.Flags; import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.blob.api.HashBlobId; import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.SearchQuery; @@ -50,7 +51,7 @@ public class NotDeletedWithRangeSearchOverrideTest { @BeforeEach void setUp() { - postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor()); + postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), new HashBlobId.Factory()); postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); testee = new NotDeletedWithRangeSearchOverride(postgresMailboxMessageDAO); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UidSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UidSearchOverrideTest.java index a42a3300d08..0f6d47a143f 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UidSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UidSearchOverrideTest.java @@ -27,6 +27,7 @@ import javax.mail.Flags; import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.blob.api.HashBlobId; import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.SearchQuery; @@ -49,7 +50,7 @@ public class UidSearchOverrideTest { @BeforeEach void setUp() { - postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor()); + postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), new HashBlobId.Factory()); postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); testee = new UidSearchOverride(postgresMailboxMessageDAO); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverrideTest.java index 84bd658a4e4..8aa38b6edaa 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverrideTest.java @@ -28,6 +28,7 @@ import javax.mail.Flags; import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.blob.api.HashBlobId; import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.SearchQuery; @@ -49,7 +50,7 @@ public class UnseenSearchOverrideTest { @BeforeEach void setUp() { - postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor()); + postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), new HashBlobId.Factory()); postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); testee = new UnseenSearchOverride(postgresMailboxMessageDAO); } diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java index bd4609e01a2..348f5222f98 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java @@ -42,12 +42,15 @@ import org.apache.james.mailbox.indexer.ReIndexer; import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.postgres.DeleteMessageListener; import org.apache.james.mailbox.postgres.PostgresAttachmentContentLoader; import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; import org.apache.james.mailbox.postgres.PostgresMailboxId; import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; import org.apache.james.mailbox.postgres.PostgresMessageId; import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.store.MailboxManagerConfiguration; import org.apache.james.mailbox.store.MailboxSessionMapperFactory; import org.apache.james.mailbox.store.NoMailboxPathLocker; @@ -116,6 +119,9 @@ protected void configure() { bind(ReIndexer.class).to(ReIndexerImpl.class); + bind(PostgresMessageDAO.class).in(Scopes.SINGLETON); + bind(PostgresMailboxMessageDAO.class).in(Scopes.SINGLETON); + Multibinder.newSetBinder(binder(), MailboxManagerDefinition.class).addBinding().to(PostgresMailboxManagerDefinition.class); Multibinder.newSetBinder(binder(), EventListener.GroupEventListener.class) @@ -126,6 +132,9 @@ protected void configure() { .addBinding() .to(MailboxSubscriptionListener.class); + Multibinder.newSetBinder(binder(), EventListener.ReactiveGroupEventListener.class) + .addBinding().to(DeleteMessageListener.class); + bind(MailboxManager.class).annotatedWith(Names.named(MAILBOXMANAGER_NAME)).to(MailboxManager.class); bind(MailboxManagerConfiguration.class).toInstance(MailboxManagerConfiguration.DEFAULT); From c9accb2b1a6d54778c4dc76cedbbab970f6b625f Mon Sep 17 00:00:00 2001 From: vttran Date: Tue, 26 Dec 2023 09:48:42 +0700 Subject: [PATCH 161/341] JAMES-2586 Fixup search overrides - Using Postgres Factory Executor replace to invoke DAO directly (#1880) --- .../postgres/search/AllSearchOverride.java | 11 ++++++---- .../search/DeletedSearchOverride.java | 11 ++++++---- .../DeletedWithRangeSearchOverride.java | 15 +++++++------ .../NotDeletedWithRangeSearchOverride.java | 16 ++++++++------ .../postgres/search/UidSearchOverride.java | 16 +++++++------- .../postgres/search/UnseenSearchOverride.java | 21 ++++++++++++------- .../search/AllSearchOverrideTest.java | 2 +- .../search/DeletedSearchOverrideTest.java | 2 +- .../DeletedWithRangeSearchOverrideTest.java | 2 +- ...NotDeletedWithRangeSearchOverrideTest.java | 2 +- .../search/UidSearchOverrideTest.java | 2 +- .../search/UnseenSearchOverrideTest.java | 2 +- 12 files changed, 61 insertions(+), 41 deletions(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/AllSearchOverride.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/AllSearchOverride.java index f073ae061b8..a11a9db3d8d 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/AllSearchOverride.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/AllSearchOverride.java @@ -21,6 +21,7 @@ import javax.inject.Inject; +import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.mailbox.MailboxSession; import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.model.Mailbox; @@ -30,13 +31,14 @@ import org.apache.james.mailbox.store.search.ListeningMessageSearchIndex; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; public class AllSearchOverride implements ListeningMessageSearchIndex.SearchOverride { - private final PostgresMailboxMessageDAO dao; + private final PostgresExecutor.Factory executorFactory; @Inject - public AllSearchOverride(PostgresMailboxMessageDAO dao) { - this.dao = dao; + public AllSearchOverride(PostgresExecutor.Factory executorFactory) { + this.executorFactory = executorFactory; } @Override @@ -65,6 +67,7 @@ private boolean isEmpty(SearchQuery searchQuery) { @Override public Flux search(MailboxSession session, Mailbox mailbox, SearchQuery searchQuery) { - return dao.listAllMessageUid((PostgresMailboxId) mailbox.getMailboxId()); + return Mono.fromCallable(() -> new PostgresMailboxMessageDAO(executorFactory.create(session.getUser().getDomainPart()))) + .flatMapMany(dao -> dao.listAllMessageUid((PostgresMailboxId) mailbox.getMailboxId())); } } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverride.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverride.java index 48f365274e2..87a4d68ddbf 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverride.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverride.java @@ -22,6 +22,7 @@ import javax.inject.Inject; import javax.mail.Flags; +import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.mailbox.MailboxSession; import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.model.Mailbox; @@ -31,13 +32,14 @@ import org.apache.james.mailbox.store.search.ListeningMessageSearchIndex; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; public class DeletedSearchOverride implements ListeningMessageSearchIndex.SearchOverride { - private final PostgresMailboxMessageDAO dao; + private final PostgresExecutor.Factory executorFactory; @Inject - public DeletedSearchOverride(PostgresMailboxMessageDAO dao) { - this.dao = dao; + public DeletedSearchOverride(PostgresExecutor.Factory executorFactory) { + this.executorFactory = executorFactory; } @Override @@ -49,6 +51,7 @@ public boolean applicable(SearchQuery searchQuery, MailboxSession session) { @Override public Flux search(MailboxSession session, Mailbox mailbox, SearchQuery searchQuery) { - return dao.findDeletedMessagesByMailboxId((PostgresMailboxId) mailbox.getMailboxId()); + return Mono.fromCallable(() -> new PostgresMailboxMessageDAO(executorFactory.create(session.getUser().getDomainPart()))) + .flatMapMany(dao -> dao.findDeletedMessagesByMailboxId((PostgresMailboxId) mailbox.getMailboxId())); } } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverride.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverride.java index c463d561041..853abc695d3 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverride.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverride.java @@ -22,6 +22,7 @@ import javax.inject.Inject; import javax.mail.Flags; +import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.mailbox.MailboxSession; import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.model.Mailbox; @@ -33,13 +34,14 @@ import com.google.common.collect.ImmutableList; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; public class DeletedWithRangeSearchOverride implements ListeningMessageSearchIndex.SearchOverride { - private final PostgresMailboxMessageDAO dao; + private final PostgresExecutor.Factory executorFactory; @Inject - public DeletedWithRangeSearchOverride(PostgresMailboxMessageDAO dao) { - this.dao = dao; + public DeletedWithRangeSearchOverride(PostgresExecutor.Factory executorFactory) { + this.executorFactory = executorFactory; } @Override @@ -61,8 +63,9 @@ public Flux search(MailboxSession session, Mailbox mailbox, SearchQu SearchQuery.UidRange[] uidRanges = uidArgument.getOperator().getRange(); - return Flux.fromIterable(ImmutableList.copyOf(uidRanges)) - .concatMap(range -> dao.findDeletedMessagesByMailboxIdAndBetweenUIDs((PostgresMailboxId) mailbox.getMailboxId(), - range.getLowValue(), range.getHighValue())); + return Mono.fromCallable(() -> new PostgresMailboxMessageDAO(executorFactory.create(session.getUser().getDomainPart()))) + .flatMapMany(dao -> Flux.fromIterable(ImmutableList.copyOf(uidRanges)) + .concatMap(range -> dao.findDeletedMessagesByMailboxIdAndBetweenUIDs((PostgresMailboxId) mailbox.getMailboxId(), + range.getLowValue(), range.getHighValue()))); } } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverride.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverride.java index 5e0e342dbac..d604e3681cb 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverride.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverride.java @@ -22,6 +22,7 @@ import javax.inject.Inject; import javax.mail.Flags; +import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.mailbox.MailboxSession; import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.model.Mailbox; @@ -32,13 +33,15 @@ import org.apache.james.mailbox.store.search.ListeningMessageSearchIndex; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; public class NotDeletedWithRangeSearchOverride implements ListeningMessageSearchIndex.SearchOverride { - private final PostgresMailboxMessageDAO dao; + + private final PostgresExecutor.Factory executorFactory; @Inject - public NotDeletedWithRangeSearchOverride(PostgresMailboxMessageDAO dao) { - this.dao = dao; + public NotDeletedWithRangeSearchOverride(PostgresExecutor.Factory executorFactory) { + this.executorFactory = executorFactory; } @Override @@ -72,8 +75,9 @@ public Flux search(MailboxSession session, Mailbox mailbox, SearchQu SearchQuery.UidRange[] uidRanges = uidArgument.getOperator().getRange(); - return Flux.fromArray(uidRanges) - .concatMap(range -> dao.listNotDeletedUids((PostgresMailboxId) mailbox.getMailboxId(), - MessageRange.range(range.getLowValue(), range.getHighValue()))); + return Mono.fromCallable(() -> new PostgresMailboxMessageDAO(executorFactory.create(session.getUser().getDomainPart()))) + .flatMapMany(dao -> Flux.fromArray(uidRanges) + .concatMap(range -> dao.listNotDeletedUids((PostgresMailboxId) mailbox.getMailboxId(), + MessageRange.range(range.getLowValue(), range.getHighValue())))); } } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UidSearchOverride.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UidSearchOverride.java index 9827bc65f85..12e2e73e7d0 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UidSearchOverride.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UidSearchOverride.java @@ -21,6 +21,7 @@ import javax.inject.Inject; +import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.mailbox.MailboxSession; import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.model.Mailbox; @@ -33,13 +34,14 @@ import com.google.common.collect.ImmutableList; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; public class UidSearchOverride implements ListeningMessageSearchIndex.SearchOverride { - private final PostgresMailboxMessageDAO dao; + private final PostgresExecutor.Factory executorFactory; @Inject - public UidSearchOverride(PostgresMailboxMessageDAO dao) { - this.dao = dao; + public UidSearchOverride(PostgresExecutor.Factory executorFactory) { + this.executorFactory = executorFactory; } @Override @@ -58,9 +60,9 @@ public Flux search(MailboxSession session, Mailbox mailbox, SearchQu .orElseThrow(() -> new RuntimeException("Missing Uid argument")); SearchQuery.UidRange[] uidRanges = uidArgument.getOperator().getRange(); - - return Flux.fromIterable(ImmutableList.copyOf(uidRanges)) - .concatMap(range -> dao.listUids((PostgresMailboxId) mailbox.getMailboxId(), - MessageRange.range(range.getLowValue(), range.getHighValue()))); + return Mono.fromCallable(() -> new PostgresMailboxMessageDAO(executorFactory.create(session.getUser().getDomainPart()))) + .flatMapMany(dao -> Flux.fromIterable(ImmutableList.copyOf(uidRanges)) + .concatMap(range -> dao.listUids((PostgresMailboxId) mailbox.getMailboxId(), + MessageRange.range(range.getLowValue(), range.getHighValue())))); } } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverride.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverride.java index 2bef17a9f9b..d269439d846 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverride.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverride.java @@ -19,11 +19,13 @@ package org.apache.james.mailbox.postgres.search; + import java.util.Optional; import javax.inject.Inject; import javax.mail.Flags; +import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.mailbox.MailboxSession; import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.model.Mailbox; @@ -36,13 +38,15 @@ import com.google.common.collect.ImmutableList; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; public class UnseenSearchOverride implements ListeningMessageSearchIndex.SearchOverride { - private final PostgresMailboxMessageDAO dao; + + private final PostgresExecutor.Factory executorFactory; @Inject - public UnseenSearchOverride(PostgresMailboxMessageDAO dao) { - this.dao = dao; + public UnseenSearchOverride(PostgresExecutor.Factory executorFactory) { + this.executorFactory = executorFactory; } @Override @@ -84,10 +88,11 @@ public Flux search(MailboxSession session, Mailbox mailbox, SearchQu .map(SearchQuery.UidCriterion.class::cast) .findFirst(); - return maybeUidCriterion - .map(uidCriterion -> Flux.fromIterable(ImmutableList.copyOf(uidCriterion.getOperator().getRange())) - .concatMap(range -> dao.listUnseen((PostgresMailboxId) mailbox.getMailboxId(), - MessageRange.range(range.getLowValue(), range.getHighValue())))) - .orElseGet(() -> dao.listUnseen((PostgresMailboxId) mailbox.getMailboxId())); + return Mono.fromCallable(() -> new PostgresMailboxMessageDAO(executorFactory.create(session.getUser().getDomainPart()))) + .flatMapMany(dao -> maybeUidCriterion + .map(uidCriterion -> Flux.fromIterable(ImmutableList.copyOf(uidCriterion.getOperator().getRange())) + .concatMap(range -> dao.listUnseen((PostgresMailboxId) mailbox.getMailboxId(), + MessageRange.range(range.getLowValue(), range.getHighValue())))) + .orElseGet(() -> dao.listUnseen((PostgresMailboxId) mailbox.getMailboxId()))); } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/AllSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/AllSearchOverrideTest.java index abe02beb2bf..04fdbfbd3ac 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/AllSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/AllSearchOverrideTest.java @@ -52,7 +52,7 @@ public class AllSearchOverrideTest { void setUp() { postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), new HashBlobId.Factory()); postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); - testee = new AllSearchOverride(postgresMailboxMessageDAO); + testee = new AllSearchOverride(postgresExtension.getExecutorFactory()); } @Test diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverrideTest.java index 8f2607d4059..82cb2f17ab9 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverrideTest.java @@ -53,7 +53,7 @@ public class DeletedSearchOverrideTest { void setUp() { postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), new HashBlobId.Factory()); postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); - testee = new DeletedSearchOverride(postgresMailboxMessageDAO); + testee = new DeletedSearchOverride(postgresExtension.getExecutorFactory()); } @Test diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverrideTest.java index 91496cad62c..a7dc79eee12 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverrideTest.java @@ -53,7 +53,7 @@ public class DeletedWithRangeSearchOverrideTest { void setUp() { postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), new HashBlobId.Factory()); postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); - testee = new DeletedWithRangeSearchOverride(postgresMailboxMessageDAO); + testee = new DeletedWithRangeSearchOverride(postgresExtension.getExecutorFactory()); } @Test diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverrideTest.java index 79b919ab58e..7c8fdab2463 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverrideTest.java @@ -53,7 +53,7 @@ public class NotDeletedWithRangeSearchOverrideTest { void setUp() { postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), new HashBlobId.Factory()); postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); - testee = new NotDeletedWithRangeSearchOverride(postgresMailboxMessageDAO); + testee = new NotDeletedWithRangeSearchOverride(postgresExtension.getExecutorFactory()); } @Test diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UidSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UidSearchOverrideTest.java index 0f6d47a143f..45237068a88 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UidSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UidSearchOverrideTest.java @@ -52,7 +52,7 @@ public class UidSearchOverrideTest { void setUp() { postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), new HashBlobId.Factory()); postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); - testee = new UidSearchOverride(postgresMailboxMessageDAO); + testee = new UidSearchOverride(postgresExtension.getExecutorFactory()); } @Test diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverrideTest.java index 8aa38b6edaa..b6d64116264 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverrideTest.java @@ -52,7 +52,7 @@ public class UnseenSearchOverrideTest { void setUp() { postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), new HashBlobId.Factory()); postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); - testee = new UnseenSearchOverride(postgresMailboxMessageDAO); + testee = new UnseenSearchOverride(postgresExtension.getExecutorFactory()); } @Test From 66b1fefc1b55030240908f03112f10131394cc9a Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Mon, 25 Dec 2023 16:41:49 +0700 Subject: [PATCH 162/341] JAMES-2586 Implement PostgresEventDeadLetters --- Jenkinsfile | 3 +- event-bus/pom.xml | 1 + event-bus/postgres/pom.xml | 52 ++++++++ .../events/PostgresEventDeadLetters.java | 120 ++++++++++++++++++ .../PostgresEventDeadLettersModule.java | 59 +++++++++ .../events/PostgresEventDeadLettersTest.java | 35 +++++ pom.xml | 5 + .../apache/james/PostgresJamesServerMain.java | 4 +- .../container/guice/postgres-common/pom.xml | 4 + .../events/PostgresDeadLetterModule.java | 47 +++++++ 10 files changed, 327 insertions(+), 3 deletions(-) create mode 100644 event-bus/postgres/pom.xml create mode 100644 event-bus/postgres/src/main/java/org/apache/james/events/PostgresEventDeadLetters.java create mode 100644 event-bus/postgres/src/main/java/org/apache/james/events/PostgresEventDeadLettersModule.java create mode 100644 event-bus/postgres/src/test/java/org/apache/james/events/PostgresEventDeadLettersTest.java create mode 100644 server/container/guice/postgres-common/src/main/java/org/apache/james/modules/events/PostgresDeadLetterModule.java diff --git a/Jenkinsfile b/Jenkinsfile index 850f502afd0..7849d6c3e93 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -45,7 +45,8 @@ pipeline { 'server/container/guice/postgres-common,' + 'server/container/guice/mailbox-postgres,' + 'server/apps/postgres-app,' + - 'mpt/impl/imap-mailbox/postgres' + 'mpt/impl/imap-mailbox/postgres,' + + 'event-bus/postgres' } tools { diff --git a/event-bus/pom.xml b/event-bus/pom.xml index 64b10dcded6..16a649f4322 100644 --- a/event-bus/pom.xml +++ b/event-bus/pom.xml @@ -34,5 +34,6 @@ cassandra distributed in-vm + postgres diff --git a/event-bus/postgres/pom.xml b/event-bus/postgres/pom.xml new file mode 100644 index 00000000000..df8f19be2c2 --- /dev/null +++ b/event-bus/postgres/pom.xml @@ -0,0 +1,52 @@ + + + 4.0.0 + + org.apache.james + event-bus + 3.9.0-SNAPSHOT + + + dead-letter-postgres + Apache James :: Event Bus :: Dead Letter :: Postgres + In Postgres implementation for the eventDeadLetter API + + + + ${james.groupId} + apache-james-backends-postgres + + + ${james.groupId} + apache-james-backends-postgres + test-jar + test + + + ${james.groupId} + event-bus-api + + + ${james.groupId} + event-bus-api + test-jar + test + + + ${james.groupId} + james-server-guice-common + test-jar + test + + + ${james.groupId} + testing-base + test + + + org.testcontainers + postgresql + test + + + diff --git a/event-bus/postgres/src/main/java/org/apache/james/events/PostgresEventDeadLetters.java b/event-bus/postgres/src/main/java/org/apache/james/events/PostgresEventDeadLetters.java new file mode 100644 index 00000000000..be6db0c7bed --- /dev/null +++ b/event-bus/postgres/src/main/java/org/apache/james/events/PostgresEventDeadLetters.java @@ -0,0 +1,120 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.events; + +import static org.apache.james.events.PostgresEventDeadLettersModule.PostgresEventDeadLettersTable.EVENT; +import static org.apache.james.events.PostgresEventDeadLettersModule.PostgresEventDeadLettersTable.GROUP; +import static org.apache.james.events.PostgresEventDeadLettersModule.PostgresEventDeadLettersTable.INSERTION_ID; +import static org.apache.james.events.PostgresEventDeadLettersModule.PostgresEventDeadLettersTable.TABLE_NAME; + +import javax.inject.Inject; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.jooq.Record; + +import com.github.fge.lambdas.Throwing; +import com.google.common.base.Preconditions; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresEventDeadLetters implements EventDeadLetters { + private final PostgresExecutor postgresExecutor; + private final EventSerializer eventSerializer; + + @Inject + public PostgresEventDeadLetters(PostgresExecutor postgresExecutor, EventSerializer eventSerializer) { + this.postgresExecutor = postgresExecutor; + this.eventSerializer = eventSerializer; + } + + @Override + public Mono store(Group registeredGroup, Event failDeliveredEvent) { + Preconditions.checkArgument(registeredGroup != null, REGISTERED_GROUP_CANNOT_BE_NULL); + Preconditions.checkArgument(failDeliveredEvent != null, FAIL_DELIVERED_EVENT_CANNOT_BE_NULL); + + InsertionId insertionId = InsertionId.random(); + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME) + .set(INSERTION_ID, insertionId.getId()) + .set(GROUP, registeredGroup.asString()) + .set(EVENT, eventSerializer.toJson(failDeliveredEvent)))) + .thenReturn(insertionId); + } + + @Override + public Mono remove(Group registeredGroup, InsertionId failDeliveredInsertionId) { + Preconditions.checkArgument(registeredGroup != null, REGISTERED_GROUP_CANNOT_BE_NULL); + Preconditions.checkArgument(failDeliveredInsertionId != null, FAIL_DELIVERED_ID_INSERTION_CANNOT_BE_NULL); + + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(TABLE_NAME) + .where(INSERTION_ID.eq(failDeliveredInsertionId.getId())))); + } + + @Override + public Mono remove(Group registeredGroup) { + Preconditions.checkArgument(registeredGroup != null, REGISTERED_GROUP_CANNOT_BE_NULL); + + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(TABLE_NAME) + .where(GROUP.eq(registeredGroup.asString())))); + } + + @Override + public Mono failedEvent(Group registeredGroup, InsertionId failDeliveredInsertionId) { + Preconditions.checkArgument(registeredGroup != null, REGISTERED_GROUP_CANNOT_BE_NULL); + Preconditions.checkArgument(failDeliveredInsertionId != null, FAIL_DELIVERED_ID_INSERTION_CANNOT_BE_NULL); + + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select(EVENT) + .from(TABLE_NAME) + .where(INSERTION_ID.eq(failDeliveredInsertionId.getId())))) + .map(this::deserializeEvent); + } + + private Event deserializeEvent(Record record) { + return eventSerializer.asEvent(record.get(EVENT)); + } + + @Override + public Flux failedIds(Group registeredGroup) { + Preconditions.checkArgument(registeredGroup != null, REGISTERED_GROUP_CANNOT_BE_NULL); + + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext + .select(INSERTION_ID) + .from(TABLE_NAME) + .where(GROUP.eq(registeredGroup.asString())))) + .map(record -> InsertionId.of(record.get(INSERTION_ID))); + } + + @Override + public Flux groupsWithFailedEvents() { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext + .selectDistinct(GROUP) + .from(TABLE_NAME))) + .map(Throwing.function(record -> Group.deserialize(record.get(GROUP)))); + } + + @Override + public Mono containEvents() { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext + .select(INSERTION_ID) + .from(TABLE_NAME) + .limit(1))) + .hasElement(); + } +} diff --git a/event-bus/postgres/src/main/java/org/apache/james/events/PostgresEventDeadLettersModule.java b/event-bus/postgres/src/main/java/org/apache/james/events/PostgresEventDeadLettersModule.java new file mode 100644 index 00000000000..28d5809c26a --- /dev/null +++ b/event-bus/postgres/src/main/java/org/apache/james/events/PostgresEventDeadLettersModule.java @@ -0,0 +1,59 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.events; + +import java.util.UUID; + +import org.apache.james.backends.postgres.PostgresIndex; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresEventDeadLettersModule { + interface PostgresEventDeadLettersTable { + Table TABLE_NAME = DSL.table("event_dead_letters"); + + Field INSERTION_ID = DSL.field("insertion_id", SQLDataType.UUID.notNull()); + Field GROUP = DSL.field("\"group\"", SQLDataType.VARCHAR.notNull()); + Field EVENT = DSL.field("event", SQLDataType.VARCHAR.notNull()); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(INSERTION_ID) + .column(GROUP) + .column(EVENT) + .primaryKey(INSERTION_ID))) + .disableRowLevelSecurity() + .build(); + + PostgresIndex GROUP_INDEX = PostgresIndex.name("event_dead_letters_group_index") + .createIndexStep((dsl, indexName) -> dsl.createIndexIfNotExists(indexName) + .on(TABLE_NAME, GROUP)); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(PostgresEventDeadLettersTable.TABLE) + .addIndex(PostgresEventDeadLettersTable.GROUP_INDEX) + .build(); +} diff --git a/event-bus/postgres/src/test/java/org/apache/james/events/PostgresEventDeadLettersTest.java b/event-bus/postgres/src/test/java/org/apache/james/events/PostgresEventDeadLettersTest.java new file mode 100644 index 00000000000..7677f4e3cdb --- /dev/null +++ b/event-bus/postgres/src/test/java/org/apache/james/events/PostgresEventDeadLettersTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.events; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.PostgresModule; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresEventDeadLettersTest implements EventDeadLettersContract.AllContracts { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity( + PostgresModule.aggregateModules(PostgresEventDeadLettersModule.MODULE)); + + @Override + public EventDeadLetters eventDeadLetters() { + return new PostgresEventDeadLetters(postgresExtension.getPostgresExecutor(), new EventBusTestFixture.TestEventSerializer()); + } +} diff --git a/pom.xml b/pom.xml index 2ed99a405eb..e38f093fd87 100644 --- a/pom.xml +++ b/pom.xml @@ -1206,6 +1206,11 @@ dead-letter-cassandra ${project.version} + + ${james.groupId} + dead-letter-postgres + ${project.version} + ${james.groupId} event-bus-api diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index 28434ebe96f..a106e0da41d 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -33,9 +33,9 @@ import org.apache.james.modules.data.PostgresUsersRepositoryModule; import org.apache.james.modules.data.SievePostgresRepositoryModules; import org.apache.james.modules.event.RabbitMQEventBusModule; +import org.apache.james.modules.events.PostgresDeadLetterModule; import org.apache.james.modules.eventstore.MemoryEventStoreModule; import org.apache.james.modules.mailbox.DefaultEventModule; -import org.apache.james.modules.mailbox.MemoryDeadLetterModule; import org.apache.james.modules.mailbox.PostgresMailboxModule; import org.apache.james.modules.mailbox.TikaMailboxModule; import org.apache.james.modules.protocols.IMAPServerModule; @@ -94,13 +94,13 @@ public class PostgresJamesServerMain implements JamesServerMain { new PostgresDelegationStoreModule(), new DefaultProcessorsConfigurationProviderModule(), new PostgresMailboxModule(), + new PostgresDeadLetterModule(), new PostgresDataModule(), new MailboxModule(), new NoJwtModule(), new RawPostDequeueDecoratorModule(), new SievePostgresRepositoryModules(), new TaskManagerModule(), - new MemoryDeadLetterModule(), new MemoryEventStoreModule(), new TikaMailboxModule()); diff --git a/server/container/guice/postgres-common/pom.xml b/server/container/guice/postgres-common/pom.xml index 3ccde23bd96..f6d77993a5c 100644 --- a/server/container/guice/postgres-common/pom.xml +++ b/server/container/guice/postgres-common/pom.xml @@ -44,6 +44,10 @@ test-jar test + + ${james.groupId} + dead-letter-postgres + ${james.groupId} james-server-data-file diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/events/PostgresDeadLetterModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/events/PostgresDeadLetterModule.java new file mode 100644 index 00000000000..9745ea79c1a --- /dev/null +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/events/PostgresDeadLetterModule.java @@ -0,0 +1,47 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.events; + +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.core.healthcheck.HealthCheck; +import org.apache.james.events.EventDeadLetters; +import org.apache.james.events.EventDeadLettersHealthCheck; +import org.apache.james.events.PostgresEventDeadLetters; +import org.apache.james.events.PostgresEventDeadLettersModule; + +import com.google.inject.AbstractModule; +import com.google.inject.Scopes; +import com.google.inject.multibindings.Multibinder; + +public class PostgresDeadLetterModule extends AbstractModule { + @Override + protected void configure() { + Multibinder.newSetBinder(binder(), PostgresModule.class) + .addBinding().toInstance(PostgresEventDeadLettersModule.MODULE); + + bind(PostgresEventDeadLetters.class).in(Scopes.SINGLETON); + bind(EventDeadLetters.class).to(PostgresEventDeadLetters.class); + + bind(EventDeadLettersHealthCheck.class).in(Scopes.SINGLETON); + Multibinder.newSetBinder(binder(), HealthCheck.class) + .addBinding() + .to(EventDeadLettersHealthCheck.class); + } +} From e105aa94fdf35575f2da7cc6c80bbbcbcc613123 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Mon, 25 Dec 2023 21:13:58 +0700 Subject: [PATCH 163/341] JAMES-2586 Fix flaky test DistributedPostgresJamesServerTest.guiceServerShouldUpdateQuota Co-authored-by: Tung Van TRAN --- .../apache/james/DistributedPostgresJamesServerTest.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/DistributedPostgresJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/DistributedPostgresJamesServerTest.java index 9856247b1e3..76635c69c78 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/DistributedPostgresJamesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/DistributedPostgresJamesServerTest.java @@ -107,12 +107,11 @@ void guiceServerShouldUpdateQuota(GuiceJamesServer jamesServer) throws Exception .select(TestIMAPClient.INBOX) .hasAMessage()); - assertThat( - testIMAPClient.connect(JAMES_SERVER_HOST, imapPort) - .login(USER, PASSWORD) - .getQuotaRoot(TestIMAPClient.INBOX)) + AWAIT.untilAsserted(() -> assertThat(testIMAPClient.connect(JAMES_SERVER_HOST, imapPort) + .login(USER, PASSWORD) + .getQuotaRoot(TestIMAPClient.INBOX)) .startsWith("* QUOTAROOT \"INBOX\" #private&toto@james.local\r\n" + "* QUOTA #private&toto@james.local (STORAGE 12 50)\r\n") - .endsWith("OK GETQUOTAROOT completed.\r\n"); + .endsWith("OK GETQUOTAROOT completed.\r\n")); } } From fdc0a73ad9a1dac07f773bf061981b8ae2be50c8 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 26 Dec 2023 09:49:38 +0700 Subject: [PATCH 164/341] JAMES-2586 Add missing license --- event-bus/postgres/pom.xml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/event-bus/postgres/pom.xml b/event-bus/postgres/pom.xml index df8f19be2c2..033ab6dafc1 100644 --- a/event-bus/postgres/pom.xml +++ b/event-bus/postgres/pom.xml @@ -1,4 +1,22 @@ + 4.0.0 From 25c568e34596353a3af731e910d50ad49b88919e Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Thu, 28 Dec 2023 14:03:50 +0700 Subject: [PATCH 165/341] JAMES-2586 Add a health check integration test --- server/apps/postgres-app/pom.xml | 6 +++++ .../DistributedPostgresJamesServerTest.java | 20 ++++++++++++++- .../src/test/resources/webadmin.properties | 25 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 server/apps/postgres-app/src/test/resources/webadmin.properties diff --git a/server/apps/postgres-app/pom.xml b/server/apps/postgres-app/pom.xml index 44151c09569..632d107d095 100644 --- a/server/apps/postgres-app/pom.xml +++ b/server/apps/postgres-app/pom.xml @@ -230,6 +230,12 @@ james-server-testing test + + ${james.groupId} + james-server-webadmin-core + test-jar + test + ${james.groupId} queue-activemq-guice diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/DistributedPostgresJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/DistributedPostgresJamesServerTest.java index 76635c69c78..e34aaed6e20 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/DistributedPostgresJamesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/DistributedPostgresJamesServerTest.java @@ -23,9 +23,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Durations.FIVE_HUNDRED_MILLISECONDS; import static org.awaitility.Durations.ONE_MINUTE; +import static org.hamcrest.Matchers.equalTo; import org.apache.james.PostgresJamesConfiguration.EventBusImpl; import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.core.healthcheck.ResultStatus; import org.apache.james.core.quota.QuotaSizeLimit; import org.apache.james.modules.AwsS3BlobStoreExtension; import org.apache.james.modules.QuotaProbesImpl; @@ -36,14 +38,19 @@ import org.apache.james.utils.DataProbeImpl; import org.apache.james.utils.SMTPMessageSender; import org.apache.james.utils.TestIMAPClient; +import org.apache.james.utils.WebAdminGuiceProbe; +import org.apache.james.webadmin.WebAdminUtils; import org.awaitility.Awaitility; import org.awaitility.core.ConditionFactory; +import org.eclipse.jetty.http.HttpStatus; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; import com.google.common.base.Strings; +import io.restassured.specification.RequestSpecification; + class DistributedPostgresJamesServerTest implements JamesServerConcreteContract { static PostgresExtension postgresExtension = PostgresExtension.empty(); @@ -82,11 +89,13 @@ class DistributedPostgresJamesServerTest implements JamesServerConcreteContract private TestIMAPClient testIMAPClient; private SMTPMessageSender smtpMessageSender; + private RequestSpecification webAdminApi; @BeforeEach - void setUp() { + void setUp(GuiceJamesServer guiceJamesServer) { this.testIMAPClient = new TestIMAPClient(); this.smtpMessageSender = new SMTPMessageSender(DOMAIN); + this.webAdminApi = WebAdminUtils.spec(guiceJamesServer.getProbe(WebAdminGuiceProbe.class).getWebAdminPort()); } @Test @@ -114,4 +123,13 @@ void guiceServerShouldUpdateQuota(GuiceJamesServer jamesServer) throws Exception "* QUOTA #private&toto@james.local (STORAGE 12 50)\r\n") .endsWith("OK GETQUOTAROOT completed.\r\n")); } + + @Test + void healthCheckShouldBeHealthy() { + webAdminApi.when() + .get("/healthcheck") + .then() + .statusCode(HttpStatus.OK_200) + .body("status", equalTo(ResultStatus.HEALTHY.getValue())); + } } diff --git a/server/apps/postgres-app/src/test/resources/webadmin.properties b/server/apps/postgres-app/src/test/resources/webadmin.properties new file mode 100644 index 00000000000..3386a14238a --- /dev/null +++ b/server/apps/postgres-app/src/test/resources/webadmin.properties @@ -0,0 +1,25 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# This template file can be used as example for James Server configuration +# DO NOT USE IT AS SUCH AND ADAPT IT TO YOUR NEEDS + +# Read https://james.apache.org/server/config-webadmin.html for further details + +enabled=true +port=0 +host=127.0.0.1 \ No newline at end of file From 0fe4ea5a08b0e9955d75672e2714750008271959 Mon Sep 17 00:00:00 2001 From: hung phan Date: Tue, 26 Dec 2023 20:51:07 +0700 Subject: [PATCH 166/341] JAMES-2586 Implement PostgresBlobStoreDAO --- server/blob/blob-postgres/pom.xml | 161 ++++++++++++++++++ .../postgres/PostgresBlobStorageModule.java | 62 +++++++ .../blob/postgres/PostgresBlobStoreDAO.java | 156 +++++++++++++++++ .../postgres/PostgresBlobStoreDAOTest.java | 50 ++++++ server/blob/pom.xml | 1 + 5 files changed, 430 insertions(+) create mode 100644 server/blob/blob-postgres/pom.xml create mode 100644 server/blob/blob-postgres/src/main/java/org/apache/james/blob/postgres/PostgresBlobStorageModule.java create mode 100644 server/blob/blob-postgres/src/main/java/org/apache/james/blob/postgres/PostgresBlobStoreDAO.java create mode 100644 server/blob/blob-postgres/src/test/java/org/apache/james/blob/postgres/PostgresBlobStoreDAOTest.java diff --git a/server/blob/blob-postgres/pom.xml b/server/blob/blob-postgres/pom.xml new file mode 100644 index 00000000000..09ab43e02b4 --- /dev/null +++ b/server/blob/blob-postgres/pom.xml @@ -0,0 +1,161 @@ + + + + 4.0.0 + + + org.apache.james + james-server-blob + 3.9.0-SNAPSHOT + ../pom.xml + + + blob-postgres + + Apache James :: Server :: Blob :: Postgres + + + 3.16.22 + 1.0.2.RELEASE + + + + + ${james.groupId} + apache-james-backends-postgres + + + ${james.groupId} + apache-james-backends-postgres + test-jar + test + + + ${james.groupId} + blob-api + + + ${james.groupId} + blob-api + test-jar + test + + + ${james.groupId} + blob-storage-strategy + + + ${james.groupId} + blob-storage-strategy + test-jar + test + + + ${james.groupId} + james-server-guice-common + test-jar + test + + + ${james.groupId} + james-server-testing + test + + + ${james.groupId} + james-server-util + test + + + ${james.groupId} + metrics-tests + test + + + ${james.groupId} + testing-base + test + + + commons-io + commons-io + + + io.projectreactor + reactor-core + + + org.awaitility + awaitility + test + + + org.jooq + jooq + ${jooq.version} + + + org.jooq + jooq-postgres-extensions + ${jooq.version} + + + org.mockito + mockito-core + test + + + org.postgresql + r2dbc-postgresql + ${r2dbc.postgresql.version} + + + org.testcontainers + junit-jupiter + test + + + org.testcontainers + postgresql + test + + + org.testcontainers + testcontainers + test + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + -Djava.library.path= + -javaagent:"${settings.localRepository}"/org/jacoco/org.jacoco.agent/${jacoco-maven-plugin.version}/org.jacoco.agent-${jacoco-maven-plugin.version}-runtime.jar=destfile=${basedir}/target/jacoco.exec + -Xms1024m -Xmx2048m + true + 1800 + + + + + + diff --git a/server/blob/blob-postgres/src/main/java/org/apache/james/blob/postgres/PostgresBlobStorageModule.java b/server/blob/blob-postgres/src/main/java/org/apache/james/blob/postgres/PostgresBlobStorageModule.java new file mode 100644 index 00000000000..d5eab5e4eb5 --- /dev/null +++ b/server/blob/blob-postgres/src/main/java/org/apache/james/blob/postgres/PostgresBlobStorageModule.java @@ -0,0 +1,62 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.blob.postgres; + +import static org.apache.james.blob.postgres.PostgresBlobStorageModule.PostgresBlobStorageTable.BUCKET_NAME_INDEX; +import static org.jooq.impl.SQLDataType.BLOB; + +import org.apache.james.backends.postgres.PostgresIndex; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresBlobStorageModule { + interface PostgresBlobStorageTable { + Table TABLE_NAME = DSL.table("blob_storage"); + + Field BUCKET_NAME = DSL.field("bucket_name", SQLDataType.VARCHAR(200).notNull()); + Field BLOB_ID = DSL.field("blob_id", SQLDataType.VARCHAR(200).notNull()); + Field DATA = DSL.field("data", BLOB.notNull()); + Field SIZE = DSL.field("size", SQLDataType.INTEGER.notNull()); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(BUCKET_NAME) + .column(BLOB_ID) + .column(DATA) + .column(SIZE) + .constraint(DSL.primaryKey(BUCKET_NAME, BLOB_ID)))) + .disableRowLevelSecurity() + .build(); + + PostgresIndex BUCKET_NAME_INDEX = PostgresIndex.name("blob_storage_bucket_name_index") + .createIndexStep((dsl, indexName) -> dsl.createIndexIfNotExists(indexName) + .on(TABLE_NAME, BUCKET_NAME)); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(PostgresBlobStorageTable.TABLE) + .addIndex(BUCKET_NAME_INDEX) + .build(); +} diff --git a/server/blob/blob-postgres/src/main/java/org/apache/james/blob/postgres/PostgresBlobStoreDAO.java b/server/blob/blob-postgres/src/main/java/org/apache/james/blob/postgres/PostgresBlobStoreDAO.java new file mode 100644 index 00000000000..dbbd67abaf6 --- /dev/null +++ b/server/blob/blob-postgres/src/main/java/org/apache/james/blob/postgres/PostgresBlobStoreDAO.java @@ -0,0 +1,156 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.blob.postgres; + +import static org.apache.james.blob.postgres.PostgresBlobStorageModule.PostgresBlobStorageTable.BLOB_ID; +import static org.apache.james.blob.postgres.PostgresBlobStorageModule.PostgresBlobStorageTable.BUCKET_NAME; +import static org.apache.james.blob.postgres.PostgresBlobStorageModule.PostgresBlobStorageTable.DATA; +import static org.apache.james.blob.postgres.PostgresBlobStorageModule.PostgresBlobStorageTable.SIZE; +import static org.apache.james.blob.postgres.PostgresBlobStorageModule.PostgresBlobStorageTable.TABLE_NAME; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Collection; + +import javax.inject.Inject; + +import org.apache.commons.io.IOUtils; +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BlobStoreDAO; +import org.apache.james.blob.api.BucketName; +import org.apache.james.blob.api.ObjectNotFoundException; +import org.apache.james.blob.api.ObjectStoreIOException; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.google.common.io.ByteSource; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresBlobStoreDAO implements BlobStoreDAO { + private final PostgresExecutor postgresExecutor; + private final BlobId.Factory blobIdFactory; + + @Inject + public PostgresBlobStoreDAO(PostgresExecutor postgresExecutor, BlobId.Factory blobIdFactory) { + this.postgresExecutor = postgresExecutor; + this.blobIdFactory = blobIdFactory; + } + + @Override + public InputStream read(BucketName bucketName, BlobId blobId) throws ObjectStoreIOException, ObjectNotFoundException { + return Mono.from(readReactive(bucketName, blobId)) + .block(); + } + + @Override + public Mono readReactive(BucketName bucketName, BlobId blobId) { + return Mono.from(readBytes(bucketName, blobId)) + .map(ByteArrayInputStream::new); + } + + @Override + public Mono readBytes(BucketName bucketName, BlobId blobId) { + return postgresExecutor.executeRow(dsl -> Mono.from(dsl.select(DATA) + .from(TABLE_NAME) + .where(BUCKET_NAME.eq(bucketName.asString())) + .and(BLOB_ID.eq(blobId.asString())))) + .map(record -> record.get(DATA)) + .switchIfEmpty(Mono.error(() -> new ObjectNotFoundException("Blob " + blobId + " does not exist in bucket " + bucketName))); + } + + @Override + public Mono save(BucketName bucketName, BlobId blobId, byte[] data) { + Preconditions.checkNotNull(data); + + return postgresExecutor.executeVoid(dslContext -> + Mono.from(dslContext.insertInto(TABLE_NAME, BUCKET_NAME, BLOB_ID, DATA, SIZE) + .values(bucketName.asString(), + blobId.asString(), + data, + data.length) + .onConflict(BUCKET_NAME, BLOB_ID) + .doUpdate() + .set(DATA, data) + .set(SIZE, data.length))); + } + + @Override + public Mono save(BucketName bucketName, BlobId blobId, InputStream inputStream) { + Preconditions.checkNotNull(inputStream); + + return Mono.fromCallable(() -> { + try { + return IOUtils.toByteArray(inputStream); + } catch (IOException e) { + throw new ObjectStoreIOException("IOException occurred", e); + } + }).flatMap(bytes -> save(bucketName, blobId, bytes)); + } + + @Override + public Mono save(BucketName bucketName, BlobId blobId, ByteSource content) { + return Mono.fromCallable(() -> { + try { + return content.read(); + } catch (IOException e) { + throw new ObjectStoreIOException("IOException occurred", e); + } + }).flatMap(bytes -> save(bucketName, blobId, bytes)); + } + + @Override + public Mono delete(BucketName bucketName, BlobId blobId) { + return postgresExecutor.executeVoid(dsl -> Mono.from(dsl.deleteFrom(TABLE_NAME) + .where(BUCKET_NAME.eq(bucketName.asString())) + .and(BLOB_ID.eq(blobId.asString())))); + } + + @Override + public Mono delete(BucketName bucketName, Collection blobIds) { + return postgresExecutor.executeVoid(dsl -> Mono.from(dsl.deleteFrom(TABLE_NAME) + .where(BUCKET_NAME.eq(bucketName.asString())) + .and(BLOB_ID.in(blobIds.stream().map(BlobId::asString).collect(ImmutableList.toImmutableList()))))); + } + + @Override + public Mono deleteBucket(BucketName bucketName) { + return postgresExecutor.executeVoid(dsl -> Mono.from(dsl.deleteFrom(TABLE_NAME) + .where(BUCKET_NAME.eq(bucketName.asString())))); + } + + @Override + public Flux listBuckets() { + return postgresExecutor.executeRows(dsl -> Flux.from(dsl.selectDistinct(BUCKET_NAME) + .from(TABLE_NAME))) + .map(record -> BucketName.of(record.get(BUCKET_NAME))); + } + + @Override + public Flux listBlobs(BucketName bucketName) { + return postgresExecutor.executeRows(dsl -> Flux.from(dsl.select(BLOB_ID) + .from(TABLE_NAME) + .where(BUCKET_NAME.eq(bucketName.asString())))) + .map(record -> blobIdFactory.from(record.get(BLOB_ID))); + } +} diff --git a/server/blob/blob-postgres/src/test/java/org/apache/james/blob/postgres/PostgresBlobStoreDAOTest.java b/server/blob/blob-postgres/src/test/java/org/apache/james/blob/postgres/PostgresBlobStoreDAOTest.java new file mode 100644 index 00000000000..c053232632c --- /dev/null +++ b/server/blob/blob-postgres/src/test/java/org/apache/james/blob/postgres/PostgresBlobStoreDAOTest.java @@ -0,0 +1,50 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.blob.postgres; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.blob.api.BlobStoreDAO; +import org.apache.james.blob.api.BlobStoreDAOContract; +import org.apache.james.blob.api.HashBlobId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.extension.RegisterExtension; + +class PostgresBlobStoreDAOTest implements BlobStoreDAOContract { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresBlobStorageModule.MODULE); + + private PostgresBlobStoreDAO blobStore; + + @BeforeEach + void setUp() { + blobStore = new PostgresBlobStoreDAO(postgresExtension.getPostgresExecutor(), new HashBlobId.Factory()); + } + + @Override + public BlobStoreDAO testee() { + return blobStore; + } + + @Override + @Disabled("Not supported") + public void listBucketsShouldReturnBucketsWithNoBlob() { + } +} diff --git a/server/blob/pom.xml b/server/blob/pom.xml index d429b1ad4fa..bd2aaa9f6ba 100644 --- a/server/blob/pom.xml +++ b/server/blob/pom.xml @@ -41,6 +41,7 @@ blob-export-file blob-file blob-memory + blob-postgres blob-s3 blob-storage-strategy From 48d8bf586bdc40e255ce720edd738b72d6da1645 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 3 Jan 2024 08:33:15 +0700 Subject: [PATCH 167/341] JAMES-2586 Disable concurrent test of PostgresBlobStoreDAO - The test is not valid because the upload parallelism with big blobs takes time and the test does not waiting for the end of the upload --- .../postgres/PostgresBlobStoreDAOTest.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/server/blob/blob-postgres/src/test/java/org/apache/james/blob/postgres/PostgresBlobStoreDAOTest.java b/server/blob/blob-postgres/src/test/java/org/apache/james/blob/postgres/PostgresBlobStoreDAOTest.java index c053232632c..7ef69a03906 100644 --- a/server/blob/blob-postgres/src/test/java/org/apache/james/blob/postgres/PostgresBlobStoreDAOTest.java +++ b/server/blob/blob-postgres/src/test/java/org/apache/james/blob/postgres/PostgresBlobStoreDAOTest.java @@ -47,4 +47,26 @@ public BlobStoreDAO testee() { @Disabled("Not supported") public void listBucketsShouldReturnBucketsWithNoBlob() { } + + @Override + @Disabled("The test is not valid because the upload parallelism with big blobs takes time and the test does not waiting for the end of the upload") + public void concurrentSaveByteSourceShouldReturnConsistentValues(String description, byte[] bytes) { + } + + @Override + @Disabled("The test is not valid because the upload parallelism with big blobs takes time and the test does not waiting for the end of the upload") + public void concurrentSaveInputStreamShouldReturnConsistentValues(String description, byte[] bytes) { + } + + @Override + @Disabled("The test is not valid because the upload parallelism with big blobs takes time and the test does not waiting for the end of the upload") + public void concurrentSaveBytesShouldReturnConsistentValues(String description, byte[] bytes) { + } + + @Override + @Disabled("The test is not valid because the upload parallelism with big blobs takes time and the test does not waiting for the end of the upload") + public void mixingSaveReadAndDeleteShouldReturnConsistentState() { + } + + } From 1657998746fc532e1be5614b8e33f29d757ce215 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 27 Dec 2023 16:11:35 +0700 Subject: [PATCH 168/341] JAMES-2586 [PGSQL] Guice binding Postgres BlobStore & Adapt to BlobStoreModulesChooser --- pom.xml | 10 ++++ .../PostgresBlobStoreIntegrationTest.java | 59 +++++++++++++++++++ server/container/guice/blob/postgres/pom.xml | 53 +++++++++++++++++ .../main/java/modules/BlobPostgresModule.java | 35 +++++++++++ server/container/guice/distributed/pom.xml | 4 ++ .../blobstore/BlobStoreConfiguration.java | 7 ++- .../blobstore/BlobStoreModulesChooser.java | 16 +++++ .../blobstore/BlobStoreConfigurationTest.java | 17 ++++++ server/container/guice/pom.xml | 1 + 9 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 server/apps/postgres-app/src/test/java/org/apache/james/PostgresBlobStoreIntegrationTest.java create mode 100644 server/container/guice/blob/postgres/pom.xml create mode 100644 server/container/guice/blob/postgres/src/main/java/modules/BlobPostgresModule.java diff --git a/pom.xml b/pom.xml index e38f093fd87..c10fcc35d25 100644 --- a/pom.xml +++ b/pom.xml @@ -1168,6 +1168,16 @@ blob-memory-guice ${project.version} + + ${james.groupId} + blob-postgres + ${project.version} + + + ${james.groupId} + blob-postgres-guice + ${project.version} + ${james.groupId} blob-s3 diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresBlobStoreIntegrationTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresBlobStoreIntegrationTest.java new file mode 100644 index 00000000000..72d8bab7475 --- /dev/null +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresBlobStoreIntegrationTest.java @@ -0,0 +1,59 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.modules.blobstore.BlobStoreConfiguration; +import org.apache.james.modules.protocols.ImapGuiceProbe; +import org.apache.james.modules.protocols.SmtpGuiceProbe; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresBlobStoreIntegrationTest implements MailsShouldBeWellReceived { + + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.scanning()) + .blobStore(BlobStoreConfiguration.builder() + .postgres() + .disableCache() + .deduplication() + .noCryptoConfig()) + .usersRepository(DEFAULT) + .build()) + .server(PostgresJamesServerMain::createServer) + .extension(PostgresExtension.empty()) + .build(); + + @Override + public int imapPort(GuiceJamesServer server) { + return server.getProbe(ImapGuiceProbe.class).getImapPort(); + } + + @Override + public int smtpPort(GuiceJamesServer server) { + return server.getProbe(SmtpGuiceProbe.class).getSmtpPort().getValue(); + } + +} \ No newline at end of file diff --git a/server/container/guice/blob/postgres/pom.xml b/server/container/guice/blob/postgres/pom.xml new file mode 100644 index 00000000000..f42dcd8ea60 --- /dev/null +++ b/server/container/guice/blob/postgres/pom.xml @@ -0,0 +1,53 @@ + + + + + 4.0.0 + + org.apache.james + james-server-guice + 3.9.0-SNAPSHOT + ../../pom.xml + + + blob-postgres-guice + jar + + Apache James :: Server :: Blob Postgres - guice injection + Blob modules on Postgres storage + + + + ${james.groupId} + blob-api + + + ${james.groupId} + blob-postgres + + + com.google.inject + guice + + + + \ No newline at end of file diff --git a/server/container/guice/blob/postgres/src/main/java/modules/BlobPostgresModule.java b/server/container/guice/blob/postgres/src/main/java/modules/BlobPostgresModule.java new file mode 100644 index 00000000000..162e1176a78 --- /dev/null +++ b/server/container/guice/blob/postgres/src/main/java/modules/BlobPostgresModule.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package modules; + +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.blob.postgres.PostgresBlobStorageModule; + +import com.google.inject.AbstractModule; +import com.google.inject.multibindings.Multibinder; + +public class BlobPostgresModule extends AbstractModule { + + @Override + protected void configure() { + Multibinder postgresDataDefinitions = Multibinder.newSetBinder(binder(), PostgresModule.class); + postgresDataDefinitions.addBinding().toInstance(PostgresBlobStorageModule.MODULE); + } +} diff --git a/server/container/guice/distributed/pom.xml b/server/container/guice/distributed/pom.xml index f8c2c965dee..2df8a3efc16 100644 --- a/server/container/guice/distributed/pom.xml +++ b/server/container/guice/distributed/pom.xml @@ -63,6 +63,10 @@ ${james.groupId} blob-file + + ${james.groupId} + blob-postgres-guice + ${james.groupId} blob-s3-guice diff --git a/server/container/guice/distributed/src/main/java/org/apache/james/modules/blobstore/BlobStoreConfiguration.java b/server/container/guice/distributed/src/main/java/org/apache/james/modules/blobstore/BlobStoreConfiguration.java index 09ec3a7e6e3..84d39ca0d5a 100644 --- a/server/container/guice/distributed/src/main/java/org/apache/james/modules/blobstore/BlobStoreConfiguration.java +++ b/server/container/guice/distributed/src/main/java/org/apache/james/modules/blobstore/BlobStoreConfiguration.java @@ -59,6 +59,10 @@ default RequireCache file() { default RequireCache s3() { return implementation(BlobStoreImplName.S3); } + + default RequireCache postgres() { + return implementation(BlobStoreImplName.POSTGRES); + } } @FunctionalInterface @@ -108,7 +112,8 @@ public static RequireImplementation builder() { public enum BlobStoreImplName { CASSANDRA("cassandra"), FILE("file"), - S3("s3"); + S3("s3"), + POSTGRES("postgres"); static String supportedImplNames() { return Stream.of(BlobStoreImplName.values()) diff --git a/server/container/guice/distributed/src/main/java/org/apache/james/modules/blobstore/BlobStoreModulesChooser.java b/server/container/guice/distributed/src/main/java/org/apache/james/modules/blobstore/BlobStoreModulesChooser.java index 708187101d5..040e61c1be5 100644 --- a/server/container/guice/distributed/src/main/java/org/apache/james/modules/blobstore/BlobStoreModulesChooser.java +++ b/server/container/guice/distributed/src/main/java/org/apache/james/modules/blobstore/BlobStoreModulesChooser.java @@ -31,6 +31,7 @@ import org.apache.james.blob.cassandra.cache.CachedBlobStore; import org.apache.james.blob.file.FileBlobStoreDAO; import org.apache.james.blob.objectstorage.aws.S3BlobStoreDAO; +import org.apache.james.blob.postgres.PostgresBlobStoreDAO; import org.apache.james.core.healthcheck.HealthCheck; import org.apache.james.modules.blobstore.validation.BlobStoreConfigurationValidationStartUpCheck.StorageStrategySupplier; import org.apache.james.modules.blobstore.validation.StoragePolicyConfigurationSanityEnforcementModule; @@ -53,6 +54,8 @@ import com.google.inject.name.Named; import com.google.inject.name.Names; +import modules.BlobPostgresModule; + public class BlobStoreModulesChooser { private static final String UNENCRYPTED = "unencrypted"; @@ -87,6 +90,17 @@ protected void configure() { } } + static class PostgresBlobStoreDAODeclarationModule extends AbstractModule { + @Override + protected void configure() { + install(new BlobPostgresModule()); + + install(new DefaultBucketModule()); + + bind(BlobStoreDAO.class).annotatedWith(Names.named(UNENCRYPTED)).to(PostgresBlobStoreDAO.class); + } + } + static class NoEncryptionModule extends AbstractModule { @Provides @Singleton @@ -133,6 +147,8 @@ public static Module chooseBlobStoreDAOModule(BlobStoreConfiguration.BlobStoreIm return new ObjectStorageBlobStoreDAODeclarationModule(); case FILE: return new FileBlobStoreDAODeclarationModule(); + case POSTGRES: + return new PostgresBlobStoreDAODeclarationModule(); default: throw new RuntimeException("Unsupported blobStore implementation " + implementation); } diff --git a/server/container/guice/distributed/src/test/java/org/apache/james/modules/blobstore/BlobStoreConfigurationTest.java b/server/container/guice/distributed/src/test/java/org/apache/james/modules/blobstore/BlobStoreConfigurationTest.java index 4bd6ea3ece6..3fc6cffb0bd 100644 --- a/server/container/guice/distributed/src/test/java/org/apache/james/modules/blobstore/BlobStoreConfigurationTest.java +++ b/server/container/guice/distributed/src/test/java/org/apache/james/modules/blobstore/BlobStoreConfigurationTest.java @@ -234,6 +234,23 @@ void provideChoosingConfigurationShouldReturnFileFactoryWhenConfigurationImplIsF .noCryptoConfig()); } + @Test + void provideChoosingConfigurationShouldReturnPostgresFactoryWhenConfigurationImplIsPostgres() throws Exception { + PropertiesConfiguration configuration = new PropertiesConfiguration(); + configuration.addProperty("implementation", BlobStoreConfiguration.BlobStoreImplName.POSTGRES.getName()); + configuration.addProperty("deduplication.enable", "false"); + FakePropertiesProvider propertyProvider = FakePropertiesProvider.builder() + .register(ConfigurationComponent.NAME, configuration) + .build(); + + assertThat(parse(propertyProvider)) + .isEqualTo(BlobStoreConfiguration.builder() + .postgres() + .disableCache() + .passthrough() + .noCryptoConfig()); + } + @Test void fromShouldThrowWhenBlobStoreImplIsMissing() { PropertiesConfiguration configuration = new PropertiesConfiguration(); diff --git a/server/container/guice/pom.xml b/server/container/guice/pom.xml index 10a863ee767..fc46cff23fb 100644 --- a/server/container/guice/pom.xml +++ b/server/container/guice/pom.xml @@ -37,6 +37,7 @@ blob/deduplication-gc blob/export blob/memory + blob/postgres blob/s3 cassandra common From 5841191e48a2e6049e0ad68646c6b4c163ac9448 Mon Sep 17 00:00:00 2001 From: Benoit TELLIER Date: Fri, 5 Jan 2024 08:12:01 +0100 Subject: [PATCH 169/341] JAMES-2586 Adopt Postgres 16.1 (#1897) --- .../org/apache/james/backends/postgres/PostgresFixture.java | 2 +- server/apps/postgres-app/docker-compose-distributed.yml | 2 +- server/apps/postgres-app/docker-compose.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresFixture.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresFixture.java index 6c003f7ad9b..897943a75cb 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresFixture.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresFixture.java @@ -88,7 +88,7 @@ public String schema() { } } - String IMAGE = "postgres:16"; + String IMAGE = "postgres:16.1"; Integer PORT = POSTGRESQL_PORT; Supplier> PG_CONTAINER = () -> new PostgreSQLContainer<>(IMAGE) .withDatabaseName(DEFAULT_DATABASE.dbName()) diff --git a/server/apps/postgres-app/docker-compose-distributed.yml b/server/apps/postgres-app/docker-compose-distributed.yml index 95bc9b03900..ddf5d3cc948 100644 --- a/server/apps/postgres-app/docker-compose-distributed.yml +++ b/server/apps/postgres-app/docker-compose-distributed.yml @@ -49,7 +49,7 @@ services: - james postgres: - image: postgres:16.0 + image: postgres:16.1 container_name: postgres ports: - "5432:5432" diff --git a/server/apps/postgres-app/docker-compose.yml b/server/apps/postgres-app/docker-compose.yml index c8d5f8f995b..50440253bde 100644 --- a/server/apps/postgres-app/docker-compose.yml +++ b/server/apps/postgres-app/docker-compose.yml @@ -23,7 +23,7 @@ services: - ./sample-configuration-single/search.properties:/root/conf/search.properties postgres: - image: postgres:16.0 + image: postgres:16.1 ports: - "5432:5432" environment: From 9dfb84b546d527c2a9439b05c2cd6ba95f677b3c Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Sun, 7 Jan 2024 00:09:05 +0700 Subject: [PATCH 170/341] JAMES-2586 Bump jOOQ to 3.16.23 --- backends-common/postgres/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backends-common/postgres/pom.xml b/backends-common/postgres/pom.xml index e204034eccb..1dfa0fa5300 100644 --- a/backends-common/postgres/pom.xml +++ b/backends-common/postgres/pom.xml @@ -29,7 +29,7 @@ Apache James :: Backends Common :: Postgres - 3.16.22 + 3.16.23 1.0.2.RELEASE From 61d9d600dd3aa744ae721ded8b31dc0578e65417 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Sun, 7 Jan 2024 00:18:57 +0700 Subject: [PATCH 171/341] JAMES-2586 Bump r2dbc-postgresql to 1.0.3.RELEASE --- backends-common/postgres/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backends-common/postgres/pom.xml b/backends-common/postgres/pom.xml index 1dfa0fa5300..2ec6e658a5d 100644 --- a/backends-common/postgres/pom.xml +++ b/backends-common/postgres/pom.xml @@ -30,7 +30,7 @@ 3.16.23 - 1.0.2.RELEASE + 1.0.3.RELEASE From 7af5e318e93a10af79597ac9655059599ea8e609 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Fri, 5 Jan 2024 15:56:12 +0700 Subject: [PATCH 172/341] JAMES-2586 - Update test cases for Delete message listener - when enabling Row level security - Remove old test cases from PostgresMailboxManagerTest - Create a contract test for the listener with 2 implementations: withoutRLS and withRLS --- .../DeleteMessageListenerContract.java | 147 ++++++++++++++++ .../postgres/DeleteMessageListenerTest.java | 56 +++++++ .../DeleteMessageListenerWithRLSTest.java | 65 ++++++++ .../postgres/PostgresMailboxManagerTest.java | 157 ------------------ 4 files changed, 268 insertions(+), 157 deletions(-) create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerContract.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerContract.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerContract.java new file mode 100644 index 00000000000..6ca3ce90236 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerContract.java @@ -0,0 +1,147 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres; + +import static org.assertj.core.api.SoftAssertions.assertSoftly; + +import java.util.UUID; + +import org.apache.james.core.Username; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.MessageManager; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.model.MessageRange; +import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.apache.james.util.ClassLoaderUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.google.common.collect.ImmutableList; + +public abstract class DeleteMessageListenerContract { + + private MailboxSession session; + private MailboxPath inbox; + private MessageManager inboxManager; + private MessageManager otherBoxManager; + private PostgresMailboxManager mailboxManager; + private PostgresMessageDAO postgresMessageDAO; + private PostgresMailboxMessageDAO postgresMailboxMessageDAO; + + abstract PostgresMailboxManager provideMailboxManager(); + abstract PostgresMessageDAO providePostgresMessageDAO(); + abstract PostgresMailboxMessageDAO providePostgresMailboxMessageDAO(); + + @BeforeEach + void setUp() throws Exception { + mailboxManager = provideMailboxManager(); + Username username = getUsername(); + session = mailboxManager.createSystemSession(username); + inbox = MailboxPath.inbox(session); + MailboxPath newPath = MailboxPath.forUser(username, "specialMailbox"); + MailboxId inboxId = mailboxManager.createMailbox(inbox, session).get(); + inboxManager = mailboxManager.getMailbox(inboxId, session); + MailboxId otherId = mailboxManager.createMailbox(newPath, session).get(); + otherBoxManager = mailboxManager.getMailbox(otherId, session); + + postgresMessageDAO = providePostgresMessageDAO(); + postgresMailboxMessageDAO = providePostgresMailboxMessageDAO(); + } + + protected Username getUsername() { + return Username.of("user" + UUID.randomUUID()); + } + + @Test + void deleteMailboxShouldDeleteUnreferencedMessageMetadata() throws Exception { + MessageManager.AppendResult appendResult = inboxManager.appendMessage(MessageManager.AppendCommand.builder() + .build(ClassLoaderUtils.getSystemResourceAsByteArray("eml/emailWithOnlyAttachment.eml")), session); + + mailboxManager.deleteMailbox(inbox, session); + + assertSoftly(softly -> { + PostgresMessageId messageId = (PostgresMessageId) appendResult.getId().getMessageId(); + PostgresMailboxId mailboxId = (PostgresMailboxId) appendResult.getId().getMailboxId(); + + softly.assertThat(postgresMessageDAO.getBlobId(messageId).blockOptional()) + .isEmpty(); + + softly.assertThat(postgresMailboxMessageDAO.countTotalMessagesByMailboxId(mailboxId).block()) + .isEqualTo(0); + }); + } + + @Test + void deleteMailboxShouldNotDeleteReferencedMessageMetadata() throws Exception { + MessageManager.AppendResult appendResult = inboxManager.appendMessage(MessageManager.AppendCommand.builder() + .build(ClassLoaderUtils.getSystemResourceAsByteArray("eml/emailWithOnlyAttachment.eml")), session); + mailboxManager.copyMessages(MessageRange.all(), inboxManager.getId(), otherBoxManager.getId(), session); + + mailboxManager.deleteMailbox(inbox, session); + + assertSoftly(softly -> { + PostgresMessageId messageId = (PostgresMessageId) appendResult.getId().getMessageId(); + + softly.assertThat(postgresMessageDAO.getBlobId(messageId).blockOptional()) + .isNotEmpty(); + + softly.assertThat(postgresMailboxMessageDAO.countTotalMessagesByMailboxId((PostgresMailboxId) otherBoxManager.getId()) + .block()) + .isEqualTo(1); + }); + } + + @Test + void deleteMessageInMailboxShouldDeleteUnreferencedMessageMetadata() throws Exception { + MessageManager.AppendResult appendResult = inboxManager.appendMessage(MessageManager.AppendCommand.builder() + .build(ClassLoaderUtils.getSystemResourceAsByteArray("eml/emailWithOnlyAttachment.eml")), session); + + inboxManager.delete(ImmutableList.of(appendResult.getId().getUid()), session); + + assertSoftly(softly -> { + PostgresMessageId messageId = (PostgresMessageId) appendResult.getId().getMessageId(); + + softly.assertThat(postgresMessageDAO.getBlobId(messageId).blockOptional()) + .isEmpty(); + }); + } + + @Test + void deleteMessageInMailboxShouldNotDeleteReferencedMessageMetadata() throws Exception { + MessageManager.AppendResult appendResult = inboxManager.appendMessage(MessageManager.AppendCommand.builder() + .build(ClassLoaderUtils.getSystemResourceAsByteArray("eml/emailWithOnlyAttachment.eml")), session); + mailboxManager.copyMessages(MessageRange.all(), inboxManager.getId(), otherBoxManager.getId(), session); + + inboxManager.delete(ImmutableList.of(appendResult.getId().getUid()), session); + PostgresMessageId messageId = (PostgresMessageId) appendResult.getId().getMessageId(); + + assertSoftly(softly -> { + softly.assertThat(postgresMessageDAO.getBlobId(messageId).blockOptional()) + .isNotEmpty(); + + softly.assertThat(postgresMailboxMessageDAO.countTotalMessagesByMailboxId((PostgresMailboxId) otherBoxManager.getId()) + .block()) + .isEqualTo(1); + }); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java new file mode 100644 index 00000000000..bc769f20426 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java @@ -0,0 +1,56 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres; + +import static org.apache.james.mailbox.postgres.PostgresMailboxManagerProvider.BLOB_ID_FACTORY; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class DeleteMessageListenerTest extends DeleteMessageListenerContract { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); + + private static PostgresMailboxManager mailboxManager; + + @BeforeAll + static void beforeAll() { + mailboxManager = PostgresMailboxManagerProvider.provideMailboxManager(postgresExtension); + } + + @Override + PostgresMailboxManager provideMailboxManager() { + return mailboxManager; + } + + @Override + PostgresMessageDAO providePostgresMessageDAO() { + return new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), BLOB_ID_FACTORY); + } + + @Override + PostgresMailboxMessageDAO providePostgresMailboxMessageDAO() { + return new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java new file mode 100644 index 00000000000..996ceddb721 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java @@ -0,0 +1,65 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres; + +import static org.apache.james.mailbox.postgres.PostgresMailboxManagerProvider.BLOB_ID_FACTORY; + +import java.util.UUID; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.core.Username; +import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class DeleteMessageListenerWithRLSTest extends DeleteMessageListenerContract { + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); + + private static PostgresMailboxManager mailboxManager; + + @BeforeAll + static void beforeAll() { + mailboxManager = PostgresMailboxManagerProvider.provideMailboxManager(postgresExtension); + } + + @Override + PostgresMailboxManager provideMailboxManager() { + return mailboxManager; + } + + @Override + PostgresMessageDAO providePostgresMessageDAO() { + return new PostgresMessageDAO(postgresExtension.getExecutorFactory().create(getUsername().getDomainPart()), BLOB_ID_FACTORY); + } + + @Override + PostgresMailboxMessageDAO providePostgresMailboxMessageDAO() { + return new PostgresMailboxMessageDAO(postgresExtension.getExecutorFactory().create(getUsername().getDomainPart())); + } + + @Override + protected Username getUsername() { + return Username.of("userHasDomain" + UUID.randomUUID() + "@domain1.tld"); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerTest.java index dc6f3a0d745..537a124c969 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerTest.java @@ -18,37 +18,17 @@ ****************************************************************/ package org.apache.james.mailbox.postgres; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.spy; - import java.util.Optional; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.events.EventBus; import org.apache.james.mailbox.MailboxManagerTest; -import org.apache.james.mailbox.MailboxSession; -import org.apache.james.mailbox.MessageManager; import org.apache.james.mailbox.SubscriptionManager; -import org.apache.james.mailbox.model.MailboxId; -import org.apache.james.mailbox.model.MailboxPath; -import org.apache.james.mailbox.model.MessageRange; import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; -import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; -import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.store.StoreSubscriptionManager; -import org.apache.james.util.ClassLoaderUtils; -import org.assertj.core.api.SoftAssertions; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import org.mockito.Mockito; - -import com.google.common.collect.ImmutableList; - -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; class PostgresMailboxManagerTest extends MailboxManagerTest { @@ -80,141 +60,4 @@ protected SubscriptionManager provideSubscriptionManager() { protected EventBus retrieveEventBus(PostgresMailboxManager mailboxManager) { return mailboxManager.getEventBus(); } - - @Nested - class DeletionTests { - private MailboxSession session; - private MailboxPath inbox; - private MailboxId inboxId; - private MessageManager inboxManager; - private MessageManager otherBoxManager; - private MailboxPath newPath; - private PostgresMailboxManager mailboxManager; - private PostgresMessageDAO postgresMessageDAO; - private PostgresMailboxMessageDAO postgresMailboxMessageDAO; - - @BeforeEach - void setUp() throws Exception { - mailboxManager = provideMailboxManager(); - session = mailboxManager.createSystemSession(USER_1); - inbox = MailboxPath.inbox(session); - newPath = MailboxPath.forUser(USER_1, "specialMailbox"); - - inboxId = mailboxManager.createMailbox(inbox, session).get(); - inboxManager = mailboxManager.getMailbox(inbox, session); - MailboxId otherId = mailboxManager.createMailbox(newPath, session).get(); - otherBoxManager = mailboxManager.getMailbox(otherId, session); - - postgresMessageDAO = spy(PostgresMailboxManagerProvider.providePostgresMessageDAO()); - postgresMailboxMessageDAO = spy(PostgresMailboxManagerProvider.providePostgresMailboxMessageDAO()); - } - - @Test - void deleteMailboxShouldDeleteUnreferencedMessageMetadata() throws Exception { - MessageManager.AppendResult appendResult = inboxManager.appendMessage(MessageManager.AppendCommand.builder() - .build(ClassLoaderUtils.getSystemResourceAsByteArray("eml/emailWithOnlyAttachment.eml")), session); - - mailboxManager.deleteMailbox(inbox, session); - - SoftAssertions.assertSoftly(softly -> { - PostgresMessageId messageId = (PostgresMessageId) appendResult.getId().getMessageId(); - PostgresMailboxId mailboxId = (PostgresMailboxId) appendResult.getId().getMailboxId(); - - softly.assertThat(postgresMessageDAO.getBlobId(messageId).blockOptional()) - .isEmpty(); - - softly.assertThat(postgresMailboxMessageDAO.countTotalMessagesByMailboxId(mailboxId).block()) - .isEqualTo(0); - }); - } - - @Test - void deleteMailboxShouldNotDeleteReferencedMessageMetadata() throws Exception { - MessageManager.AppendResult appendResult = inboxManager.appendMessage(MessageManager.AppendCommand.builder() - .build(ClassLoaderUtils.getSystemResourceAsByteArray("eml/emailWithOnlyAttachment.eml")), session); - mailboxManager.copyMessages(MessageRange.all(), inboxManager.getId(), otherBoxManager.getId(), session); - - mailboxManager.deleteMailbox(inbox, session); - - SoftAssertions.assertSoftly(softly -> { - PostgresMessageId messageId = (PostgresMessageId) appendResult.getId().getMessageId(); - - softly.assertThat(postgresMessageDAO.getBlobId(messageId).blockOptional()) - .isNotEmpty(); - }); - } - - @Test - void deleteMessageInMailboxShouldDeleteUnreferencedMessageMetadata() throws Exception { - MessageManager.AppendResult appendResult = inboxManager.appendMessage(MessageManager.AppendCommand.builder() - .build(ClassLoaderUtils.getSystemResourceAsByteArray("eml/emailWithOnlyAttachment.eml")), session); - - inboxManager.delete(ImmutableList.of(appendResult.getId().getUid()), session); - - SoftAssertions.assertSoftly(softly -> { - PostgresMessageId messageId = (PostgresMessageId) appendResult.getId().getMessageId(); - - softly.assertThat(postgresMessageDAO.getBlobId(messageId).blockOptional()) - .isEmpty(); - }); - } - - @Test - void deleteMessageInMailboxShouldNotDeleteReferencedMessageMetadata() throws Exception { - MessageManager.AppendResult appendResult = inboxManager.appendMessage(MessageManager.AppendCommand.builder() - .build(ClassLoaderUtils.getSystemResourceAsByteArray("eml/emailWithOnlyAttachment.eml")), session); - mailboxManager.copyMessages(MessageRange.all(), inboxManager.getId(), otherBoxManager.getId(), session); - - inboxManager.delete(ImmutableList.of(appendResult.getId().getUid()), session); - - SoftAssertions.assertSoftly(softly -> { - PostgresMessageId messageId = (PostgresMessageId) appendResult.getId().getMessageId(); - - softly.assertThat(postgresMessageDAO.getBlobId(messageId).blockOptional()) - .isNotEmpty(); - }); - } - - @Test - void deleteMailboxShouldEventuallyDeleteUnreferencedMessageMetadataWhenDeletingMailboxMessageFail() throws Exception { - doReturn(Flux.error(new RuntimeException("Fake exception"))) - .doCallRealMethod() - .when(postgresMailboxMessageDAO).deleteByMailboxId(Mockito.any()); - - MessageManager.AppendResult appendResult = inboxManager.appendMessage(MessageManager.AppendCommand.builder() - .build(ClassLoaderUtils.getSystemResourceAsByteArray("eml/emailWithOnlyAttachment.eml")), session); - - mailboxManager.deleteMailbox(inbox, session); - - SoftAssertions.assertSoftly(softly -> { - PostgresMessageId messageId = (PostgresMessageId) appendResult.getId().getMessageId(); - PostgresMailboxId mailboxId = (PostgresMailboxId) appendResult.getId().getMailboxId(); - - softly.assertThat(postgresMessageDAO.getBlobId(messageId).blockOptional()) - .isEmpty(); - - softly.assertThat(postgresMailboxMessageDAO.countTotalMessagesByMailboxId(mailboxId).block()) - .isEqualTo(0); - }); - } - - @Test - void deleteMessageInMailboxShouldEventuallyDeleteUnreferencedMessageMetadataWhenDeletingMessageFail() throws Exception { - doReturn(Mono.error(new RuntimeException("Fake exception"))) - .doCallRealMethod() - .when(postgresMessageDAO).deleteByMessageId(Mockito.any()); - - MessageManager.AppendResult appendResult = inboxManager.appendMessage(MessageManager.AppendCommand.builder() - .build(ClassLoaderUtils.getSystemResourceAsByteArray("eml/emailWithOnlyAttachment.eml")), session); - - inboxManager.delete(ImmutableList.of(appendResult.getId().getUid()), session); - - SoftAssertions.assertSoftly(softly -> { - PostgresMessageId messageId = (PostgresMessageId) appendResult.getId().getMessageId(); - - softly.assertThat(postgresMessageDAO.getBlobId(messageId).blockOptional()) - .isEmpty(); - }); - } - } } From f69b805c28b91ec5fa1de9f4f95d18fde3a07fdf Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Fri, 5 Jan 2024 15:58:38 +0700 Subject: [PATCH 173/341] JAMES-2586 - Fix BUG - DeleteMessageListener - not work correctly when enabling RLS --- .../postgres/DeleteMessageListener.java | 48 +++++++++++-------- .../mail/dao/PostgresMailboxMessageDAO.java | 18 ++++++- .../postgres/mail/dao/PostgresMessageDAO.java | 20 +++++++- .../PostgresMailboxManagerProvider.java | 25 ++++------ .../mailbox/PostgresMailboxModule.java | 5 -- 5 files changed, 73 insertions(+), 43 deletions(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java index 72038f2077b..59c87683066 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java @@ -40,16 +40,18 @@ public class DeleteMessageListener implements EventListener.ReactiveGroupEventLi public static class DeleteMessageListenerGroup extends Group { } - private final PostgresMessageDAO postgresMessageDAO; - private final PostgresMailboxMessageDAO postgresMailboxMessageDAO; private final BlobStore blobStore; + private final PostgresMessageDAO.Factory messageDAOFactory; + private final PostgresMailboxMessageDAO.Factory mailboxMessageDAOFactory; + + @Inject - public DeleteMessageListener(PostgresMessageDAO postgresMessageDAO, - PostgresMailboxMessageDAO postgresMailboxMessageDAO, - BlobStore blobStore) { - this.postgresMessageDAO = postgresMessageDAO; - this.postgresMailboxMessageDAO = postgresMailboxMessageDAO; + public DeleteMessageListener(BlobStore blobStore, + PostgresMailboxMessageDAO.Factory mailboxMessageDAOFactory, + PostgresMessageDAO.Factory messageDAOFactory) { + this.messageDAOFactory = messageDAOFactory; + this.mailboxMessageDAOFactory = mailboxMessageDAOFactory; this.blobStore = blobStore; } @@ -71,30 +73,38 @@ public Publisher reactiveEvent(Event event) { } if (event instanceof MailboxDeletion) { MailboxDeletion mailboxDeletion = (MailboxDeletion) event; - PostgresMailboxId mailboxId = (PostgresMailboxId) mailboxDeletion.getMailboxId(); - return handleMailboxDeletion(mailboxId); + return handleMailboxDeletion(mailboxDeletion); } return Mono.empty(); } - private Mono handleMailboxDeletion(PostgresMailboxId mailboxId) { - return postgresMailboxMessageDAO.deleteByMailboxId(mailboxId) - .flatMap(this::handleMessageDeletion) + private Mono handleMailboxDeletion(MailboxDeletion event) { + PostgresMessageDAO postgresMessageDAO = messageDAOFactory.create(event.getUsername().getDomainPart()); + PostgresMailboxMessageDAO postgresMailboxMessageDAO = mailboxMessageDAOFactory.create(event.getUsername().getDomainPart()); + + return postgresMailboxMessageDAO.deleteByMailboxId((PostgresMailboxId) event.getMailboxId()) + .flatMap(msgId -> handleMessageDeletion(msgId, postgresMessageDAO, postgresMailboxMessageDAO)) .then(); } - private Mono handleMessageDeletion(Expunged expunged) { - return Flux.fromIterable(expunged.getExpunged() - .values()) + private Mono handleMessageDeletion(Expunged event) { + PostgresMessageDAO postgresMessageDAO = messageDAOFactory.create(event.getUsername().getDomainPart()); + PostgresMailboxMessageDAO postgresMailboxMessageDAO = mailboxMessageDAOFactory.create(event.getUsername().getDomainPart()); + + return Flux.fromIterable(event.getExpunged() + .values()) .map(MessageMetaData::getMessageId) .map(PostgresMessageId.class::cast) - .flatMap(this::handleMessageDeletion) + .flatMap(msgId -> handleMessageDeletion(msgId, postgresMessageDAO, postgresMailboxMessageDAO)) .then(); } - private Mono handleMessageDeletion(PostgresMessageId messageId) { + + private Mono handleMessageDeletion(PostgresMessageId messageId, + PostgresMessageDAO postgresMessageDAO, + PostgresMailboxMessageDAO postgresMailboxMessageDAO) { return Mono.just(messageId) - .filterWhen(this::isUnreferenced) + .filterWhen(msgId -> isUnreferenced(msgId, postgresMailboxMessageDAO)) .flatMap(id -> postgresMessageDAO.getBlobId(messageId) .flatMap(this::deleteMessageBlobs) .then(postgresMessageDAO.deleteByMessageId(messageId))); @@ -105,7 +115,7 @@ private Mono deleteMessageBlobs(BlobId blobId) { .then(); } - private Mono isUnreferenced(PostgresMessageId id) { + private Mono isUnreferenced(PostgresMessageId id, PostgresMailboxMessageDAO postgresMailboxMessageDAO) { return postgresMailboxMessageDAO.countByMessageId(id) .filter(count -> count == 0) .map(count -> true) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java index 20815d9b987..a267dfc3aa3 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java @@ -50,14 +50,17 @@ import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.RECORD_TO_MESSAGE_UID_FUNCTION; import java.util.List; +import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import javax.inject.Inject; +import javax.inject.Singleton; import javax.mail.Flags; import org.apache.commons.lang3.tuple.Pair; import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.core.Domain; import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.ModSeq; import org.apache.james.mailbox.model.ComposedMessageIdWithMetaData; @@ -92,6 +95,20 @@ public class PostgresMailboxMessageDAO { + public static class Factory { + private final PostgresExecutor.Factory executorFactory; + + @Inject + @Singleton + public Factory(PostgresExecutor.Factory executorFactory) { + this.executorFactory = executorFactory; + } + + public PostgresMailboxMessageDAO create(Optional domain) { + return new PostgresMailboxMessageDAO(executorFactory.create(domain)); + } + } + private static final TableOnConditionStep MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP = TABLE_NAME.join(MessageTable.TABLE_NAME) .on(tableField(TABLE_NAME, MESSAGE_ID).eq(tableField(MessageTable.TABLE_NAME, MessageTable.MESSAGE_ID))); @@ -109,7 +126,6 @@ private static SelectFinalStep> selectMessageUidByMailboxIdAndExtr private final PostgresExecutor postgresExecutor; - @Inject public PostgresMailboxMessageDAO(PostgresExecutor postgresExecutor) { this.postgresExecutor = postgresExecutor; } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java index c2126f64d4c..3bb18c7bb40 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java @@ -43,10 +43,12 @@ import java.util.Optional; import javax.inject.Inject; +import javax.inject.Singleton; import org.apache.commons.io.IOUtils; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.blob.api.BlobId; +import org.apache.james.core.Domain; import org.apache.james.mailbox.postgres.PostgresMessageId; import org.apache.james.mailbox.store.mail.model.MailboxMessage; import org.jooq.postgres.extensions.types.Hstore; @@ -55,11 +57,27 @@ import reactor.core.scheduler.Schedulers; public class PostgresMessageDAO { + + public static class Factory { + private final BlobId.Factory blobIdFactory; + private final PostgresExecutor.Factory executorFactory; + + @Inject + @Singleton + public Factory(BlobId.Factory blobIdFactory, PostgresExecutor.Factory executorFactory) { + this.blobIdFactory = blobIdFactory; + this.executorFactory = executorFactory; + } + + public PostgresMessageDAO create(Optional domain) { + return new PostgresMessageDAO(executorFactory.create(domain), blobIdFactory); + } + } + public static final long DEFAULT_LONG_VALUE = 0L; private final PostgresExecutor postgresExecutor; private final BlobId.Factory blobIdFactory; - @Inject public PostgresMessageDAO(PostgresExecutor postgresExecutor, BlobId.Factory blobIdFactory) { this.postgresExecutor = postgresExecutor; this.blobIdFactory = blobIdFactory; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java index ae3954a83ec..ccdcf906ce6 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java @@ -57,13 +57,11 @@ public class PostgresMailboxManagerProvider { private static final int LIMIT_ANNOTATIONS = 3; private static final int LIMIT_ANNOTATION_SIZE = 30; - private static PostgresMessageDAO postgresMessageDAO; - private static PostgresMailboxMessageDAO postgresMailboxMessageDAO; + public static final BlobId.Factory BLOB_ID_FACTORY = new HashBlobId.Factory(); public static PostgresMailboxManager provideMailboxManager(PostgresExtension postgresExtension) { - BlobId.Factory blobIdFactory = new HashBlobId.Factory(); - DeDuplicationBlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory); - MailboxSessionMapperFactory mf = provideMailboxSessionMapperFactory(postgresExtension, blobIdFactory, blobStore); + DeDuplicationBlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, BLOB_ID_FACTORY); + MailboxSessionMapperFactory mf = provideMailboxSessionMapperFactory(postgresExtension, BLOB_ID_FACTORY, blobStore); MailboxACLResolver aclResolver = new UnionMailboxACLResolver(); MessageParser messageParser = new MessageParser(); @@ -78,11 +76,11 @@ public static PostgresMailboxManager provideMailboxManager(PostgresExtension pos SessionProviderImpl sessionProvider = new SessionProviderImpl(noAuthenticator, noAuthorizator); QuotaComponents quotaComponents = QuotaComponents.disabled(sessionProvider, mf); MessageSearchIndex index = new SimpleMessageSearchIndex(mf, mf, new DefaultTextExtractor(), new PostgresAttachmentContentLoader()); - postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getExecutorFactory().create(), blobIdFactory); - postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getExecutorFactory().create()); - eventBus.register(new DeleteMessageListener(postgresMessageDAO, - postgresMailboxMessageDAO, - blobStore)); + + PostgresMessageDAO.Factory postgresMessageDAOFactory = new PostgresMessageDAO.Factory(BLOB_ID_FACTORY, postgresExtension.getExecutorFactory()); + PostgresMailboxMessageDAO.Factory postgresMailboxMessageDAOFactory = new PostgresMailboxMessageDAO.Factory(postgresExtension.getExecutorFactory()); + + eventBus.register(new DeleteMessageListener(blobStore, postgresMailboxMessageDAOFactory, postgresMessageDAOFactory)); return new PostgresMailboxManager((PostgresMailboxSessionMapperFactory) mf, sessionProvider, messageParser, new PostgresMessageId.Factory(), @@ -107,11 +105,4 @@ public static MailboxSessionMapperFactory provideMailboxSessionMapperFactory(Pos blobIdFactory); } - public static PostgresMessageDAO providePostgresMessageDAO() { - return postgresMessageDAO; - } - - public static PostgresMailboxMessageDAO providePostgresMailboxMessageDAO() { - return postgresMailboxMessageDAO; - } } diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java index 348f5222f98..2361fbc750c 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java @@ -49,8 +49,6 @@ import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; import org.apache.james.mailbox.postgres.PostgresMessageId; import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; -import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; -import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.store.MailboxManagerConfiguration; import org.apache.james.mailbox.store.MailboxSessionMapperFactory; import org.apache.james.mailbox.store.NoMailboxPathLocker; @@ -119,9 +117,6 @@ protected void configure() { bind(ReIndexer.class).to(ReIndexerImpl.class); - bind(PostgresMessageDAO.class).in(Scopes.SINGLETON); - bind(PostgresMailboxMessageDAO.class).in(Scopes.SINGLETON); - Multibinder.newSetBinder(binder(), MailboxManagerDefinition.class).addBinding().to(PostgresMailboxManagerDefinition.class); Multibinder.newSetBinder(binder(), EventListener.GroupEventListener.class) From 2ec3c0914c6d6bb60e4848ad6c1ed9cb5b336b43 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 3 Jan 2024 15:15:50 +0700 Subject: [PATCH 174/341] Guice InitializationOperation support priority when init module - The priority will determine the sort order before start initialization --- .../james/utils/InitializationOperations.java | 1 + .../james/utils/InitializationOperation.java | 7 +++++++ .../utils/InitilizationOperationBuilder.java | 18 ++++++++++++++++-- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/server/container/guice/common/src/main/java/org/apache/james/utils/InitializationOperations.java b/server/container/guice/common/src/main/java/org/apache/james/utils/InitializationOperations.java index 61adb875d35..a8cf0f45fb0 100644 --- a/server/container/guice/common/src/main/java/org/apache/james/utils/InitializationOperations.java +++ b/server/container/guice/common/src/main/java/org/apache/james/utils/InitializationOperations.java @@ -47,6 +47,7 @@ private Set processStartables() { return startables.get().stream() .flatMap(this::configurationPerformerFor) .distinct() + .sorted((a, b) -> Integer.compare(b.priority(), a.priority())) .peek(Throwing.consumer(InitializationOperation::initModule).sneakyThrow()) .collect(Collectors.toSet()); } diff --git a/server/container/guice/configuration/src/main/java/org/apache/james/utils/InitializationOperation.java b/server/container/guice/configuration/src/main/java/org/apache/james/utils/InitializationOperation.java index 57417555909..7ddd75daada 100644 --- a/server/container/guice/configuration/src/main/java/org/apache/james/utils/InitializationOperation.java +++ b/server/container/guice/configuration/src/main/java/org/apache/james/utils/InitializationOperation.java @@ -27,6 +27,8 @@ public interface InitializationOperation { + int DEFAULT_PRIORITY = 0; + void initModule() throws Exception; /** @@ -41,4 +43,9 @@ public interface InitializationOperation { default List> requires() { return ImmutableList.of(); } + + default int priority() { + return DEFAULT_PRIORITY; + } + } diff --git a/server/container/guice/configuration/src/main/java/org/apache/james/utils/InitilizationOperationBuilder.java b/server/container/guice/configuration/src/main/java/org/apache/james/utils/InitilizationOperationBuilder.java index 84df2dad646..2896237d2bf 100644 --- a/server/container/guice/configuration/src/main/java/org/apache/james/utils/InitilizationOperationBuilder.java +++ b/server/container/guice/configuration/src/main/java/org/apache/james/utils/InitilizationOperationBuilder.java @@ -19,6 +19,8 @@ package org.apache.james.utils; +import static org.apache.james.utils.InitializationOperation.DEFAULT_PRIORITY; + import java.util.Arrays; import java.util.List; @@ -41,7 +43,11 @@ public interface RequireInit { } public static RequireInit forClass(Class type) { - return init -> new PrivateImpl(init, type); + return init -> new PrivateImpl(init, type, DEFAULT_PRIORITY); + } + + public static RequireInit forClass(Class type, int priority) { + return init -> new PrivateImpl(init, type, priority); } public static class PrivateImpl implements InitializationOperation { @@ -49,9 +55,12 @@ public static class PrivateImpl implements InitializationOperation { private final Class type; private List> requires; - private PrivateImpl(Init init, Class type) { + private final int priority; + + private PrivateImpl(Init init, Class type, int priority) { this.init = init; this.type = type; + this.priority = priority; /* Class requirements are by default infered from the parameters of the first @Inject annotated constructor. @@ -85,5 +94,10 @@ public PrivateImpl requires(List> requires) { public List> requires() { return requires; } + + @Override + public int priority() { + return priority; + } } } From 82c45bd671e363919fc767fd6c07a0522095cc6b Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 3 Jan 2024 15:18:00 +0700 Subject: [PATCH 175/341] JAMES-2586 Refactor the way initPostgres of PostgresTableManager - Use the InitializationOperation with higher priority to ensure it will run first (before DomainList init) --- .../postgres/PostgresTableManager.java | 16 +++++------ .../modules/data/PostgresCommonModule.java | 27 ++++++++++++++----- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java index b4b2fb622c2..84e2bc7fe60 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java @@ -20,9 +20,9 @@ package org.apache.james.backends.postgres; import javax.inject.Inject; -import javax.inject.Provider; import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.lifecycle.api.Startable; import org.jooq.exception.DataAccessException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,20 +33,20 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; -public class PostgresTableManager implements Provider { +public class PostgresTableManager implements Startable { + public static final int INITIALIZATION_PRIORITY = 1; private static final Logger LOGGER = LoggerFactory.getLogger(PostgresTableManager.class); private final PostgresExecutor postgresExecutor; private final PostgresModule module; private final boolean rowLevelSecurityEnabled; @Inject - public PostgresTableManager(PostgresExecutor.Factory factory, + public PostgresTableManager(PostgresExecutor postgresExecutor, PostgresModule module, PostgresConfiguration postgresConfiguration) { - this.postgresExecutor = factory.create(); + this.postgresExecutor = postgresExecutor; this.module = module; this.rowLevelSecurityEnabled = postgresConfiguration.rowLevelSecurityEnabled(); - initPostgres(); } @VisibleForTesting @@ -56,7 +56,7 @@ public PostgresTableManager(PostgresExecutor postgresExecutor, PostgresModule mo this.rowLevelSecurityEnabled = rowLevelSecurityEnabled; } - private void initPostgres() { + public void initPostgres() { initializePostgresExtension() .then(initializeTables()) .then(initializeTableIndexes()) @@ -163,8 +163,4 @@ private Mono handleIndexCreationException(PostgresIndex index return Mono.error(e); } - @Override - public PostgresExecutor get() { - return postgresExecutor; - } } diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index 53e98144d18..e5f849cebba 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -18,6 +18,7 @@ ****************************************************************/ package org.apache.james.modules.data; +import static org.apache.james.backends.postgres.PostgresTableManager.INITIALIZATION_PRIORITY; import static org.apache.james.backends.postgres.utils.PostgresExecutor.DEFAULT_INJECT; import java.io.FileNotFoundException; @@ -33,6 +34,8 @@ import org.apache.james.backends.postgres.utils.PostgresHealthCheck; import org.apache.james.backends.postgres.utils.SinglePostgresConnectionFactory; import org.apache.james.core.healthcheck.HealthCheck; +import org.apache.james.utils.InitializationOperation; +import org.apache.james.utils.InitilizationOperationBuilder; import org.apache.james.utils.PropertiesProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -42,6 +45,7 @@ import com.google.inject.Scopes; import com.google.inject.Singleton; import com.google.inject.multibindings.Multibinder; +import com.google.inject.multibindings.ProvidesIntoSet; import com.google.inject.name.Named; import io.r2dbc.postgresql.PostgresqlConnectionConfiguration; @@ -57,8 +61,6 @@ public void configure() { Multibinder.newSetBinder(binder(), PostgresModule.class); bind(PostgresExecutor.Factory.class).in(Scopes.SINGLETON); - bind(PostgresExecutor.class).toProvider(PostgresTableManager.class); - Multibinder.newSetBinder(binder(), HealthCheck.class) .addBinding().to(PostgresHealthCheck.class); } @@ -102,16 +104,29 @@ PostgresModule composePostgresDataDefinitions(Set modules) { @Provides @Singleton - PostgresTableManager postgresTableManager(PostgresExecutor.Factory factory, + PostgresTableManager postgresTableManager(PostgresExecutor postgresExecutor, PostgresModule postgresModule, PostgresConfiguration postgresConfiguration) { - return new PostgresTableManager(factory, postgresModule, postgresConfiguration); + return new PostgresTableManager(postgresExecutor, postgresModule, postgresConfiguration); } @Provides @Named(DEFAULT_INJECT) @Singleton - PostgresExecutor defaultPostgresExecutor(PostgresTableManager postgresTableManager) { - return postgresTableManager.get(); + PostgresExecutor defaultPostgresExecutor(PostgresExecutor.Factory factory) { + return factory.create(); + } + + @Provides + @Singleton + PostgresExecutor postgresExecutor(@Named(DEFAULT_INJECT) PostgresExecutor postgresExecutor) { + return postgresExecutor; + } + + @ProvidesIntoSet + InitializationOperation provisionPostgresTablesAndIndexes(PostgresTableManager postgresTableManager) { + return InitilizationOperationBuilder + .forClass(PostgresTableManager.class, INITIALIZATION_PRIORITY) + .init(postgresTableManager::initPostgres); } } From 14ee22ca6e8160de4bc29d2f85c121419c7930cb Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 2 Jan 2024 15:05:05 +0700 Subject: [PATCH 176/341] JAMES-2586 Implement PostgresDeletedMessageMetadataVault --- Jenkinsfile | 3 +- .../deleted-messages-vault-postgres/pom.xml | 79 ++++++++++++ .../PostgresDeletedMessageMetadataModule.java | 65 ++++++++++ .../PostgresDeletedMessageMetadataVault.java | 115 ++++++++++++++++++ ...stgresDeletedMessageMetadataVaultTest.java | 46 +++++++ .../vault/metadata/MetadataSerializer.java | 0 mailbox/pom.xml | 1 + pom.xml | 5 + 8 files changed, 313 insertions(+), 1 deletion(-) create mode 100644 mailbox/plugin/deleted-messages-vault-postgres/pom.xml create mode 100644 mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/PostgresDeletedMessageMetadataModule.java create mode 100644 mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/PostgresDeletedMessageMetadataVault.java create mode 100644 mailbox/plugin/deleted-messages-vault-postgres/src/test/java/org/apache/james/vault/metadata/PostgresDeletedMessageMetadataVaultTest.java rename mailbox/plugin/{deleted-messages-vault-cassandra => deleted-messages-vault}/src/main/java/org/apache/james/vault/metadata/MetadataSerializer.java (100%) diff --git a/Jenkinsfile b/Jenkinsfile index 7849d6c3e93..2fde792fbdd 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -46,7 +46,8 @@ pipeline { 'server/container/guice/mailbox-postgres,' + 'server/apps/postgres-app,' + 'mpt/impl/imap-mailbox/postgres,' + - 'event-bus/postgres' + 'event-bus/postgres,' + + 'mailbox/plugin/deleted-messages-vault-postgres' } tools { diff --git a/mailbox/plugin/deleted-messages-vault-postgres/pom.xml b/mailbox/plugin/deleted-messages-vault-postgres/pom.xml new file mode 100644 index 00000000000..103fd725b40 --- /dev/null +++ b/mailbox/plugin/deleted-messages-vault-postgres/pom.xml @@ -0,0 +1,79 @@ + + + + 4.0.0 + + org.apache.james + apache-james-mailbox + 3.9.0-SNAPSHOT + ../../pom.xml + + + apache-james-mailbox-deleted-messages-vault-postgres + Apache James :: Mailbox :: Plugin :: Deleted Messages Vault :: Postgres + Apache James Mailbox Deleted Messages Vault metadata on top of Postgres + + + + ${james.groupId} + apache-james-backends-postgres + + + ${james.groupId} + apache-james-backends-postgres + test-jar + test + + + ${james.groupId} + apache-james-mailbox-deleted-messages-vault + + + ${james.groupId} + apache-james-mailbox-deleted-messages-vault + test-jar + test + + + ${james.groupId} + apache-james-mailbox-memory + test + + + ${james.groupId} + james-server-guice-common + test-jar + test + + + ${james.groupId} + james-server-testing + test + + + ${james.groupId} + testing-base + test + + + org.testcontainers + postgresql + test + + + diff --git a/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/PostgresDeletedMessageMetadataModule.java b/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/PostgresDeletedMessageMetadataModule.java new file mode 100644 index 00000000000..de041482a47 --- /dev/null +++ b/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/PostgresDeletedMessageMetadataModule.java @@ -0,0 +1,65 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.vault.metadata; + +import static org.apache.james.vault.metadata.PostgresDeletedMessageMetadataModule.DeletedMessageMetadataTable.OWNER_MESSAGE_ID_INDEX; +import static org.apache.james.vault.metadata.PostgresDeletedMessageMetadataModule.DeletedMessageMetadataTable.TABLE; + +import org.apache.james.backends.postgres.PostgresIndex; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.JSONB; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresDeletedMessageMetadataModule { + interface DeletedMessageMetadataTable { + Table TABLE_NAME = DSL.table("deleted_messages_metadata"); + + Field BUCKET_NAME = DSL.field("bucket_name", SQLDataType.VARCHAR.notNull()); + Field OWNER = DSL.field("owner", SQLDataType.VARCHAR.notNull()); + Field MESSAGE_ID = DSL.field("messageId", SQLDataType.VARCHAR.notNull()); + Field BLOB_ID = DSL.field("blob_id", SQLDataType.VARCHAR.notNull()); + Field METADATA = DSL.field("metadata", SQLDataType.JSONB.notNull()); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(BUCKET_NAME) + .column(OWNER) + .column(MESSAGE_ID) + .column(BLOB_ID) + .column(METADATA) + .primaryKey(BUCKET_NAME, OWNER, MESSAGE_ID))) + .disableRowLevelSecurity() + .build(); + + PostgresIndex OWNER_MESSAGE_ID_INDEX = PostgresIndex.name("owner_messageId_index") + .createIndexStep((dsl, indexName) -> dsl.createUniqueIndexIfNotExists(indexName) + .on(TABLE_NAME, OWNER, MESSAGE_ID)); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(TABLE) + .addIndex(OWNER_MESSAGE_ID_INDEX) + .build(); +} diff --git a/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/PostgresDeletedMessageMetadataVault.java b/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/PostgresDeletedMessageMetadataVault.java new file mode 100644 index 00000000000..70bbd254761 --- /dev/null +++ b/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/PostgresDeletedMessageMetadataVault.java @@ -0,0 +1,115 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.vault.metadata; + +import static org.apache.james.util.ReactorUtils.publishIfPresent; +import static org.apache.james.vault.metadata.PostgresDeletedMessageMetadataModule.DeletedMessageMetadataTable.BLOB_ID; +import static org.apache.james.vault.metadata.PostgresDeletedMessageMetadataModule.DeletedMessageMetadataTable.BUCKET_NAME; +import static org.apache.james.vault.metadata.PostgresDeletedMessageMetadataModule.DeletedMessageMetadataTable.MESSAGE_ID; +import static org.apache.james.vault.metadata.PostgresDeletedMessageMetadataModule.DeletedMessageMetadataTable.METADATA; +import static org.apache.james.vault.metadata.PostgresDeletedMessageMetadataModule.DeletedMessageMetadataTable.OWNER; +import static org.apache.james.vault.metadata.PostgresDeletedMessageMetadataModule.DeletedMessageMetadataTable.TABLE_NAME; +import static org.jooq.JSONB.jsonb; + +import java.util.function.Function; + +import javax.inject.Inject; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BucketName; +import org.apache.james.core.Username; +import org.apache.james.mailbox.model.MessageId; +import org.jooq.Record; +import org.reactivestreams.Publisher; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresDeletedMessageMetadataVault implements DeletedMessageMetadataVault { + private final PostgresExecutor postgresExecutor; + private final MetadataSerializer metadataSerializer; + private final BlobId.Factory blobIdFactory; + + @Inject + public PostgresDeletedMessageMetadataVault(PostgresExecutor postgresExecutor, + MetadataSerializer metadataSerializer, + BlobId.Factory blobIdFactory) { + this.postgresExecutor = postgresExecutor; + this.metadataSerializer = metadataSerializer; + this.blobIdFactory = blobIdFactory; + } + + @Override + public Publisher store(DeletedMessageWithStorageInformation deletedMessage) { + return postgresExecutor.executeVoid(context -> Mono.from(context.insertInto(TABLE_NAME) + .set(OWNER, deletedMessage.getDeletedMessage().getOwner().asString()) + .set(MESSAGE_ID, deletedMessage.getDeletedMessage().getMessageId().serialize()) + .set(BUCKET_NAME, deletedMessage.getStorageInformation().getBucketName().asString()) + .set(BLOB_ID, deletedMessage.getStorageInformation().getBlobId().asString()) + .set(METADATA, jsonb(metadataSerializer.serialize(deletedMessage))))); + } + + @Override + public Publisher removeMetadataRelatedToBucket(BucketName bucketName) { + return postgresExecutor.executeVoid(context -> Mono.from(context.deleteFrom(TABLE_NAME) + .where(BUCKET_NAME.eq(bucketName.asString())))); + } + + @Override + public Publisher remove(BucketName bucketName, Username username, MessageId messageId) { + return postgresExecutor.executeVoid(context -> Mono.from(context.deleteFrom(TABLE_NAME) + .where(BUCKET_NAME.eq(bucketName.asString()), + OWNER.eq(username.asString()), + MESSAGE_ID.eq(messageId.serialize())))); + } + + @Override + public Publisher retrieveStorageInformation(Username username, MessageId messageId) { + return postgresExecutor.executeRow(context -> Mono.from(context.select(BUCKET_NAME, BLOB_ID) + .from(TABLE_NAME) + .where(OWNER.eq(username.asString()), + MESSAGE_ID.eq(messageId.serialize())))) + .map(toStorageInformation()); + } + + private Function toStorageInformation() { + return record -> StorageInformation.builder() + .bucketName(BucketName.of(record.get(BUCKET_NAME))) + .blobId(blobIdFactory.from(record.get(BLOB_ID))); + } + + @Override + public Publisher listMessages(BucketName bucketName, Username username) { + return postgresExecutor.executeRows(context -> Flux.from(context.select(METADATA) + .from(TABLE_NAME) + .where(BUCKET_NAME.eq(bucketName.asString()), + OWNER.eq(username.asString())))) + .map(record -> metadataSerializer.deserialize(record.get(METADATA).data())) + .handle(publishIfPresent()); + } + + @Override + public Publisher listRelatedBuckets() { + return postgresExecutor.executeRows(context -> Flux.from(context.selectDistinct(BUCKET_NAME) + .from(TABLE_NAME))) + .map(record -> BucketName.of(record.get(BUCKET_NAME))); + } +} diff --git a/mailbox/plugin/deleted-messages-vault-postgres/src/test/java/org/apache/james/vault/metadata/PostgresDeletedMessageMetadataVaultTest.java b/mailbox/plugin/deleted-messages-vault-postgres/src/test/java/org/apache/james/vault/metadata/PostgresDeletedMessageMetadataVaultTest.java new file mode 100644 index 00000000000..766df623c36 --- /dev/null +++ b/mailbox/plugin/deleted-messages-vault-postgres/src/test/java/org/apache/james/vault/metadata/PostgresDeletedMessageMetadataVaultTest.java @@ -0,0 +1,46 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.vault.metadata; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.blob.api.HashBlobId; +import org.apache.james.mailbox.inmemory.InMemoryId; +import org.apache.james.mailbox.inmemory.InMemoryMessageId; +import org.apache.james.vault.dto.DeletedMessageWithStorageInformationConverter; +import org.junit.jupiter.api.extension.RegisterExtension; + +class PostgresDeletedMessageMetadataVaultTest implements DeletedMessageMetadataVaultContract { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity( + PostgresModule.aggregateModules(PostgresDeletedMessageMetadataModule.MODULE)); + + @Override + public DeletedMessageMetadataVault metadataVault() { + HashBlobId.Factory blobIdFactory = new HashBlobId.Factory(); + InMemoryMessageId.Factory messageIdFactory = new InMemoryMessageId.Factory(); + DeletedMessageWithStorageInformationConverter dtoConverter = new DeletedMessageWithStorageInformationConverter(blobIdFactory, + messageIdFactory, new InMemoryId.Factory()); + + return new PostgresDeletedMessageMetadataVault(postgresExtension.getPostgresExecutor(), + new MetadataSerializer(dtoConverter), + blobIdFactory); + } +} diff --git a/mailbox/plugin/deleted-messages-vault-cassandra/src/main/java/org/apache/james/vault/metadata/MetadataSerializer.java b/mailbox/plugin/deleted-messages-vault/src/main/java/org/apache/james/vault/metadata/MetadataSerializer.java similarity index 100% rename from mailbox/plugin/deleted-messages-vault-cassandra/src/main/java/org/apache/james/vault/metadata/MetadataSerializer.java rename to mailbox/plugin/deleted-messages-vault/src/main/java/org/apache/james/vault/metadata/MetadataSerializer.java diff --git a/mailbox/pom.xml b/mailbox/pom.xml index 629c089807e..0fe0776bc09 100644 --- a/mailbox/pom.xml +++ b/mailbox/pom.xml @@ -49,6 +49,7 @@ plugin/deleted-messages-vault plugin/deleted-messages-vault-cassandra + plugin/deleted-messages-vault-postgres plugin/quota-mailing plugin/quota-mailing-cassandra diff --git a/pom.xml b/pom.xml index c10fcc35d25..24717476b3b 100644 --- a/pom.xml +++ b/pom.xml @@ -793,6 +793,11 @@ apache-james-mailbox-deleted-messages-vault-cassandra ${project.version} + + ${james.groupId} + apache-james-mailbox-deleted-messages-vault-postgres + ${project.version} + ${james.groupId} apache-james-mailbox-event-json From c639d120d52f7ec90f770ee757e292ab93a7b5dc Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 2 Jan 2024 16:09:28 +0700 Subject: [PATCH 177/341] JAMES-2586 Guice binding + module chooser + sample config for Postgres DeletedMessageVault --- .../deletedMessageVault.properties | 7 +++ .../james/PostgresJamesConfiguration.java | 33 ++++++++++++-- .../apache/james/PostgresJamesServerMain.java | 14 ++++++ .../apache/james/PostgresJamesServerTest.java | 2 + .../container/guice/mailbox-postgres/pom.xml | 4 ++ .../PostgresDeletedMessageVaultModule.java | 44 +++++++++++++++++++ 6 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 server/apps/postgres-app/sample-configuration/deletedMessageVault.properties create mode 100644 server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresDeletedMessageVaultModule.java diff --git a/server/apps/postgres-app/sample-configuration/deletedMessageVault.properties b/server/apps/postgres-app/sample-configuration/deletedMessageVault.properties new file mode 100644 index 00000000000..a6df89a2275 --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/deletedMessageVault.properties @@ -0,0 +1,7 @@ +# ============================================= Deleted Messages Vault Configuration ================================== + +enabled=false + +# Retention period for your deleted messages into the vault, after which they expire and can be potentially cleaned up +# Optional, default 1y +# retentionPeriod=1y \ No newline at end of file diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java index 26bad105be5..d526c892378 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java @@ -34,6 +34,7 @@ import org.apache.james.server.core.configuration.FileConfigurationProvider; import org.apache.james.server.core.filesystem.FileSystemImpl; import org.apache.james.utils.PropertiesProvider; +import org.apache.james.vault.VaultConfiguration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -69,8 +70,8 @@ public static class Builder { private Optional usersRepositoryImplementation; private Optional searchConfiguration; private Optional blobStoreConfiguration; - private Optional eventBusImpl; + private Optional deletedMessageVaultConfiguration; private Builder() { searchConfiguration = Optional.empty(); @@ -79,6 +80,7 @@ private Builder() { usersRepositoryImplementation = Optional.empty(); blobStoreConfiguration = Optional.empty(); eventBusImpl = Optional.empty(); + deletedMessageVaultConfiguration = Optional.empty(); } public Builder workingDirectory(String path) { @@ -129,6 +131,11 @@ public Builder eventBusImpl(EventBusImpl eventBusImpl) { return this; } + public Builder deletedMessageVaultConfiguration(VaultConfiguration vaultConfiguration) { + this.deletedMessageVaultConfiguration = Optional.of(vaultConfiguration); + return this; + } + public PostgresJamesConfiguration build() { ConfigurationPath configurationPath = this.configurationPath.orElse(new ConfigurationPath(FileSystem.FILE_PROTOCOL_AND_CONF)); JamesServerResourceLoader directories = new JamesServerResourceLoader(rootDirectory @@ -154,13 +161,24 @@ public PostgresJamesConfiguration build() { EventBusImpl eventBusImpl = this.eventBusImpl.orElseGet(() -> EventBusImpl.from(propertiesProvider)); + VaultConfiguration deletedMessageVaultConfiguration = this.deletedMessageVaultConfiguration.orElseGet(() -> { + try { + return VaultConfiguration.from(propertiesProvider.getConfiguration("deletedMessageVault")); + } catch (FileNotFoundException e) { + return VaultConfiguration.DEFAULT; + } catch (ConfigurationException e) { + throw new RuntimeException(e); + } + }); + return new PostgresJamesConfiguration( configurationPath, directories, searchConfiguration, usersRepositoryChoice, blobStoreConfiguration, - eventBusImpl); + eventBusImpl, + deletedMessageVaultConfiguration); } } @@ -174,19 +192,22 @@ public static Builder builder() { private final UsersRepositoryModuleChooser.Implementation usersRepositoryImplementation; private final BlobStoreConfiguration blobStoreConfiguration; private final EventBusImpl eventBusImpl; - + private final VaultConfiguration deletedMessageVaultConfiguration; private PostgresJamesConfiguration(ConfigurationPath configurationPath, JamesDirectoriesProvider directories, SearchConfiguration searchConfiguration, UsersRepositoryModuleChooser.Implementation usersRepositoryImplementation, - BlobStoreConfiguration blobStoreConfiguration, EventBusImpl eventBusImpl) { + BlobStoreConfiguration blobStoreConfiguration, + EventBusImpl eventBusImpl, + VaultConfiguration deletedMessageVaultConfiguration) { this.configurationPath = configurationPath; this.directories = directories; this.searchConfiguration = searchConfiguration; this.usersRepositoryImplementation = usersRepositoryImplementation; this.blobStoreConfiguration = blobStoreConfiguration; this.eventBusImpl = eventBusImpl; + this.deletedMessageVaultConfiguration = deletedMessageVaultConfiguration; } @Override @@ -214,4 +235,8 @@ public BlobStoreConfiguration blobStoreConfiguration() { public EventBusImpl eventBusImpl() { return eventBusImpl; } + + public VaultConfiguration getDeletedMessageVaultConfiguration() { + return deletedMessageVaultConfiguration; + } } diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index a106e0da41d..fd07cc23ac2 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -23,6 +23,7 @@ import org.apache.james.blob.api.BlobReferenceSource; import org.apache.james.data.UsersRepositoryModuleChooser; +import org.apache.james.modules.BlobExportMechanismModule; import org.apache.james.modules.MailboxModule; import org.apache.james.modules.MailetProcessingModule; import org.apache.james.modules.RunArgumentsModule; @@ -36,6 +37,7 @@ import org.apache.james.modules.events.PostgresDeadLetterModule; import org.apache.james.modules.eventstore.MemoryEventStoreModule; import org.apache.james.modules.mailbox.DefaultEventModule; +import org.apache.james.modules.mailbox.PostgresDeletedMessageVaultModule; import org.apache.james.modules.mailbox.PostgresMailboxModule; import org.apache.james.modules.mailbox.TikaMailboxModule; import org.apache.james.modules.protocols.IMAPServerModule; @@ -60,7 +62,9 @@ import org.apache.james.modules.server.TaskManagerModule; import org.apache.james.modules.server.WebAdminReIndexingTaskSerializationModule; import org.apache.james.modules.server.WebAdminServerModule; +import org.apache.james.modules.vault.DeletedMessageVaultRoutesModule; import org.apache.james.server.blob.deduplication.StorageStrategy; +import org.apache.james.vault.VaultConfiguration; import com.google.common.collect.ImmutableList; import com.google.inject.Module; @@ -91,6 +95,7 @@ public class PostgresJamesServerMain implements JamesServerMain { private static final Module POSTGRES_SERVER_MODULE = Modules.combine( new ActiveMQQueueModule(), + new BlobExportMechanismModule(), new PostgresDelegationStoreModule(), new DefaultProcessorsConfigurationProviderModule(), new PostgresMailboxModule(), @@ -131,6 +136,7 @@ static GuiceJamesServer createServer(PostgresJamesConfiguration configuration) { .chooseModules(configuration.getUsersRepositoryImplementation())) .combineWith(chooseBlobStoreModules(configuration)) .combineWith(chooseEventBusModules(configuration)) + .combineWith(chooseDeletedMessageVaultModules(configuration.getDeletedMessageVaultConfiguration())) .combineWith(POSTGRES_MODULE_AGGREGATE); } @@ -158,4 +164,12 @@ public static List chooseEventBusModules(PostgresJamesConfiguration conf throw new RuntimeException("Unsupported event-bus implementation " + configuration.eventBusImpl().name()); } } + + private static Module chooseDeletedMessageVaultModules(VaultConfiguration vaultConfiguration) { + if (vaultConfiguration.isEnabled()) { + return Modules.combine(new PostgresDeletedMessageVaultModule(), new DeletedMessageVaultRoutesModule()); + } + + return Modules.EMPTY_MODULE; + } } diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java index ca25ff6c9e8..6d7ba64109a 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJamesServerTest.java @@ -33,6 +33,7 @@ import org.apache.james.utils.DataProbeImpl; import org.apache.james.utils.SMTPMessageSender; import org.apache.james.utils.TestIMAPClient; +import org.apache.james.vault.VaultConfiguration; import org.awaitility.Awaitility; import org.awaitility.core.ConditionFactory; import org.junit.jupiter.api.BeforeEach; @@ -52,6 +53,7 @@ class PostgresJamesServerTest implements JamesServerConcreteContract { .searchConfiguration(SearchConfiguration.scanning()) .usersRepository(DEFAULT) .eventBusImpl(EventBusImpl.IN_MEMORY) + .deletedMessageVaultConfiguration(VaultConfiguration.ENABLED_DEFAULT) .build()) .server(PostgresJamesServerMain::createServer) .extension(postgresExtension) diff --git a/server/container/guice/mailbox-postgres/pom.xml b/server/container/guice/mailbox-postgres/pom.xml index 3f345720af9..28da17432dc 100644 --- a/server/container/guice/mailbox-postgres/pom.xml +++ b/server/container/guice/mailbox-postgres/pom.xml @@ -33,6 +33,10 @@ Apache James :: Server :: Postgres - Guice injection + + ${james.groupId} + apache-james-mailbox-deleted-messages-vault-postgres + ${james.groupId} apache-james-mailbox-postgres diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresDeletedMessageVaultModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresDeletedMessageVaultModule.java new file mode 100644 index 00000000000..6e874b0d4fc --- /dev/null +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresDeletedMessageVaultModule.java @@ -0,0 +1,44 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.mailbox; + +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.modules.vault.DeletedMessageVaultModule; +import org.apache.james.vault.metadata.DeletedMessageMetadataVault; +import org.apache.james.vault.metadata.PostgresDeletedMessageMetadataModule; +import org.apache.james.vault.metadata.PostgresDeletedMessageMetadataVault; + +import com.google.inject.AbstractModule; +import com.google.inject.Scopes; +import com.google.inject.multibindings.Multibinder; + +public class PostgresDeletedMessageVaultModule extends AbstractModule { + @Override + protected void configure() { + install(new DeletedMessageVaultModule()); + + Multibinder postgresDataDefinitions = Multibinder.newSetBinder(binder(), PostgresModule.class); + postgresDataDefinitions.addBinding().toInstance(PostgresDeletedMessageMetadataModule.MODULE); + + bind(PostgresDeletedMessageMetadataVault.class).in(Scopes.SINGLETON); + bind(DeletedMessageMetadataVault.class) + .to(PostgresDeletedMessageMetadataVault.class); + } +} From 98e5c87e7d7110bcf1ef3698a07efb25fac7ee7a Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Mon, 8 Jan 2024 10:27:48 +0700 Subject: [PATCH 178/341] JAMES-2586 Plug DeletedMessageVaultDeletionCallback into DeleteMessageListener missing attachment metadata though -> need to do it in https://github.com/linagora/james-project/issues/5011 --- .../deleted-messages-vault-postgres/pom.xml | 4 + .../DeletedMessageVaultDeletionCallback.java | 123 ++++++++++++++++++ .../postgres/DeleteMessageListener.java | 52 ++++++-- .../postgres/mail/MessageRepresentation.java | 113 ++++++++++++++++ .../postgres/mail/dao/PostgresMessageDAO.java | 27 +++- .../DeleteMessageListenerContract.java | 8 +- .../PostgresMailboxManagerProvider.java | 5 +- .../PostgresDeletedMessageVaultModule.java | 6 + .../mailbox/PostgresMailboxModule.java | 1 + 9 files changed, 319 insertions(+), 20 deletions(-) create mode 100644 mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/DeletedMessageVaultDeletionCallback.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/MessageRepresentation.java diff --git a/mailbox/plugin/deleted-messages-vault-postgres/pom.xml b/mailbox/plugin/deleted-messages-vault-postgres/pom.xml index 103fd725b40..856b49aa565 100644 --- a/mailbox/plugin/deleted-messages-vault-postgres/pom.xml +++ b/mailbox/plugin/deleted-messages-vault-postgres/pom.xml @@ -54,6 +54,10 @@ apache-james-mailbox-memory test + + ${james.groupId} + apache-james-mailbox-postgres + ${james.groupId} james-server-guice-common diff --git a/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/DeletedMessageVaultDeletionCallback.java b/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/DeletedMessageVaultDeletionCallback.java new file mode 100644 index 00000000000..18a7027c469 --- /dev/null +++ b/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/DeletedMessageVaultDeletionCallback.java @@ -0,0 +1,123 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.vault.metadata; + +import java.io.IOException; +import java.io.InputStream; +import java.io.SequenceInputStream; +import java.time.Clock; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.Optional; +import java.util.Set; + +import javax.inject.Inject; + +import org.apache.james.blob.api.BlobStore; +import org.apache.james.core.MailAddress; +import org.apache.james.core.MaybeSender; +import org.apache.james.core.Username; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.postgres.DeleteMessageListener; +import org.apache.james.mailbox.postgres.mail.MessageRepresentation; +import org.apache.james.mime4j.MimeIOException; +import org.apache.james.mime4j.codec.DecodeMonitor; +import org.apache.james.mime4j.dom.Message; +import org.apache.james.mime4j.dom.address.Mailbox; +import org.apache.james.mime4j.message.DefaultMessageBuilder; +import org.apache.james.mime4j.stream.MimeConfig; +import org.apache.james.server.core.Envelope; +import org.apache.james.vault.DeletedMessage; +import org.apache.james.vault.DeletedMessageVault; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.github.fge.lambdas.Throwing; +import com.google.common.collect.ImmutableSet; + +import reactor.core.publisher.Mono; + +public class DeletedMessageVaultDeletionCallback implements DeleteMessageListener.DeletionCallback { + private static final Logger LOGGER = LoggerFactory.getLogger(DeletedMessageVaultDeletionCallback.class); + + private final DeletedMessageVault deletedMessageVault; + private final BlobStore blobStore; + private final Clock clock; + + @Inject + public DeletedMessageVaultDeletionCallback(DeletedMessageVault deletedMessageVault, BlobStore blobStore, Clock clock) { + this.deletedMessageVault = deletedMessageVault; + this.blobStore = blobStore; + this.clock = clock; + } + + @Override + public Mono forMessage(MessageRepresentation message, MailboxId mailboxId, Username owner) { + return Mono.fromSupplier(Throwing.supplier(() -> message.getHeaderContent().getInputStream())) + .flatMap(headerStream -> { + Optional mimeMessage = parseMessage(headerStream, message.getMessageId()); + DeletedMessage deletedMessage = DeletedMessage.builder() + .messageId(message.getMessageId()) + .originMailboxes(mailboxId) + .user(owner) + .deliveryDate(ZonedDateTime.ofInstant(message.getInternalDate().toInstant(), ZoneOffset.UTC)) + .deletionDate(ZonedDateTime.ofInstant(clock.instant(), ZoneOffset.UTC)) + .sender(retrieveSender(mimeMessage)) + .recipients(retrieveRecipients(mimeMessage)) + .hasAttachment(false) // todo return actual value in ticket: https://github.com/linagora/james-project/issues/5011 + .size(message.getSize()) + .subject(mimeMessage.map(Message::getSubject)) + .build(); + + return Mono.from(blobStore.readReactive(blobStore.getDefaultBucketName(), message.getBodyBlobId(), BlobStore.StoragePolicy.LOW_COST)) + .map(bodyStream -> new SequenceInputStream(headerStream, bodyStream)) + .flatMap(bodyStream -> Mono.from(deletedMessageVault.append(deletedMessage, bodyStream))); + }); + } + + private Optional parseMessage(InputStream inputStream, MessageId messageId) { + DefaultMessageBuilder messageBuilder = new DefaultMessageBuilder(); + messageBuilder.setMimeEntityConfig(MimeConfig.PERMISSIVE); + messageBuilder.setDecodeMonitor(DecodeMonitor.SILENT); + try { + return Optional.ofNullable(messageBuilder.parseMessage(inputStream)); + } catch (MimeIOException e) { + LOGGER.warn("Can not parse the message {}", messageId, e); + return Optional.empty(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private MaybeSender retrieveSender(Optional mimeMessage) { + return mimeMessage + .map(Message::getSender) + .map(Mailbox::getAddress) + .map(MaybeSender::getMailSender) + .orElse(MaybeSender.nullSender()); + } + + private Set retrieveRecipients(Optional maybeMessage) { + return maybeMessage.map(message -> Envelope.fromMime4JMessage(message, Envelope.ValidationPolicy.IGNORE)) + .map(Envelope::getRecipients) + .orElse(ImmutableSet.of()); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java index 59c87683066..590e57a2d96 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java @@ -19,16 +19,21 @@ package org.apache.james.mailbox.postgres; +import java.util.Set; +import java.util.function.Function; + import javax.inject.Inject; -import org.apache.james.blob.api.BlobId; import org.apache.james.blob.api.BlobStore; +import org.apache.james.core.Username; import org.apache.james.events.Event; import org.apache.james.events.EventListener; import org.apache.james.events.Group; import org.apache.james.mailbox.events.MailboxEvents.Expunged; import org.apache.james.mailbox.events.MailboxEvents.MailboxDeletion; +import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.MessageMetaData; +import org.apache.james.mailbox.postgres.mail.MessageRepresentation; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.reactivestreams.Publisher; @@ -37,10 +42,18 @@ import reactor.core.publisher.Mono; public class DeleteMessageListener implements EventListener.ReactiveGroupEventListener { + @FunctionalInterface + public interface DeletionCallback { + Mono forMessage(MessageRepresentation messageRepresentation, MailboxId mailboxId, Username owner); + } + public static class DeleteMessageListenerGroup extends Group { } + public static final int LOW_CONCURRENCY = 4; + private final BlobStore blobStore; + private final Set deletionCallbackList; private final PostgresMessageDAO.Factory messageDAOFactory; private final PostgresMailboxMessageDAO.Factory mailboxMessageDAOFactory; @@ -49,10 +62,12 @@ public static class DeleteMessageListenerGroup extends Group { @Inject public DeleteMessageListener(BlobStore blobStore, PostgresMailboxMessageDAO.Factory mailboxMessageDAOFactory, - PostgresMessageDAO.Factory messageDAOFactory) { + PostgresMessageDAO.Factory messageDAOFactory, + Set deletionCallbackList) { this.messageDAOFactory = messageDAOFactory; this.mailboxMessageDAOFactory = mailboxMessageDAOFactory; this.blobStore = blobStore; + this.deletionCallbackList = deletionCallbackList; } @Override @@ -83,7 +98,7 @@ private Mono handleMailboxDeletion(MailboxDeletion event) { PostgresMailboxMessageDAO postgresMailboxMessageDAO = mailboxMessageDAOFactory.create(event.getUsername().getDomainPart()); return postgresMailboxMessageDAO.deleteByMailboxId((PostgresMailboxId) event.getMailboxId()) - .flatMap(msgId -> handleMessageDeletion(msgId, postgresMessageDAO, postgresMailboxMessageDAO)) + .flatMap(msgId -> handleMessageDeletion(postgresMessageDAO, postgresMailboxMessageDAO, msgId, event.getMailboxId(), event.getMailboxPath().getUser())) .then(); } @@ -95,26 +110,35 @@ private Mono handleMessageDeletion(Expunged event) { .values()) .map(MessageMetaData::getMessageId) .map(PostgresMessageId.class::cast) - .flatMap(msgId -> handleMessageDeletion(msgId, postgresMessageDAO, postgresMailboxMessageDAO)) + .flatMap(msgId -> handleMessageDeletion(postgresMessageDAO, postgresMailboxMessageDAO, msgId, event.getMailboxId(), event.getMailboxPath().getUser()), LOW_CONCURRENCY) .then(); } - - private Mono handleMessageDeletion(PostgresMessageId messageId, - PostgresMessageDAO postgresMessageDAO, - PostgresMailboxMessageDAO postgresMailboxMessageDAO) { + private Mono handleMessageDeletion(PostgresMessageDAO postgresMessageDAO, + PostgresMailboxMessageDAO postgresMailboxMessageDAO, + PostgresMessageId messageId, + MailboxId mailboxId, + Username owner) { return Mono.just(messageId) - .filterWhen(msgId -> isUnreferenced(msgId, postgresMailboxMessageDAO)) - .flatMap(id -> postgresMessageDAO.getBlobId(messageId) - .flatMap(this::deleteMessageBlobs) - .then(postgresMessageDAO.deleteByMessageId(messageId))); + .filterWhen(msgId -> isUnreferenced(messageId, postgresMailboxMessageDAO)) + .flatMap(msgId -> postgresMessageDAO.retrieveMessage(messageId) + .flatMap(executeDeletionCallbacks(mailboxId, owner)) + .then(deleteBodyBlob(msgId, postgresMessageDAO)) + .then(postgresMessageDAO.deleteByMessageId(msgId))); } - private Mono deleteMessageBlobs(BlobId blobId) { - return Mono.from(blobStore.delete(blobStore.getDefaultBucketName(), blobId)) + private Function> executeDeletionCallbacks(MailboxId mailboxId, Username owner) { + return messageRepresentation -> Flux.fromIterable(deletionCallbackList) + .concatMap(callback -> callback.forMessage(messageRepresentation, mailboxId, owner)) .then(); } + private Mono deleteBodyBlob(PostgresMessageId id, PostgresMessageDAO postgresMessageDAO) { + return postgresMessageDAO.getBodyBlobId(id) + .flatMap(blobId -> Mono.from(blobStore.delete(blobStore.getDefaultBucketName(), blobId)) + .then()); + } + private Mono isUnreferenced(PostgresMessageId id, PostgresMailboxMessageDAO postgresMailboxMessageDAO) { return postgresMailboxMessageDAO.countByMessageId(id) .filter(count -> count == 0) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/MessageRepresentation.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/MessageRepresentation.java new file mode 100644 index 00000000000..b960f7dde54 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/MessageRepresentation.java @@ -0,0 +1,113 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import java.util.Date; + +import org.apache.james.blob.api.BlobId; +import org.apache.james.mailbox.model.Content; +import org.apache.james.mailbox.model.MessageId; + +import com.google.common.base.Preconditions; + +public class MessageRepresentation { + public static MessageRepresentation.Builder builder() { + return new MessageRepresentation.Builder(); + } + + public static class Builder { + private MessageId messageId; + private Date internalDate; + private Long size; + private Content headerContent; + private BlobId bodyBlobId; + + public MessageRepresentation.Builder messageId(MessageId messageId) { + this.messageId = messageId; + return this; + } + + public MessageRepresentation.Builder internalDate(Date internalDate) { + this.internalDate = internalDate; + return this; + } + + public MessageRepresentation.Builder size(long size) { + Preconditions.checkArgument(size >= 0, "size can not be negative"); + this.size = size; + return this; + } + + public MessageRepresentation.Builder headerContent(Content headerContent) { + this.headerContent = headerContent; + return this; + } + + public MessageRepresentation.Builder bodyBlobId(BlobId bodyBlobId) { + this.bodyBlobId = bodyBlobId; + return this; + } + + public MessageRepresentation build() { + Preconditions.checkNotNull(messageId, "messageId is required"); + Preconditions.checkNotNull(internalDate, "internalDate is required"); + Preconditions.checkNotNull(size, "size is required"); + Preconditions.checkNotNull(headerContent, "headerContent is required"); + Preconditions.checkNotNull(bodyBlobId, "mailboxId is required"); + + return new MessageRepresentation(messageId, internalDate, size, headerContent, bodyBlobId); + } + } + + private final MessageId messageId; + private final Date internalDate; + private final Long size; + private final Content headerContent; + private final BlobId bodyBlobId; + + private MessageRepresentation(MessageId messageId, Date internalDate, Long size, + Content headerContent, BlobId bodyBlobId) { + this.messageId = messageId; + this.internalDate = internalDate; + this.size = size; + this.headerContent = headerContent; + this.bodyBlobId = bodyBlobId; + } + + public Date getInternalDate() { + return internalDate; + } + + public Long getSize() { + return size; + } + + public MessageId getMessageId() { + return messageId; + } + + public Content getHeaderContent() { + return headerContent; + } + + public BlobId getBodyBlobId() { + return bodyBlobId; + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java index 3bb18c7bb40..c68b0e3792d 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java @@ -20,6 +20,7 @@ package org.apache.james.mailbox.postgres.mail.dao; import static org.apache.james.backends.postgres.PostgresCommons.DATE_TO_LOCAL_DATE_TIME; +import static org.apache.james.backends.postgres.PostgresCommons.LOCAL_DATE_TIME_DATE_FUNCTION; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.BODY_BLOB_ID; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.BODY_START_OCTET; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_DESCRIPTION; @@ -39,7 +40,9 @@ import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.SIZE; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.TABLE_NAME; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.TEXTUAL_LINE_COUNT; +import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.BYTE_TO_CONTENT_FUNCTION; +import java.time.LocalDateTime; import java.util.Optional; import javax.inject.Inject; @@ -49,8 +52,12 @@ import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.blob.api.BlobId; import org.apache.james.core.Domain; +import org.apache.james.mailbox.model.MessageId; import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.mailbox.postgres.mail.MessageRepresentation; +import org.apache.james.mailbox.postgres.mail.PostgresMessageModule; import org.apache.james.mailbox.store.mail.model.MailboxMessage; +import org.jooq.Record; import org.jooq.postgres.extensions.types.Hstore; import reactor.core.publisher.Mono; @@ -107,12 +114,30 @@ public Mono insert(MailboxMessage message, String bodyBlobId) { .set(HEADER_CONTENT, headerContentAsByte)))); } + public Mono retrieveMessage(PostgresMessageId messageId) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select( + INTERNAL_DATE, SIZE, BODY_START_OCTET, HEADER_CONTENT, BODY_BLOB_ID) + .from(TABLE_NAME) + .where(MESSAGE_ID.eq(messageId.asUuid())))) + .map(record -> toMessageRepresentation(record, messageId)); + } + + private MessageRepresentation toMessageRepresentation(Record record, MessageId messageId) { + return MessageRepresentation.builder() + .messageId(messageId) + .internalDate(LOCAL_DATE_TIME_DATE_FUNCTION.apply(record.get(PostgresMessageModule.MessageTable.INTERNAL_DATE, LocalDateTime.class))) + .size(record.get(PostgresMessageModule.MessageTable.SIZE)) + .headerContent(BYTE_TO_CONTENT_FUNCTION.apply(record.get(HEADER_CONTENT))) + .bodyBlobId(blobIdFactory.from(record.get(BODY_BLOB_ID))) + .build(); + } + public Mono deleteByMessageId(PostgresMessageId messageId) { return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(TABLE_NAME) .where(MESSAGE_ID.eq(messageId.asUuid())))); } - public Mono getBlobId(PostgresMessageId messageId) { + public Mono getBodyBlobId(PostgresMessageId messageId) { return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select(BODY_BLOB_ID) .from(TABLE_NAME) .where(MESSAGE_ID.eq(messageId.asUuid())))) diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerContract.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerContract.java index 6ca3ce90236..2ebb80f843c 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerContract.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerContract.java @@ -83,7 +83,7 @@ void deleteMailboxShouldDeleteUnreferencedMessageMetadata() throws Exception { PostgresMessageId messageId = (PostgresMessageId) appendResult.getId().getMessageId(); PostgresMailboxId mailboxId = (PostgresMailboxId) appendResult.getId().getMailboxId(); - softly.assertThat(postgresMessageDAO.getBlobId(messageId).blockOptional()) + softly.assertThat(postgresMessageDAO.getBodyBlobId(messageId).blockOptional()) .isEmpty(); softly.assertThat(postgresMailboxMessageDAO.countTotalMessagesByMailboxId(mailboxId).block()) @@ -102,7 +102,7 @@ void deleteMailboxShouldNotDeleteReferencedMessageMetadata() throws Exception { assertSoftly(softly -> { PostgresMessageId messageId = (PostgresMessageId) appendResult.getId().getMessageId(); - softly.assertThat(postgresMessageDAO.getBlobId(messageId).blockOptional()) + softly.assertThat(postgresMessageDAO.getBodyBlobId(messageId).blockOptional()) .isNotEmpty(); softly.assertThat(postgresMailboxMessageDAO.countTotalMessagesByMailboxId((PostgresMailboxId) otherBoxManager.getId()) @@ -121,7 +121,7 @@ void deleteMessageInMailboxShouldDeleteUnreferencedMessageMetadata() throws Exce assertSoftly(softly -> { PostgresMessageId messageId = (PostgresMessageId) appendResult.getId().getMessageId(); - softly.assertThat(postgresMessageDAO.getBlobId(messageId).blockOptional()) + softly.assertThat(postgresMessageDAO.getBodyBlobId(messageId).blockOptional()) .isEmpty(); }); } @@ -136,7 +136,7 @@ void deleteMessageInMailboxShouldNotDeleteReferencedMessageMetadata() throws Exc PostgresMessageId messageId = (PostgresMessageId) appendResult.getId().getMessageId(); assertSoftly(softly -> { - softly.assertThat(postgresMessageDAO.getBlobId(messageId).blockOptional()) + softly.assertThat(postgresMessageDAO.getBodyBlobId(messageId).blockOptional()) .isNotEmpty(); softly.assertThat(postgresMailboxMessageDAO.countTotalMessagesByMailboxId((PostgresMailboxId) otherBoxManager.getId()) diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java index ccdcf906ce6..a7753286f42 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java @@ -52,6 +52,8 @@ import org.apache.james.server.blob.deduplication.DeDuplicationBlobStore; import org.apache.james.utils.UpdatableTickingClock; +import com.google.common.collect.ImmutableSet; + public class PostgresMailboxManagerProvider { private static final int LIMIT_ANNOTATIONS = 3; @@ -80,7 +82,8 @@ public static PostgresMailboxManager provideMailboxManager(PostgresExtension pos PostgresMessageDAO.Factory postgresMessageDAOFactory = new PostgresMessageDAO.Factory(BLOB_ID_FACTORY, postgresExtension.getExecutorFactory()); PostgresMailboxMessageDAO.Factory postgresMailboxMessageDAOFactory = new PostgresMailboxMessageDAO.Factory(postgresExtension.getExecutorFactory()); - eventBus.register(new DeleteMessageListener(blobStore, postgresMailboxMessageDAOFactory, postgresMessageDAOFactory)); + eventBus.register(new DeleteMessageListener(blobStore, postgresMailboxMessageDAOFactory, postgresMessageDAOFactory, + ImmutableSet.of())); return new PostgresMailboxManager((PostgresMailboxSessionMapperFactory) mf, sessionProvider, messageParser, new PostgresMessageId.Factory(), diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresDeletedMessageVaultModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresDeletedMessageVaultModule.java index 6e874b0d4fc..607776982f5 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresDeletedMessageVaultModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresDeletedMessageVaultModule.java @@ -20,8 +20,10 @@ package org.apache.james.modules.mailbox; import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.mailbox.postgres.DeleteMessageListener; import org.apache.james.modules.vault.DeletedMessageVaultModule; import org.apache.james.vault.metadata.DeletedMessageMetadataVault; +import org.apache.james.vault.metadata.DeletedMessageVaultDeletionCallback; import org.apache.james.vault.metadata.PostgresDeletedMessageMetadataModule; import org.apache.james.vault.metadata.PostgresDeletedMessageMetadataVault; @@ -40,5 +42,9 @@ protected void configure() { bind(PostgresDeletedMessageMetadataVault.class).in(Scopes.SINGLETON); bind(DeletedMessageMetadataVault.class) .to(PostgresDeletedMessageMetadataVault.class); + + Multibinder.newSetBinder(binder(), DeleteMessageListener.DeletionCallback.class) + .addBinding() + .to(DeletedMessageVaultDeletionCallback.class); } } diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java index 2361fbc750c..97f4716a4a5 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java @@ -129,6 +129,7 @@ protected void configure() { Multibinder.newSetBinder(binder(), EventListener.ReactiveGroupEventListener.class) .addBinding().to(DeleteMessageListener.class); + Multibinder.newSetBinder(binder(), DeleteMessageListener.DeletionCallback.class); bind(MailboxManager.class).annotatedWith(Names.named(MAILBOXMANAGER_NAME)).to(MailboxManager.class); bind(MailboxManagerConfiguration.class).toInstance(MailboxManagerConfiguration.DEFAULT); From 4f8d988e35762759c68cf18a9a889511c708dad0 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Mon, 8 Jan 2024 14:43:47 +0700 Subject: [PATCH 179/341] JAMES-2586 PostgresDeletedMessageVaultIntegrationTest Can not rely on `DeletedMessageVaultIntegrationTest` for now, as it requires `JmapGuiceProbe`. --- Jenkinsfile | 1 + pom.xml | 11 ++ .../apache/james/PostgresJamesServerMain.java | 2 +- .../webadmin-integration-test/pom.xml | 1 + .../pom.xml | 114 +++++++++++++++ ...resDeletedMessageVaultIntegrationTest.java | 131 ++++++++++++++++++ .../src/test/resources/dnsservice.xml | 25 ++++ .../src/test/resources/domainlist.xml | 24 ++++ .../src/test/resources/imapserver.xml | 41 ++++++ .../src/test/resources/jwt_publickey | 9 ++ .../src/test/resources/listeners.xml | 49 +++++++ .../src/test/resources/lmtpserver.xml | 23 +++ .../src/test/resources/mailetcontainer.xml | 117 ++++++++++++++++ .../test/resources/mailrepositorystore.xml | 31 +++++ .../src/test/resources/managesieveserver.xml | 32 +++++ .../src/test/resources/pop3server.xml | 23 +++ .../src/test/resources/smtpserver.xml | 54 ++++++++ .../src/test/resources/webadmin.properties | 27 ++++ 18 files changed, 714 insertions(+), 1 deletion(-) create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/pom.xml create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/vault/PostgresDeletedMessageVaultIntegrationTest.java create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/dnsservice.xml create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/domainlist.xml create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/imapserver.xml create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/jwt_publickey create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/listeners.xml create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/lmtpserver.xml create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/mailetcontainer.xml create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/mailrepositorystore.xml create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/managesieveserver.xml create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/pop3server.xml create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/smtpserver.xml create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/webadmin.properties diff --git a/Jenkinsfile b/Jenkinsfile index 2fde792fbdd..6178026cb62 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -45,6 +45,7 @@ pipeline { 'server/container/guice/postgres-common,' + 'server/container/guice/mailbox-postgres,' + 'server/apps/postgres-app,' + + 'server/protocols/webadmin-integration-test/postgres-webadmin-integration-test,' + 'mpt/impl/imap-mailbox/postgres,' + 'event-bus/postgres,' + 'mailbox/plugin/deleted-messages-vault-postgres' diff --git a/pom.xml b/pom.xml index 24717476b3b..e9fb8321622 100644 --- a/pom.xml +++ b/pom.xml @@ -1743,6 +1743,17 @@ james-server-onami ${project.version} + + ${james.groupId} + james-server-postgres-app + ${project.version} + + + ${james.groupId} + james-server-postgres-app + ${project.version} + test-jar + ${james.groupId} james-server-protocols-imap4 diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index fd07cc23ac2..5da7524604d 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -127,7 +127,7 @@ public static void main(String[] args) throws Exception { JamesServerMain.main(server); } - static GuiceJamesServer createServer(PostgresJamesConfiguration configuration) { + public static GuiceJamesServer createServer(PostgresJamesConfiguration configuration) { SearchConfiguration searchConfiguration = configuration.searchConfiguration(); return GuiceJamesServer.forConfiguration(configuration) diff --git a/server/protocols/webadmin-integration-test/pom.xml b/server/protocols/webadmin-integration-test/pom.xml index ea9509f5154..f3bd3187991 100644 --- a/server/protocols/webadmin-integration-test/pom.xml +++ b/server/protocols/webadmin-integration-test/pom.xml @@ -35,6 +35,7 @@ distributed-webadmin-integration-test memory-webadmin-integration-test + postgres-webadmin-integration-test webadmin-integration-test-common diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/pom.xml b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/pom.xml new file mode 100644 index 00000000000..3bed95cec39 --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/pom.xml @@ -0,0 +1,114 @@ + + + + 4.0.0 + + + org.apache.james + webadmin-integration-test + 3.9.0-SNAPSHOT + ../pom.xml + + + postgres-webadmin-integration-test + jar + + Apache James :: Server :: Web Admin server integration tests :: Postgres App + + + + + ${james.groupId} + james-server-guice + ${project.version} + pom + import + + + + + + + ${james.groupId} + apache-james-backends-postgres + test-jar + test + + + ${james.groupId} + apache-james-mailbox-opensearch + test-jar + test + + + ${james.groupId} + blob-s3 + test-jar + test + + + ${james.groupId} + blob-s3-guice + test-jar + test + + + ${james.groupId} + james-server-guice-opensearch + ${project.version} + test-jar + test + + + ${james.groupId} + james-server-postgres-app + test + + + ${james.groupId} + james-server-postgres-app + test-jar + test + + + ${james.groupId} + james-server-webadmin-integration-test-common + test + + + org.testcontainers + postgresql + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + 1800 + + + + + diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/vault/PostgresDeletedMessageVaultIntegrationTest.java b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/vault/PostgresDeletedMessageVaultIntegrationTest.java new file mode 100644 index 00000000000..fc12a043605 --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/vault/PostgresDeletedMessageVaultIntegrationTest.java @@ -0,0 +1,131 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.webadmin.integration.vault; + +import static io.restassured.config.ParamConfig.UpdateStrategy.REPLACE; +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; +import static org.awaitility.Durations.FIVE_HUNDRED_MILLISECONDS; +import static org.awaitility.Durations.ONE_MINUTE; + +import org.apache.james.GuiceJamesServer; +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.PostgresJamesConfiguration; +import org.apache.james.PostgresJamesServerMain; +import org.apache.james.SearchConfiguration; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.mailbox.DefaultMailboxes; +import org.apache.james.modules.protocols.ImapGuiceProbe; +import org.apache.james.modules.protocols.SmtpGuiceProbe; +import org.apache.james.utils.DataProbeImpl; +import org.apache.james.utils.SMTPMessageSender; +import org.apache.james.utils.TestIMAPClient; +import org.apache.james.utils.WebAdminGuiceProbe; +import org.apache.james.vault.VaultConfiguration; +import org.apache.james.webadmin.WebAdminUtils; +import org.awaitility.Awaitility; +import org.awaitility.core.ConditionFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.restassured.config.ParamConfig; +import io.restassured.specification.RequestSpecification; + +class PostgresDeletedMessageVaultIntegrationTest { + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.scanning()) + .usersRepository(DEFAULT) + .eventBusImpl(PostgresJamesConfiguration.EventBusImpl.IN_MEMORY) + .deletedMessageVaultConfiguration(VaultConfiguration.ENABLED_DEFAULT) + .build()) + .server(PostgresJamesServerMain::createServer) + .extension(PostgresExtension.empty()) + .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) + .build(); + + private static final ConditionFactory AWAIT = Awaitility.await() + .atMost(ONE_MINUTE) + .with() + .pollInterval(FIVE_HUNDRED_MILLISECONDS); + private static final String DOMAIN = "james.local"; + private static final String USER = "toto@" + DOMAIN; + private static final String PASSWORD = "123456"; + private static final String JAMES_SERVER_HOST = "127.0.0.1"; + + private TestIMAPClient testIMAPClient; + private SMTPMessageSender smtpMessageSender; + private RequestSpecification webAdminApi; + + @BeforeEach + void setUp(GuiceJamesServer jamesServer) throws Exception { + this.testIMAPClient = new TestIMAPClient(); + this.smtpMessageSender = new SMTPMessageSender(DOMAIN); + this.webAdminApi = WebAdminUtils.spec(jamesServer.getProbe(WebAdminGuiceProbe.class).getWebAdminPort()) + .config(WebAdminUtils.defaultConfig() + .paramConfig(new ParamConfig(REPLACE, REPLACE, REPLACE))); + + jamesServer.getProbe(DataProbeImpl.class) + .fluent() + .addDomain(DOMAIN) + .addUser(USER, PASSWORD); + } + + @Test + void restoreDeletedMessageShouldSucceed(GuiceJamesServer jamesServer) throws Exception { + // Create a message + int imapPort = jamesServer.getProbe(ImapGuiceProbe.class).getImapPort(); + smtpMessageSender.connect(JAMES_SERVER_HOST, jamesServer.getProbe(SmtpGuiceProbe.class).getSmtpPort()) + .authenticate(USER, PASSWORD) + .sendMessageWithHeaders(USER, USER, "Subject: thisIsASubject\r\n\r\nBody"); + testIMAPClient.connect(JAMES_SERVER_HOST, imapPort) + .login(USER, PASSWORD) + .select(TestIMAPClient.INBOX) + .awaitMessageCount(AWAIT, 1); + + // Delete the message + testIMAPClient.setFlagsForAllMessagesInMailbox("\\Deleted"); + testIMAPClient.expunge(); + testIMAPClient.awaitNoMessage(AWAIT); + + // Restore the message using the Deleted message vault webadmin endpoint + String restoreBySubjectQuery = "{" + + " \"combinator\": \"and\"," + + " \"limit\": 1," + + " \"criteria\": [" + + " {" + + " \"fieldName\": \"subject\"," + + " \"operator\": \"equals\"," + + " \"value\": \"thisIsASubject\"" + + " }" + + " ]" + + "}"; + DeletedMessagesVaultRequests.restoreMessagesForUserWithQuery(webAdminApi, USER, restoreBySubjectQuery); + + // await the message to be restored + testIMAPClient.select(DefaultMailboxes.RESTORED_MESSAGES) + .awaitMessageCount(AWAIT, 1); + } + +} diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/dnsservice.xml b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/dnsservice.xml new file mode 100644 index 00000000000..6e4fbd2efb5 --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/dnsservice.xml @@ -0,0 +1,25 @@ + + + + + true + false + 50000 + diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/domainlist.xml b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/domainlist.xml new file mode 100644 index 00000000000..fe17431a1ea --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/domainlist.xml @@ -0,0 +1,24 @@ + + + + + false + false + diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/imapserver.xml b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/imapserver.xml new file mode 100644 index 00000000000..f7429d1ac37 --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/imapserver.xml @@ -0,0 +1,41 @@ + + + + + + + + imapserver + 0.0.0.0:0 + 200 + + + file://conf/keystore + james72laBalle + org.bouncycastle.jce.provider.BouncyCastleProvider + + 0 + 0 + false + false + + diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/jwt_publickey b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/jwt_publickey new file mode 100644 index 00000000000..53914e0533a --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/jwt_publickey @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtlChO/nlVP27MpdkG0Bh +16XrMRf6M4NeyGa7j5+1UKm42IKUf3lM28oe82MqIIRyvskPc11NuzSor8HmvH8H +lhDs5DyJtx2qp35AT0zCqfwlaDnlDc/QDlZv1CoRZGpQk1Inyh6SbZwYpxxwh0fi ++d/4RpE3LBVo8wgOaXPylOlHxsDizfkL8QwXItyakBfMO6jWQRrj7/9WDhGf4Hi+ +GQur1tPGZDl9mvCoRHjFrD5M/yypIPlfMGWFVEvV5jClNMLAQ9bYFuOc7H1fEWw6 +U1LZUUbJW9/CH45YXz82CYqkrfbnQxqRb2iVbVjs/sHopHd1NTiCfUtwvcYJiBVj +kwIDAQAB +-----END PUBLIC KEY----- diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/listeners.xml b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/listeners.xml new file mode 100644 index 00000000000..ff2e5172324 --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/listeners.xml @@ -0,0 +1,49 @@ + + + + + + org.apache.james.mailbox.cassandra.MailboxOperationLoggingListener + + + org.apache.james.mailbox.quota.mailing.listeners.QuotaThresholdCrossingListener + QuotaThresholdCrossingListener-lower-threshold + + + + 0.1 + + + first + + + + org.apache.james.mailbox.quota.mailing.listeners.QuotaThresholdCrossingListener + QuotaThresholdCrossingListener-upper-threshold + + + + 0.2 + + + second + + + \ No newline at end of file diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/lmtpserver.xml b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/lmtpserver.xml new file mode 100644 index 00000000000..f838adb5f01 --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/lmtpserver.xml @@ -0,0 +1,23 @@ + + + + + + diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/mailetcontainer.xml b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/mailetcontainer.xml new file mode 100644 index 00000000000..166cf259cec --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/mailetcontainer.xml @@ -0,0 +1,117 @@ + + + + + + + + postmaster + + + + 20 + postgres://var/mail/error/ + + + + + + + + transport + + + + + + ignore + + + postgres://var/mail/error/ + ignore + + + + + + + + + + + + + + bcc + + + + ignore + + + + local-address-error + 550 - Requested action not taken: no such user here + + + + outgoing + 5000, 100000, 500000 + 3 + 0 + 10 + true + bounces + + + relay-denied + + + + + + none + + + postgres://var/mail/address-error/ + + + + + + none + + + postgres://var/mail/relay-denied/ + Warning: You are sending an e-mail to a remote server. You must be authentified to perform such an operation + + + + + + false + + + + + + + + diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/mailrepositorystore.xml b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/mailrepositorystore.xml new file mode 100644 index 00000000000..689745af60f --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/mailrepositorystore.xml @@ -0,0 +1,31 @@ + + + + + + + + + postgres + + + + + diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/managesieveserver.xml b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/managesieveserver.xml new file mode 100644 index 00000000000..f136a432b8a --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/managesieveserver.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/pop3server.xml b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/pop3server.xml new file mode 100644 index 00000000000..bec385ae306 --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/pop3server.xml @@ -0,0 +1,23 @@ + + + + + + diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/smtpserver.xml b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/smtpserver.xml new file mode 100644 index 00000000000..2fd612d961b --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/smtpserver.xml @@ -0,0 +1,54 @@ + + + + + + + smtpserver-global + 0.0.0.0:0 + 200 + + file://conf/keystore + james72laBalle + org.bouncycastle.jce.provider.BouncyCastleProvider + SunX509 + + 360 + 0 + 0 + + never + false + true + + 0.0.0.0/0 + false + 0 + true + Apache JAMES awesome SMTP Server + + + + + false + + + + diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/webadmin.properties b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/webadmin.properties new file mode 100644 index 00000000000..78a176aabda --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/webadmin.properties @@ -0,0 +1,27 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# This template file can be used as example for James Server configuration +# DO NOT USE IT AS SUCH AND ADAPT IT TO YOUR NEEDS + +# Read https://james.apache.org/server/config-webadmin.html for further details + +enabled=true +port=0 +host=127.0.0.1 + +extensions.routes=org.apache.james.webadmin.dropwizard.MetricsRoutes \ No newline at end of file From b6edc7ae223a212fc15a06917b9a31a8f290bf02 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Mon, 8 Jan 2024 15:39:09 +0700 Subject: [PATCH 180/341] JAMES-2586 Plug PreDeletionHooks --- .../postgres/mail/PostgresMailboxManager.java | 6 ++++-- .../postgres/mail/PostgresMessageManager.java | 4 ++-- .../postgres/DeleteMessageListenerTest.java | 3 ++- .../postgres/DeleteMessageListenerWithRLSTest.java | 3 ++- .../postgres/PostgresMailboxManagerProvider.java | 6 ++++-- .../postgres/PostgresMailboxManagerStressTest.java | 4 +++- .../postgres/PostgresMailboxManagerTest.java | 14 ++++---------- .../PostgresRecomputeCurrentQuotasServiceTest.java | 3 ++- .../postgres/host/PostgresHostSystem.java | 4 +++- 9 files changed, 26 insertions(+), 21 deletions(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxManager.java index 070c12333ae..0f25e6bc081 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxManager.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxManager.java @@ -62,11 +62,12 @@ public PostgresMailboxManager(PostgresMailboxSessionMapperFactory mapperFactory, QuotaComponents quotaComponents, MessageSearchIndex index, ThreadIdGuessingAlgorithm threadIdGuessingAlgorithm, + PreDeletionHooks preDeletionHooks, Clock clock) { super(mapperFactory, sessionProvider, new NoMailboxPathLocker(), messageParser, messageIdFactory, annotationManager, eventBus, storeRightManager, quotaComponents, - index, MailboxManagerConfiguration.DEFAULT, PreDeletionHooks.NO_PRE_DELETION_HOOK, threadIdGuessingAlgorithm, clock); + index, MailboxManagerConfiguration.DEFAULT, preDeletionHooks, threadIdGuessingAlgorithm, clock); } @Override @@ -82,7 +83,8 @@ protected StoreMessageManager createMessageManager(Mailbox mailboxRow, MailboxSe configuration.getBatchSizes(), getStoreRightManager(), getThreadIdGuessingAlgorithm(), - getClock()); + getClock(), + getPreDeletionHooks()); } @Override diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageManager.java index c10700e36af..4bf0c237bd0 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageManager.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageManager.java @@ -64,9 +64,9 @@ public PostgresMessageManager(MailboxSessionMapperFactory mapperFactory, QuotaManager quotaManager, QuotaRootResolver quotaRootResolver, MessageId.Factory messageIdFactory, BatchSizes batchSizes, StoreRightManager storeRightManager, ThreadIdGuessingAlgorithm threadIdGuessingAlgorithm, - Clock clock) { + Clock clock, PreDeletionHooks preDeletionHooks) { super(StoreMailboxManager.DEFAULT_NO_MESSAGE_CAPABILITIES, mapperFactory, index, eventBus, locker, mailbox, - quotaManager, quotaRootResolver, batchSizes, storeRightManager, PreDeletionHooks.NO_PRE_DELETION_HOOK, + quotaManager, quotaRootResolver, batchSizes, storeRightManager, preDeletionHooks, new MessageStorer.WithoutAttachment(mapperFactory, messageIdFactory, new MessageFactory.StoreMessageFactory(), threadIdGuessingAlgorithm, clock)); this.storeRightManager = storeRightManager; this.mapperFactory = mapperFactory; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java index bc769f20426..7e93f82be6f 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java @@ -25,6 +25,7 @@ import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.apache.james.mailbox.store.PreDeletionHooks; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.extension.RegisterExtension; @@ -36,7 +37,7 @@ public class DeleteMessageListenerTest extends DeleteMessageListenerContract { @BeforeAll static void beforeAll() { - mailboxManager = PostgresMailboxManagerProvider.provideMailboxManager(postgresExtension); + mailboxManager = PostgresMailboxManagerProvider.provideMailboxManager(postgresExtension, PreDeletionHooks.NO_PRE_DELETION_HOOK); } @Override diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java index 996ceddb721..3d76c756867 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java @@ -28,6 +28,7 @@ import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.apache.james.mailbox.store.PreDeletionHooks; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.extension.RegisterExtension; @@ -40,7 +41,7 @@ public class DeleteMessageListenerWithRLSTest extends DeleteMessageListenerContr @BeforeAll static void beforeAll() { - mailboxManager = PostgresMailboxManagerProvider.provideMailboxManager(postgresExtension); + mailboxManager = PostgresMailboxManagerProvider.provideMailboxManager(postgresExtension, PreDeletionHooks.NO_PRE_DELETION_HOOK); } @Override diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java index a7753286f42..99377923daf 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java @@ -39,6 +39,7 @@ import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.store.MailboxSessionMapperFactory; +import org.apache.james.mailbox.store.PreDeletionHooks; import org.apache.james.mailbox.store.SessionProviderImpl; import org.apache.james.mailbox.store.StoreMailboxAnnotationManager; import org.apache.james.mailbox.store.StoreRightManager; @@ -61,7 +62,7 @@ public class PostgresMailboxManagerProvider { public static final BlobId.Factory BLOB_ID_FACTORY = new HashBlobId.Factory(); - public static PostgresMailboxManager provideMailboxManager(PostgresExtension postgresExtension) { + public static PostgresMailboxManager provideMailboxManager(PostgresExtension postgresExtension, PreDeletionHooks preDeletionHooks) { DeDuplicationBlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, BLOB_ID_FACTORY); MailboxSessionMapperFactory mf = provideMailboxSessionMapperFactory(postgresExtension, BLOB_ID_FACTORY, blobStore); @@ -88,7 +89,8 @@ public static PostgresMailboxManager provideMailboxManager(PostgresExtension pos return new PostgresMailboxManager((PostgresMailboxSessionMapperFactory) mf, sessionProvider, messageParser, new PostgresMessageId.Factory(), eventBus, annotationManager, - storeRightManager, quotaComponents, index, new NaiveThreadIdGuessingAlgorithm(), new UpdatableTickingClock(Instant.now())); + storeRightManager, quotaComponents, index, new NaiveThreadIdGuessingAlgorithm(), + preDeletionHooks, new UpdatableTickingClock(Instant.now())); } public static MailboxSessionMapperFactory provideMailboxSessionMapperFactory(PostgresExtension postgresExtension) { diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerStressTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerStressTest.java index c61c56eb3a6..036d08f079b 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerStressTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerStressTest.java @@ -25,6 +25,7 @@ import org.apache.james.events.EventBus; import org.apache.james.mailbox.MailboxManagerStressContract; import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; +import org.apache.james.mailbox.store.PreDeletionHooks; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.RegisterExtension; @@ -48,7 +49,8 @@ public EventBus retrieveEventBus() { @BeforeEach void setUp() { if (mailboxManager.isEmpty()) { - mailboxManager = Optional.of(PostgresMailboxManagerProvider.provideMailboxManager(postgresExtension)); + mailboxManager = Optional.of(PostgresMailboxManagerProvider.provideMailboxManager(postgresExtension, + PreDeletionHooks.NO_PRE_DELETION_HOOK)); } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerTest.java index 537a124c969..320e5c8d252 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerTest.java @@ -25,19 +25,12 @@ import org.apache.james.mailbox.MailboxManagerTest; import org.apache.james.mailbox.SubscriptionManager; import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; +import org.apache.james.mailbox.store.PreDeletionHooks; import org.apache.james.mailbox.store.StoreSubscriptionManager; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Nested; +import org.apache.james.metrics.tests.RecordingMetricFactory; import org.junit.jupiter.api.extension.RegisterExtension; class PostgresMailboxManagerTest extends MailboxManagerTest { - - @Disabled("JPAMailboxManager is using DefaultMessageId which doesn't support full feature of a messageId, which is an essential" + - " element of the Vault") - @Nested - class HookTests { - } - @RegisterExtension static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); @@ -46,7 +39,8 @@ class HookTests { @Override protected PostgresMailboxManager provideMailboxManager() { if (mailboxManager.isEmpty()) { - mailboxManager = Optional.of(PostgresMailboxManagerProvider.provideMailboxManager(postgresExtension)); + mailboxManager = Optional.of(PostgresMailboxManagerProvider.provideMailboxManager(postgresExtension, + new PreDeletionHooks(preDeletionHooks(), new RecordingMetricFactory()))); } return mailboxManager.get(); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/PostgresRecomputeCurrentQuotasServiceTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/PostgresRecomputeCurrentQuotasServiceTest.java index 0d2ba967de2..89eed4ddc1c 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/PostgresRecomputeCurrentQuotasServiceTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/PostgresRecomputeCurrentQuotasServiceTest.java @@ -36,6 +36,7 @@ import org.apache.james.mailbox.quota.task.RecomputeCurrentQuotasServiceContract; import org.apache.james.mailbox.quota.task.RecomputeMailboxCurrentQuotasService; import org.apache.james.mailbox.store.MailboxSessionMapperFactory; +import org.apache.james.mailbox.store.PreDeletionHooks; import org.apache.james.mailbox.store.StoreMailboxManager; import org.apache.james.mailbox.store.quota.CurrentQuotaCalculator; import org.apache.james.mailbox.store.quota.DefaultUserQuotaRootResolver; @@ -78,7 +79,7 @@ void setUp() throws Exception { configuration.addProperty("enableVirtualHosting", "false"); usersRepository.configure(configuration); - mailboxManager = PostgresMailboxManagerProvider.provideMailboxManager(postgresExtension); + mailboxManager = PostgresMailboxManagerProvider.provideMailboxManager(postgresExtension, PreDeletionHooks.NO_PRE_DELETION_HOOK); sessionProvider = mailboxManager.getSessionProvider(); currentQuotaManager = new PostgresCurrentQuotaManager(new PostgresQuotaCurrentValueDAO(postgresExtension.getPostgresExecutor())); diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java index 0e2a041730b..3bdb05e1cee 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java @@ -50,6 +50,7 @@ import org.apache.james.mailbox.postgres.quota.PostgresCurrentQuotaManager; import org.apache.james.mailbox.postgres.quota.PostgresPerUserMaxQuotaManager; import org.apache.james.mailbox.quota.CurrentQuotaManager; +import org.apache.james.mailbox.store.PreDeletionHooks; import org.apache.james.mailbox.store.SessionProviderImpl; import org.apache.james.mailbox.store.StoreMailboxAnnotationManager; import org.apache.james.mailbox.store.StoreRightManager; @@ -128,7 +129,8 @@ public void beforeTest() throws Exception { mailboxManager = new PostgresMailboxManager(mapperFactory, sessionProvider, messageParser, new PostgresMessageId.Factory(), - eventBus, annotationManager, storeRightManager, quotaComponents, index, new NaiveThreadIdGuessingAlgorithm(), new UpdatableTickingClock(Instant.now())); + eventBus, annotationManager, storeRightManager, quotaComponents, index, new NaiveThreadIdGuessingAlgorithm(), + PreDeletionHooks.NO_PRE_DELETION_HOOK, new UpdatableTickingClock(Instant.now())); eventBus.register(quotaUpdater); eventBus.register(new MailboxAnnotationListener(mapperFactory, sessionProvider)); From f3dc19aa5735bd41fd7fe3753ae655460395d3cc Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Tue, 9 Jan 2024 13:09:44 +0700 Subject: [PATCH 181/341] JAMES-2586 - Set blobStorage implementation is postgres by default --- server/apps/postgres-app/docker-compose.yml | 1 + .../sample-configuration/blob.properties | 66 +++++++++++++++++++ .../james/PostgresJamesConfiguration.java | 5 +- .../BodyDeduplicationIntegrationTest.java | 2 +- 4 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 server/apps/postgres-app/sample-configuration/blob.properties diff --git a/server/apps/postgres-app/docker-compose.yml b/server/apps/postgres-app/docker-compose.yml index 50440253bde..2eabbe331bd 100644 --- a/server/apps/postgres-app/docker-compose.yml +++ b/server/apps/postgres-app/docker-compose.yml @@ -21,6 +21,7 @@ services: - "8000:8000" volumes: - ./sample-configuration-single/search.properties:/root/conf/search.properties + - ./sample-configuration/blob.properties:/root/conf/blob.properties postgres: image: postgres:16.1 diff --git a/server/apps/postgres-app/sample-configuration/blob.properties b/server/apps/postgres-app/sample-configuration/blob.properties new file mode 100644 index 00000000000..3a01ce1e91b --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/blob.properties @@ -0,0 +1,66 @@ +# ============================================= BlobStore Implementation ================================== +# Read https://james.apache.org/server/config-blobstore.html for further details + +# Choose your BlobStore implementation +# Mandatory, allowed values are: file, s3, postgres. +implementation=postgres + +# ========================================= Deduplication ======================================== +# If you choose to enable deduplication, the mails with the same content will be stored only once. +# Warning: Once this feature is enabled, there is no turning back as turning it off will lead to the deletion of all +# the mails sharing the same content once one is deleted. +# Mandatory, Allowed values are: true, false +deduplication.enable=true + +# deduplication.family needs to be incremented every time the deduplication.generation.duration is changed +# Positive integer, defaults to 1 +# deduplication.gc.generation.family=1 + +# Duration of generation. +# Deduplication only takes place within a singe generation. +# Only items two generation old can be garbage collected. (This prevent concurrent insertions issues and +# accounts for a clock skew). +# deduplication.family needs to be incremented everytime this parameter is changed. +# Duration. Default unit: days. Defaults to 30 days. +# deduplication.gc.generation.duration=30days + +# ========================================= Encryption ======================================== +# If you choose to enable encryption, the blob content will be encrypted before storing them in the BlobStore. +# Warning: Once this feature is enabled, there is no turning back as turning it off will lead to all content being +# encrypted. This comes at a performance impact but presents you from leaking data if, for instance the third party +# offering you a S3 service is compromised. +# Optional, Allowed values are: true, false, defaults to false +encryption.aes.enable=false + +# Mandatory (if AES encryption is enabled) salt and password. Salt needs to be an hexadecimal encoded string +#encryption.aes.password=xxx +#encryption.aes.salt=73616c7479 +# Optional, defaults to PBKDF2WithHmacSHA512 +#encryption.aes.private.key.algorithm=PBKDF2WithHmacSHA512 + +# ============================================ Blobs Exporting ============================================== +# Read https://james.apache.org/server/config-blob-export.html for further details + +# Choosing blob exporting mechanism, allowed mechanism are: localFile, linshare +# LinShare is a file sharing service, will be explained in the below section +# Optional, default is localFile +blob.export.implementation=localFile + +# ======================================= Local File Blobs Exporting ======================================== +# Optional, directory to store exported blob, directory path follows James file system format +# default is file://var/blobExporting +blob.export.localFile.directory=file://var/blobExporting + +# ======================================= LinShare File Blobs Exporting ======================================== +# LinShare is a sharing service where you can use james, connects to an existing LinShare server and shares files to +# other mail addresses as long as those addresses available in LinShare. For example you can deploy James and LinShare +# sharing the same LDAP repository +# Mandatory if you choose LinShare, url to connect to LinShare service +# blob.export.linshare.url=http://linshare:8080 + +# ======================================= LinShare Configuration BasicAuthentication =================================== +# Authentication is mandatory if you choose LinShare, TechnicalAccount is need to connect to LinShare specific service. +# For Example: It will be formalized to 'Authorization: Basic {Credential of UUID/password}' + +# blob.export.linshare.technical.account.uuid=Technical_Account_UUID +# blob.export.linshare.technical.account.password=password diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java index d526c892378..dbf65c350f9 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java @@ -43,9 +43,9 @@ public class PostgresJamesConfiguration implements Configuration { - private static final Logger LOGGER = LoggerFactory.getLogger(PostgresJamesConfiguration.class); + private static final Logger LOGGER = LoggerFactory.getLogger("org.apache.james.CONFIGURATION"); - private static BlobStoreConfiguration.BlobStoreImplName DEFAULT_BLOB_STORE = BlobStoreConfiguration.BlobStoreImplName.FILE; + private static final BlobStoreConfiguration.BlobStoreImplName DEFAULT_BLOB_STORE = BlobStoreConfiguration.BlobStoreImplName.POSTGRES; public enum EventBusImpl { IN_MEMORY, RABBITMQ; @@ -171,6 +171,7 @@ public PostgresJamesConfiguration build() { } }); + LOGGER.info("BlobStore configuration {}", blobStoreConfiguration); return new PostgresJamesConfiguration( configurationPath, directories, diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/BodyDeduplicationIntegrationTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/BodyDeduplicationIntegrationTest.java index ec50a572844..c048b3de6b3 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/BodyDeduplicationIntegrationTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/BodyDeduplicationIntegrationTest.java @@ -56,7 +56,7 @@ class BodyDeduplicationIntegrationTest implements MailsShouldBeWellReceived { .configurationFromClasspath() .searchConfiguration(SearchConfiguration.scanning()) .blobStore(BlobStoreConfiguration.builder() - .file() + .postgres() .disableCache() .deduplication() .noCryptoConfig()) From c7a3b8ba827043f7303704c59313e3edadb75cb3 Mon Sep 17 00:00:00 2001 From: hung phan Date: Thu, 4 Jan 2024 17:11:37 +0700 Subject: [PATCH 182/341] JAMES-2586 Implement BlobReferenceSource(s) for postgres-app --- .../postgres/PostgresConfiguration.java | 177 ++++++--- .../utils/JamesPostgresConnectionFactory.java | 1 + .../postgres/utils/PostgresExecutor.java | 1 + .../postgres/PostgresConfigurationTest.java | 115 +++--- .../backends/postgres/PostgresExtension.java | 21 +- .../PostgresMessageBlobReferenceSource.java | 42 +++ .../postgres/mail/dao/PostgresMessageDAO.java | 11 +- ...ostgresMessageBlobReferenceSourceTest.java | 100 +++++ .../sample-configuration/postgres.properties | 21 +- .../apache/james/PostgresJamesServerMain.java | 8 - .../mailbox/PostgresMailboxModule.java | 8 + .../modules/data/PostgresCommonModule.java | 45 ++- .../data/PostgresMailRepositoryModule.java | 7 + .../postgres/PostgresMailRepository.java | 301 +-------------- ...gresMailRepositoryBlobReferenceSource.java | 41 ++ .../PostgresMailRepositoryContentDAO.java | 354 ++++++++++++++++++ .../PostgresMailRepositoryFactory.java | 2 +- ...MailRepositoryBlobReferenceSourceTest.java | 94 +++++ .../postgres/PostgresMailRepositoryTest.java | 2 +- 19 files changed, 927 insertions(+), 424 deletions(-) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageBlobReferenceSource.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageBlobReferenceSourceTest.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryBlobReferenceSource.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryContentDAO.java create mode 100644 server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryBlobReferenceSourceTest.java diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java index 7ffeb8be400..82683044ff7 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java @@ -19,31 +19,34 @@ package org.apache.james.backends.postgres; -import java.net.URI; -import java.util.List; import java.util.Objects; import java.util.Optional; import org.apache.commons.configuration2.Configuration; -import com.google.common.base.Joiner; import com.google.common.base.Preconditions; -import com.google.common.base.Splitter; -import com.google.common.collect.ImmutableList; public class PostgresConfiguration { - public static final String URL = "url"; public static final String DATABASE_NAME = "database.name"; public static final String DATABASE_NAME_DEFAULT_VALUE = "postgres"; public static final String DATABASE_SCHEMA = "database.schema"; public static final String DATABASE_SCHEMA_DEFAULT_VALUE = "public"; + public static final String HOST = "database.host"; + public static final String HOST_DEFAULT_VALUE = "localhost"; + public static final String PORT = "database.port"; + public static final int PORT_DEFAULT_VALUE = 5432; + public static final String USERNAME = "database.username"; + public static final String PASSWORD = "database.password"; + public static final String NON_RLS_USERNAME = "database.non-rls.username"; + public static final String NON_RLS_PASSWORD = "database.non-rls.password"; public static final String RLS_ENABLED = "row.level.security.enabled"; public static class Credential { private final String username; private final String password; - Credential(String username, String password) { + + public Credential(String username, String password) { this.username = username; this.password = password; } @@ -58,16 +61,16 @@ public String getPassword() { } public static class Builder { - private Optional url = Optional.empty(); private Optional databaseName = Optional.empty(); private Optional databaseSchema = Optional.empty(); + private Optional host = Optional.empty(); + private Optional port = Optional.empty(); + private Optional username = Optional.empty(); + private Optional password = Optional.empty(); + private Optional nonRLSUser = Optional.empty(); + private Optional nonRLSPassword = Optional.empty(); private Optional rowLevelSecurityEnabled = Optional.empty(); - public Builder url(String url) { - this.url = Optional.of(url); - return this; - } - public Builder databaseName(String databaseName) { this.databaseName = Optional.of(databaseName); return this; @@ -88,6 +91,66 @@ public Builder databaseSchema(Optional databaseSchema) { return this; } + public Builder host(String host) { + this.host = Optional.of(host); + return this; + } + + public Builder host(Optional host) { + this.host = host; + return this; + } + + public Builder port(Integer port) { + this.port = Optional.of(port); + return this; + } + + public Builder port(Optional port) { + this.port = port; + return this; + } + + public Builder username(String username) { + this.username = Optional.of(username); + return this; + } + + public Builder username(Optional username) { + this.username = username; + return this; + } + + public Builder password(String password) { + this.password = Optional.of(password); + return this; + } + + public Builder password(Optional password) { + this.password = password; + return this; + } + + public Builder nonRLSUser(String nonRLSUser) { + this.nonRLSUser = Optional.of(nonRLSUser); + return this; + } + + public Builder nonRLSUser(Optional nonRLSUser) { + this.nonRLSUser = nonRLSUser; + return this; + } + + public Builder nonRLSPassword(String nonRLSPassword) { + this.nonRLSPassword = Optional.of(nonRLSPassword); + return this; + } + + public Builder nonRLSPassword(Optional nonRLSPassword) { + this.nonRLSPassword = nonRLSPassword; + return this; + } + public Builder rowLevelSecurityEnabled(boolean rlsEnabled) { this.rowLevelSecurityEnabled = Optional.of(rlsEnabled); return this; @@ -99,36 +162,22 @@ public Builder rowLevelSecurityEnabled() { } public PostgresConfiguration build() { - Preconditions.checkArgument(url.isPresent() && !url.get().isBlank(), "You need to specify Postgres URI"); - URI postgresURI = asURI(url.get()); + Preconditions.checkArgument(username.isPresent() && !username.get().isBlank(), "You need to specify username"); + Preconditions.checkArgument(password.isPresent() && !password.get().isBlank(), "You need to specify password"); + + if (rowLevelSecurityEnabled.isPresent() && rowLevelSecurityEnabled.get()) { + Preconditions.checkArgument(nonRLSUser.isPresent() && !nonRLSUser.get().isBlank(), "You need to specify nonRLSUser"); + Preconditions.checkArgument(nonRLSPassword.isPresent() && !nonRLSPassword.get().isBlank(), "You need to specify nonRLSPassword"); + } - return new PostgresConfiguration(postgresURI, - parseCredential(postgresURI), + return new PostgresConfiguration(host.orElse(HOST_DEFAULT_VALUE), + port.orElse(PORT_DEFAULT_VALUE), databaseName.orElse(DATABASE_NAME_DEFAULT_VALUE), databaseSchema.orElse(DATABASE_SCHEMA_DEFAULT_VALUE), + new Credential(username.get(), password.get()), + new Credential(nonRLSUser.orElse(username.get()), nonRLSPassword.orElse(password.get())), rowLevelSecurityEnabled.orElse(false)); } - - private Credential parseCredential(URI postgresURI) { - Preconditions.checkArgument(postgresURI.getUserInfo() != null, "Postgres URI need to contains user credential"); - Preconditions.checkArgument(postgresURI.getUserInfo().contains(":"), "User info needs a password part"); - - List parts = Splitter.on(':') - .splitToList(postgresURI.getUserInfo()); - ImmutableList passwordParts = parts.stream() - .skip(1) - .collect(ImmutableList.toImmutableList()); - - return new Credential(parts.get(0), Joiner.on(':').join(passwordParts)); - } - - private URI asURI(String uri) { - try { - return URI.create(uri); - } catch (Exception e) { - throw new IllegalArgumentException("You need to specify a valid Postgres URI", e); - } - } } public static Builder builder() { @@ -137,33 +186,43 @@ public static Builder builder() { public static PostgresConfiguration from(Configuration propertiesConfiguration) { return builder() - .url(propertiesConfiguration.getString(URL, null)) .databaseName(Optional.ofNullable(propertiesConfiguration.getString(DATABASE_NAME))) .databaseSchema(Optional.ofNullable(propertiesConfiguration.getString(DATABASE_SCHEMA))) + .host(Optional.ofNullable(propertiesConfiguration.getString(HOST))) + .port(propertiesConfiguration.getInt(PORT, PORT_DEFAULT_VALUE)) + .username(Optional.ofNullable(propertiesConfiguration.getString(USERNAME))) + .password(Optional.ofNullable(propertiesConfiguration.getString(PASSWORD))) + .nonRLSUser(Optional.ofNullable(propertiesConfiguration.getString(NON_RLS_USERNAME))) + .nonRLSPassword(Optional.ofNullable(propertiesConfiguration.getString(NON_RLS_PASSWORD))) .rowLevelSecurityEnabled(propertiesConfiguration.getBoolean(RLS_ENABLED, false)) .build(); } - private final URI uri; - private final Credential credential; + private final String host; + private final int port; private final String databaseName; private final String databaseSchema; + private final Credential credential; + private final Credential nonRLSCredential; private final boolean rowLevelSecurityEnabled; - private PostgresConfiguration(URI uri, Credential credential, String databaseName, String databaseSchema, boolean rowLevelSecurityEnabled) { - this.uri = uri; - this.credential = credential; + private PostgresConfiguration(String host, int port, String databaseName, String databaseSchema, + Credential credential, Credential nonRLSCredential, boolean rowLevelSecurityEnabled) { + this.host = host; + this.port = port; this.databaseName = databaseName; this.databaseSchema = databaseSchema; + this.credential = credential; + this.nonRLSCredential = nonRLSCredential; this.rowLevelSecurityEnabled = rowLevelSecurityEnabled; } - public URI getUri() { - return uri; + public String getHost() { + return host; } - public Credential getCredential() { - return credential; + public int getPort() { + return port; } public String getDatabaseName() { @@ -174,26 +233,36 @@ public String getDatabaseSchema() { return databaseSchema; } + public Credential getCredential() { + return credential; + } + + public Credential getNonRLSCredential() { + return nonRLSCredential; + } + public boolean rowLevelSecurityEnabled() { return rowLevelSecurityEnabled; } + @Override + public final int hashCode() { + return Objects.hash(host, port, databaseName, databaseSchema, credential, nonRLSCredential, rowLevelSecurityEnabled); + } + @Override public final boolean equals(Object o) { if (o instanceof PostgresConfiguration) { PostgresConfiguration that = (PostgresConfiguration) o; return Objects.equals(this.rowLevelSecurityEnabled, that.rowLevelSecurityEnabled) - && Objects.equals(this.uri, that.uri) + && Objects.equals(this.host, that.host) + && Objects.equals(this.port, that.port) && Objects.equals(this.credential, that.credential) + && Objects.equals(this.nonRLSCredential, that.nonRLSCredential) && Objects.equals(this.databaseName, that.databaseName) && Objects.equals(this.databaseSchema, that.databaseSchema); } return false; } - - @Override - public final int hashCode() { - return Objects.hash(uri, credential, databaseName, databaseSchema, rowLevelSecurityEnabled); - } } diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/JamesPostgresConnectionFactory.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/JamesPostgresConnectionFactory.java index 8d8391e209e..c196f806429 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/JamesPostgresConnectionFactory.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/JamesPostgresConnectionFactory.java @@ -28,6 +28,7 @@ public interface JamesPostgresConnectionFactory { String DOMAIN_ATTRIBUTE = "app.current_domain"; + String NON_RLS_INJECT = "non_rls"; default Mono getConnection(Domain domain) { return getConnection(Optional.ofNullable(domain)); diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index 67f6c2067ba..268e14a08a2 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -47,6 +47,7 @@ public class PostgresExecutor { public static final String DEFAULT_INJECT = "default"; + public static final String NON_RLS_INJECT = "non_rls"; public static final int MAX_RETRY_ATTEMPTS = 5; public static final Duration MIN_BACKOFF = Duration.ofMillis(1); diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java index 248eb0dd662..b47f66abe44 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java @@ -22,89 +22,98 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.Test; class PostgresConfigurationTest { @Test - void shouldThrowWhenMissingPostgresURI() { + void shouldReturnCorrespondingProperties() { + PostgresConfiguration configuration = PostgresConfiguration.builder() + .host("1.1.1.1") + .port(1111) + .databaseName("db") + .databaseSchema("sc") + .username("james") + .password("1") + .nonRLSUser("nonrlsjames") + .nonRLSPassword("2") + .rowLevelSecurityEnabled() + .build(); + + assertThat(configuration.getHost()).isEqualTo("1.1.1.1"); + assertThat(configuration.getPort()).isEqualTo(1111); + assertThat(configuration.getDatabaseName()).isEqualTo("db"); + assertThat(configuration.getDatabaseSchema()).isEqualTo("sc"); + assertThat(configuration.getCredential().getUsername()).isEqualTo("james"); + assertThat(configuration.getCredential().getPassword()).isEqualTo("1"); + assertThat(configuration.getNonRLSCredential().getUsername()).isEqualTo("nonrlsjames"); + assertThat(configuration.getNonRLSCredential().getPassword()).isEqualTo("2"); + assertThat(configuration.rowLevelSecurityEnabled()).isEqualTo(true); + } + + @Test + void shouldUseDefaultValues() { + PostgresConfiguration configuration = PostgresConfiguration.builder() + .username("james") + .password("1") + .build(); + + assertThat(configuration.getHost()).isEqualTo(PostgresConfiguration.HOST_DEFAULT_VALUE); + assertThat(configuration.getPort()).isEqualTo(PostgresConfiguration.PORT_DEFAULT_VALUE); + assertThat(configuration.getDatabaseName()).isEqualTo(PostgresConfiguration.DATABASE_NAME_DEFAULT_VALUE); + assertThat(configuration.getDatabaseSchema()).isEqualTo(PostgresConfiguration.DATABASE_SCHEMA_DEFAULT_VALUE); + assertThat(configuration.getNonRLSCredential().getUsername()).isEqualTo("james"); + assertThat(configuration.getNonRLSCredential().getPassword()).isEqualTo("1"); + assertThat(configuration.rowLevelSecurityEnabled()).isEqualTo(false); + } + + @Test + void shouldThrowWhenMissingUsername() { assertThatThrownBy(() -> PostgresConfiguration.builder() .build()) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("You need to specify Postgres URI"); + .hasMessage("You need to specify username"); } @Test - void shouldThrowWhenInvalidURI() { + void shouldThrowWhenMissingPassword() { assertThatThrownBy(() -> PostgresConfiguration.builder() - .url(":invalid") + .username("james") .build()) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("You need to specify a valid Postgres URI"); + .hasMessage("You need to specify password"); } @Test - void shouldThrowWhenURIMissingCredential() { + void shouldThrowWhenMissingNonRLSUserAndRLSIsEnabled() { assertThatThrownBy(() -> PostgresConfiguration.builder() - .url("postgresql://localhost:5432") + .username("james") + .password("1") + .rowLevelSecurityEnabled() .build()) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Postgres URI need to contains user credential"); + .hasMessage("You need to specify nonRLSUser"); } @Test - void shouldParseValidURI() { - PostgresConfiguration configuration = PostgresConfiguration.builder() - .url("postgresql://username:password@postgreshost:5672") - .build(); - - assertThat(configuration.getUri().getHost()).isEqualTo("postgreshost"); - assertThat(configuration.getUri().getPort()).isEqualTo(5672); - assertThat(configuration.getCredential().getUsername()).isEqualTo("username"); - assertThat(configuration.getCredential().getPassword()).isEqualTo("password"); + void shouldThrowWhenMissingNonRLSPasswordAndRLSIsEnabled() { + assertThatThrownBy(() -> PostgresConfiguration.builder() + .username("james") + .password("1") + .nonRLSUser("nonrlsjames") + .rowLevelSecurityEnabled() + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("You need to specify nonRLSPassword"); } @Test void rowLevelSecurityShouldBeDisabledByDefault() { PostgresConfiguration configuration = PostgresConfiguration.builder() - .url("postgresql://username:password@postgreshost:5672") + .username("james") + .password("1") .build(); assertThat(configuration.rowLevelSecurityEnabled()).isFalse(); } - - @Test - void databaseNameShouldFallbackToDefaultWhenNotSet() { - PostgresConfiguration configuration = PostgresConfiguration.builder() - .url("postgresql://username:password@postgreshost:5672") - .build(); - - assertThat(configuration.getDatabaseName()).isEqualTo("postgres"); - } - - @Test - void databaseSchemaShouldFallbackToDefaultWhenNotSet() { - PostgresConfiguration configuration = PostgresConfiguration.builder() - .url("postgresql://username:password@postgreshost:5672") - .build(); - - assertThat(configuration.getDatabaseSchema()).isEqualTo("public"); - } - - @Test - void shouldReturnCorrespondingProperties() { - PostgresConfiguration configuration = PostgresConfiguration.builder() - .url("postgresql://username:password@postgreshost:5672") - .rowLevelSecurityEnabled() - .databaseName("databaseName") - .databaseSchema("databaseSchema") - .build(); - - SoftAssertions.assertSoftly(softly -> { - softly.assertThat(configuration.rowLevelSecurityEnabled()).isEqualTo(true); - softly.assertThat(configuration.getDatabaseName()).isEqualTo("databaseName"); - softly.assertThat(configuration.getDatabaseSchema()).isEqualTo("databaseSchema"); - }); - } } diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index 672a770d6ec..2a2c6b9a33f 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -25,9 +25,9 @@ import java.io.IOException; import java.net.URISyntaxException; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; -import org.apache.http.client.utils.URIBuilder; import org.apache.james.GuiceModuleTestExtension; import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.PostgresExecutor; @@ -114,23 +114,22 @@ private void querySettingExtension() throws IOException, InterruptedException { PG_CONTAINER.execInContainer("psql", "-U", selectedDatabase.dbUser(), selectedDatabase.dbName(), "-c", String.format("CREATE EXTENSION IF NOT EXISTS hstore SCHEMA %s;", selectedDatabase.schema())); } - private void initPostgresSession() throws URISyntaxException { + private void initPostgresSession() { postgresConfiguration = PostgresConfiguration.builder() - .url(new URIBuilder() - .setScheme("postgresql") - .setHost(getHost()) - .setPort(getMappedPort()) - .setUserInfo(selectedDatabase.dbUser(), selectedDatabase.dbPassword()) - .build() - .toString()) .databaseName(selectedDatabase.dbName()) .databaseSchema(selectedDatabase.schema()) + .host(getHost()) + .port(getMappedPort()) + .username(selectedDatabase.dbUser()) + .password(selectedDatabase.dbPassword()) + .nonRLSUser(DEFAULT_DATABASE.dbUser()) + .nonRLSPassword(DEFAULT_DATABASE.dbPassword()) .rowLevelSecurityEnabled(rlsEnabled) .build(); connectionFactory = new PostgresqlConnectionFactory(PostgresqlConnectionConfiguration.builder() - .host(postgresConfiguration.getUri().getHost()) - .port(postgresConfiguration.getUri().getPort()) + .host(postgresConfiguration.getHost()) + .port(postgresConfiguration.getPort()) .username(postgresConfiguration.getCredential().getUsername()) .password(postgresConfiguration.getCredential().getPassword()) .database(postgresConfiguration.getDatabaseName()) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageBlobReferenceSource.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageBlobReferenceSource.java new file mode 100644 index 00000000000..d4136a081e5 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageBlobReferenceSource.java @@ -0,0 +1,42 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import javax.inject.Inject; + +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BlobReferenceSource; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; + +import reactor.core.publisher.Flux; + +public class PostgresMessageBlobReferenceSource implements BlobReferenceSource { + private PostgresMessageDAO postgresMessageDAO; + + @Inject + public PostgresMessageBlobReferenceSource(PostgresMessageDAO postgresMessageDAO) { + this.postgresMessageDAO = postgresMessageDAO; + } + + @Override + public Flux listReferencedBlobs() { + return postgresMessageDAO.listBlobs(); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java index c68b0e3792d..d4aca8b5a99 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java @@ -46,6 +46,7 @@ import java.util.Optional; import javax.inject.Inject; +import javax.inject.Named; import javax.inject.Singleton; import org.apache.commons.io.IOUtils; @@ -60,6 +61,7 @@ import org.jooq.Record; import org.jooq.postgres.extensions.types.Hstore; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; @@ -85,7 +87,8 @@ public PostgresMessageDAO create(Optional domain) { private final PostgresExecutor postgresExecutor; private final BlobId.Factory blobIdFactory; - public PostgresMessageDAO(PostgresExecutor postgresExecutor, BlobId.Factory blobIdFactory) { + @Inject + public PostgresMessageDAO(@Named(PostgresExecutor.NON_RLS_INJECT) PostgresExecutor postgresExecutor, BlobId.Factory blobIdFactory) { this.postgresExecutor = postgresExecutor; this.blobIdFactory = blobIdFactory; } @@ -144,4 +147,10 @@ public Mono getBodyBlobId(PostgresMessageId messageId) { .map(record -> blobIdFactory.from(record.get(BODY_BLOB_ID))); } + public Flux listBlobs() { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(BODY_BLOB_ID) + .from(TABLE_NAME))) + .map(record -> blobIdFactory.from(record.get(BODY_BLOB_ID))); + } + } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageBlobReferenceSourceTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageBlobReferenceSourceTest.java new file mode 100644 index 00000000000..37b5a911172 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageBlobReferenceSourceTest.java @@ -0,0 +1,100 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.UUID; + +import javax.mail.Flags; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.blob.api.HashBlobId; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.model.ByteContent; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.model.ThreadId; +import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.apache.james.mailbox.store.mail.model.MailboxMessage; +import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; +import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresMessageBlobReferenceSourceTest { + private static final int BODY_START = 16; + private static final PostgresMailboxId MAILBOX_ID = PostgresMailboxId.generate(); + private static final String CONTENT = "Subject: Test7 \n\nBody7\n.\n"; + private static final String CONTENT_2 = "Subject: Test3 \n\nBody23\n.\n"; + private static final MessageUid MESSAGE_UID = MessageUid.of(1); + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); + + PostgresMessageBlobReferenceSource blobReferenceSource; + PostgresMessageDAO postgresMessageDAO; + + @BeforeEach + void beforeEach() { + postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), new HashBlobId.Factory()); + blobReferenceSource = new PostgresMessageBlobReferenceSource(postgresMessageDAO); + } + + @Test + void blobReferencesShouldBeEmptyByDefault() { + assertThat(blobReferenceSource.listReferencedBlobs().collectList().block()) + .isEmpty(); + } + + @Test + void blobReferencesShouldReturnAllBlobs() { + MessageId messageId1 = PostgresMessageId.Factory.of(UUID.randomUUID()); + SimpleMailboxMessage message = createMessage(messageId1, ThreadId.fromBaseMessageId(messageId1), CONTENT, BODY_START, new PropertyBuilder()); + MessageId messageId2 = PostgresMessageId.Factory.of(UUID.randomUUID()); + MailboxMessage message2 = createMessage(messageId2, ThreadId.fromBaseMessageId(messageId2), CONTENT_2, BODY_START, new PropertyBuilder()); + postgresMessageDAO.insert(message, "1") .block(); + postgresMessageDAO.insert(message2, "2") .block(); + + assertThat(blobReferenceSource.listReferencedBlobs().collectList().block()) + .hasSize(2); + } + + private SimpleMailboxMessage createMessage(MessageId messageId, ThreadId threadId, String content, int bodyStart, PropertyBuilder propertyBuilder) { + return SimpleMailboxMessage.builder() + .messageId(messageId) + .threadId(threadId) + .mailboxId(MAILBOX_ID) + .uid(MESSAGE_UID) + .internalDate(new Date()) + .bodyStartOctet(bodyStart) + .size(content.length()) + .content(new ByteContent(content.getBytes(StandardCharsets.UTF_8))) + .flags(new Flags()) + .properties(propertyBuilder) + .build(); + } + +} diff --git a/server/apps/postgres-app/sample-configuration/postgres.properties b/server/apps/postgres-app/sample-configuration/postgres.properties index 0bfe376f4d8..b93071532e7 100644 --- a/server/apps/postgres-app/sample-configuration/postgres.properties +++ b/server/apps/postgres-app/sample-configuration/postgres.properties @@ -1,11 +1,26 @@ -# String. Required. PostgreSQL URI in the format postgresql://username:password@host:port -url=postgresql://james:secret1@postgres:5432 - # String. Optional, default to 'postgres'. Database name. database.name=james # String. Optional, default to 'public'. Database schema. database.schema=public +# String. Optional, default to 'localhost'. Database host. +database.host=postgres + +# Integer. Optional, default to 5432. Database port. +database.port=5432 + +# String. Required. Database username. +database.username=james + +# String. Required. Database password of the user. +database.password=secret1 + +# String. It is required when row.level.security.enabled is true. Database username with the permission of bypassing RLS. +database.non-rls.username=nonrlsjames + +# String. It is required when row.level.security.enabled is true. Database password of non-rls user. +database.non-rls.password=secret1 + # Boolean. Optional, default to false. Whether to enable row level security. row.level.security.enabled=true diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index 5da7524604d..76f6244de2c 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -21,7 +21,6 @@ import java.util.List; -import org.apache.james.blob.api.BlobReferenceSource; import org.apache.james.data.UsersRepositoryModuleChooser; import org.apache.james.modules.BlobExportMechanismModule; import org.apache.james.modules.MailboxModule; @@ -63,12 +62,10 @@ import org.apache.james.modules.server.WebAdminReIndexingTaskSerializationModule; import org.apache.james.modules.server.WebAdminServerModule; import org.apache.james.modules.vault.DeletedMessageVaultRoutesModule; -import org.apache.james.server.blob.deduplication.StorageStrategy; import org.apache.james.vault.VaultConfiguration; import com.google.common.collect.ImmutableList; import com.google.inject.Module; -import com.google.inject.multibindings.Multibinder; import com.google.inject.util.Modules; public class PostgresJamesServerMain implements JamesServerMain { @@ -145,11 +142,6 @@ private static List chooseBlobStoreModules(PostgresJamesConfiguration co .addAll(BlobStoreModulesChooser.chooseModules(configuration.blobStoreConfiguration())) .add(new BlobStoreCacheModulesChooser.CacheDisabledModule()); - // should remove this after https://github.com/linagora/james-project/issues/4998 - if (configuration.blobStoreConfiguration().storageStrategy().equals(StorageStrategy.DEDUPLICATION)) { - builder.add(binder -> Multibinder.newSetBinder(binder, BlobReferenceSource.class)); - } - return builder.build(); } diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java index 97f4716a4a5..b1a955f6ac5 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java @@ -29,6 +29,7 @@ import org.apache.james.adapter.mailbox.UserRepositoryAuthenticator; import org.apache.james.adapter.mailbox.UserRepositoryAuthorizator; import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.blob.api.BlobReferenceSource; import org.apache.james.events.EventListener; import org.apache.james.mailbox.AttachmentContentLoader; import org.apache.james.mailbox.Authenticator; @@ -49,6 +50,8 @@ import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; import org.apache.james.mailbox.postgres.PostgresMessageId; import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; +import org.apache.james.mailbox.postgres.mail.PostgresMessageBlobReferenceSource; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.store.MailboxManagerConfiguration; import org.apache.james.mailbox.store.MailboxSessionMapperFactory; import org.apache.james.mailbox.store.NoMailboxPathLocker; @@ -117,6 +120,8 @@ protected void configure() { bind(ReIndexer.class).to(ReIndexerImpl.class); + bind(PostgresMessageDAO.class).in(Scopes.SINGLETON); + Multibinder.newSetBinder(binder(), MailboxManagerDefinition.class).addBinding().to(PostgresMailboxManagerDefinition.class); Multibinder.newSetBinder(binder(), EventListener.GroupEventListener.class) @@ -141,6 +146,9 @@ protected void configure() { Multibinder deleteUserDataTaskStepMultibinder = Multibinder.newSetBinder(binder(), DeleteUserDataTaskStep.class); deleteUserDataTaskStepMultibinder.addBinding().to(MailboxUserDeletionTaskStep.class); + + Multibinder blobReferenceSourceMultibinder = Multibinder.newSetBinder(binder(), BlobReferenceSource.class); + blobReferenceSourceMultibinder.addBinding().to(PostgresMessageBlobReferenceSource.class); } @Singleton diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index e5f849cebba..5a2950e484b 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -73,13 +73,24 @@ PostgresConfiguration provideConfiguration(PropertiesProvider propertiesProvider @Provides @Singleton - JamesPostgresConnectionFactory provideJamesPostgresConnectionFactory(PostgresConfiguration postgresConfiguration, ConnectionFactory connectionFactory) { + JamesPostgresConnectionFactory provideJamesPostgresConnectionFactory(PostgresConfiguration postgresConfiguration, + ConnectionFactory connectionFactory, + @Named(JamesPostgresConnectionFactory.NON_RLS_INJECT) JamesPostgresConnectionFactory singlePostgresConnectionFactory) { if (postgresConfiguration.rowLevelSecurityEnabled()) { LOGGER.info("PostgreSQL row level security enabled"); LOGGER.info("Implementation for PostgreSQL connection factory: {}", DomainImplPostgresConnectionFactory.class.getName()); return new DomainImplPostgresConnectionFactory(connectionFactory); } LOGGER.info("Implementation for PostgreSQL connection factory: {}", SinglePostgresConnectionFactory.class.getName()); + return singlePostgresConnectionFactory; + } + + @Provides + @Named(JamesPostgresConnectionFactory.NON_RLS_INJECT) + @Singleton + JamesPostgresConnectionFactory provideJamesPostgresConnectionFactoryWithRLSBypass(PostgresConfiguration postgresConfiguration, + @Named(JamesPostgresConnectionFactory.NON_RLS_INJECT) ConnectionFactory connectionFactory) { + LOGGER.info("Implementation for PostgreSQL connection factory: {}", SinglePostgresConnectionFactory.class.getName()); return new SinglePostgresConnectionFactory(Mono.from(connectionFactory.create()).block()); } @@ -87,8 +98,8 @@ JamesPostgresConnectionFactory provideJamesPostgresConnectionFactory(PostgresCon @Singleton ConnectionFactory postgresqlConnectionFactory(PostgresConfiguration postgresConfiguration) { return new PostgresqlConnectionFactory(PostgresqlConnectionConfiguration.builder() - .host(postgresConfiguration.getUri().getHost()) - .port(postgresConfiguration.getUri().getPort()) + .host(postgresConfiguration.getHost()) + .port(postgresConfiguration.getPort()) .username(postgresConfiguration.getCredential().getUsername()) .password(postgresConfiguration.getCredential().getPassword()) .database(postgresConfiguration.getDatabaseName()) @@ -96,6 +107,20 @@ ConnectionFactory postgresqlConnectionFactory(PostgresConfiguration postgresConf .build()); } + @Provides + @Named(JamesPostgresConnectionFactory.NON_RLS_INJECT) + @Singleton + ConnectionFactory postgresqlConnectionFactoryRLSBypass(PostgresConfiguration postgresConfiguration) { + return new PostgresqlConnectionFactory(PostgresqlConnectionConfiguration.builder() + .host(postgresConfiguration.getHost()) + .port(postgresConfiguration.getPort()) + .username(postgresConfiguration.getNonRLSCredential().getUsername()) + .password(postgresConfiguration.getNonRLSCredential().getPassword()) + .database(postgresConfiguration.getDatabaseName()) + .schema(postgresConfiguration.getDatabaseSchema()) + .build()); + } + @Provides @Singleton PostgresModule composePostgresDataDefinitions(Set modules) { @@ -110,6 +135,13 @@ PostgresTableManager postgresTableManager(PostgresExecutor postgresExecutor, return new PostgresTableManager(postgresExecutor, postgresModule, postgresConfiguration); } + @Provides + @Named(PostgresExecutor.NON_RLS_INJECT) + @Singleton + PostgresExecutor.Factory postgresExecutorFactoryWithRLSBypass(@Named(PostgresExecutor.NON_RLS_INJECT) JamesPostgresConnectionFactory singlePostgresConnectionFactory) { + return new PostgresExecutor.Factory(singlePostgresConnectionFactory); + } + @Provides @Named(DEFAULT_INJECT) @Singleton @@ -117,6 +149,13 @@ PostgresExecutor defaultPostgresExecutor(PostgresExecutor.Factory factory) { return factory.create(); } + @Provides + @Named(PostgresExecutor.NON_RLS_INJECT) + @Singleton + PostgresExecutor postgresExecutorWithRLSBypass(@Named(PostgresExecutor.NON_RLS_INJECT) PostgresExecutor.Factory factory) { + return factory.create(); + } + @Provides @Singleton PostgresExecutor postgresExecutor(@Named(DEFAULT_INJECT) PostgresExecutor postgresExecutor) { diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresMailRepositoryModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresMailRepositoryModule.java index f0bbbfa3ae9..550fb7c8cfc 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresMailRepositoryModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresMailRepositoryModule.java @@ -21,11 +21,14 @@ import org.apache.commons.configuration2.BaseHierarchicalConfiguration; import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.blob.api.BlobReferenceSource; import org.apache.james.mailrepository.api.MailRepositoryFactory; import org.apache.james.mailrepository.api.MailRepositoryUrlStore; import org.apache.james.mailrepository.api.Protocol; import org.apache.james.mailrepository.memory.MailRepositoryStoreConfiguration; import org.apache.james.mailrepository.postgres.PostgresMailRepository; +import org.apache.james.mailrepository.postgres.PostgresMailRepositoryBlobReferenceSource; +import org.apache.james.mailrepository.postgres.PostgresMailRepositoryContentDAO; import org.apache.james.mailrepository.postgres.PostgresMailRepositoryFactory; import org.apache.james.mailrepository.postgres.PostgresMailRepositoryUrlStore; @@ -37,6 +40,7 @@ public class PostgresMailRepositoryModule extends AbstractModule { @Override protected void configure() { + bind(PostgresMailRepositoryContentDAO.class).in(Scopes.SINGLETON); bind(PostgresMailRepositoryUrlStore.class).in(Scopes.SINGLETON); bind(MailRepositoryUrlStore.class).to(PostgresMailRepositoryUrlStore.class); @@ -51,5 +55,8 @@ protected void configure() { .addBinding().to(PostgresMailRepositoryFactory.class); Multibinder.newSetBinder(binder(), PostgresModule.class) .addBinding().toInstance(org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.MODULE); + + Multibinder blobReferenceSourceMultibinder = Multibinder.newSetBinder(binder(), BlobReferenceSource.class); + blobReferenceSourceMultibinder.addBinding().to(PostgresMailRepositoryBlobReferenceSource.class); } } diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepository.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepository.java index 241fb215368..1f9da8f4c74 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepository.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepository.java @@ -19,344 +19,67 @@ package org.apache.james.mailrepository.postgres; -import static org.apache.james.backends.postgres.PostgresCommons.DATE_TO_LOCAL_DATE_TIME; -import static org.apache.james.backends.postgres.PostgresCommons.LOCAL_DATE_TIME_DATE_FUNCTION; -import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.ATTRIBUTES; -import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.BODY_BLOB_ID; -import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.ERROR; -import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.HEADER_BLOB_ID; -import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.KEY; -import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.LAST_UPDATED; -import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.PER_RECIPIENT_SPECIFIC_HEADERS; -import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.RECIPIENTS; -import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.REMOTE_ADDRESS; -import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.REMOTE_HOST; -import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.SENDER; -import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.STATE; -import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.TABLE_NAME; -import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.URL; -import static org.apache.james.util.ReactorUtils.DEFAULT_CONCURRENCY; - -import java.time.LocalDateTime; -import java.util.Arrays; import java.util.Collection; import java.util.Iterator; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.function.Consumer; -import java.util.stream.Stream; import javax.inject.Inject; import javax.mail.MessagingException; -import javax.mail.internet.MimeMessage; -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.tuple.Pair; -import org.apache.james.backends.postgres.utils.PostgresExecutor; -import org.apache.james.backends.postgres.utils.PostgresUtils; -import org.apache.james.blob.api.BlobId; -import org.apache.james.blob.api.Store; -import org.apache.james.blob.mail.MimeMessagePartsId; -import org.apache.james.blob.mail.MimeMessageStore; -import org.apache.james.core.MailAddress; -import org.apache.james.core.MaybeSender; import org.apache.james.mailrepository.api.MailKey; import org.apache.james.mailrepository.api.MailRepository; import org.apache.james.mailrepository.api.MailRepositoryUrl; -import org.apache.james.server.core.MailImpl; -import org.apache.james.server.core.MimeMessageWrapper; -import org.apache.james.util.AuditTrail; -import org.apache.mailet.Attribute; -import org.apache.mailet.AttributeName; -import org.apache.mailet.AttributeValue; import org.apache.mailet.Mail; -import org.apache.mailet.PerRecipientHeaders; -import org.jooq.Record; -import org.jooq.postgres.extensions.types.Hstore; - -import com.fasterxml.jackson.databind.JsonNode; -import com.github.fge.lambdas.Throwing; -import com.google.common.base.Splitter; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.ImmutableMap; -import com.google.common.collect.Multimap; -import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; public class PostgresMailRepository implements MailRepository { - private static final String HEADERS_SEPARATOR = "; "; - - private final PostgresExecutor postgresExecutor; private final MailRepositoryUrl url; - private final Store mimeMessageStore; - private final BlobId.Factory blobIdFactory; + private final PostgresMailRepositoryContentDAO postgresMailRepositoryContentDAO; @Inject - public PostgresMailRepository(PostgresExecutor postgresExecutor, - MailRepositoryUrl url, - MimeMessageStore.Factory mimeMessageStoreFactory, - BlobId.Factory blobIdFactory) { - this.postgresExecutor = postgresExecutor; + public PostgresMailRepository(MailRepositoryUrl url, + PostgresMailRepositoryContentDAO postgresMailRepositoryContentDAO) { this.url = url; - this.mimeMessageStore = mimeMessageStoreFactory.mimeMessageStore(); - this.blobIdFactory = blobIdFactory; + this.postgresMailRepositoryContentDAO = postgresMailRepositoryContentDAO; } @Override public long size() throws MessagingException { - return sizeReactive().block(); + return postgresMailRepositoryContentDAO.size(url); } @Override public Mono sizeReactive() { - return postgresExecutor.executeCount(context -> Mono.from(context.selectCount() - .from(TABLE_NAME) - .where(URL.eq(url.asString())))) - .map(Integer::longValue); + return postgresMailRepositoryContentDAO.sizeReactive(url); } @Override public MailKey store(Mail mail) throws MessagingException { - MailKey mailKey = MailKey.forMail(mail); - - return storeMailBlob(mail) - .flatMap(mimeMessagePartsId -> storeMailMetadata(mail, mailKey, mimeMessagePartsId) - .doOnSuccess(auditTrailStoredMail(mail)) - .onErrorResume(PostgresUtils.UNIQUE_CONSTRAINT_VIOLATION_PREDICATE, e -> Mono.from(mimeMessageStore.delete(mimeMessagePartsId)) - .thenReturn(mailKey))) - .block(); - } - - private Mono storeMailBlob(Mail mail) throws MessagingException { - return mimeMessageStore.save(mail.getMessage()); - } - - private Mono storeMailMetadata(Mail mail, MailKey mailKey, MimeMessagePartsId mimeMessagePartsId) { - return postgresExecutor.executeVoid(context -> Mono.from(context.insertInto(TABLE_NAME) - .set(URL, url.asString()) - .set(KEY, mailKey.asString()) - .set(HEADER_BLOB_ID, mimeMessagePartsId.getHeaderBlobId().asString()) - .set(BODY_BLOB_ID, mimeMessagePartsId.getBodyBlobId().asString()) - .set(STATE, mail.getState()) - .set(ERROR, mail.getErrorMessage()) - .set(SENDER, mail.getMaybeSender().asString()) - .set(RECIPIENTS, asStringArray(mail.getRecipients())) - .set(REMOTE_ADDRESS, mail.getRemoteAddr()) - .set(REMOTE_HOST, mail.getRemoteHost()) - .set(LAST_UPDATED, DATE_TO_LOCAL_DATE_TIME.apply(mail.getLastUpdated())) - .set(ATTRIBUTES, asHstore(mail.attributes())) - .set(PER_RECIPIENT_SPECIFIC_HEADERS, asHstore(mail.getPerRecipientSpecificHeaders().getHeadersByRecipient())) - .onConflict(URL, KEY) - .doUpdate() - .set(HEADER_BLOB_ID, mimeMessagePartsId.getHeaderBlobId().asString()) - .set(BODY_BLOB_ID, mimeMessagePartsId.getBodyBlobId().asString()) - .set(STATE, mail.getState()) - .set(ERROR, mail.getErrorMessage()) - .set(SENDER, mail.getMaybeSender().asString()) - .set(RECIPIENTS, asStringArray(mail.getRecipients())) - .set(REMOTE_ADDRESS, mail.getRemoteAddr()) - .set(REMOTE_HOST, mail.getRemoteHost()) - .set(LAST_UPDATED, DATE_TO_LOCAL_DATE_TIME.apply(mail.getLastUpdated())) - .set(ATTRIBUTES, asHstore(mail.attributes())) - .set(PER_RECIPIENT_SPECIFIC_HEADERS, asHstore(mail.getPerRecipientSpecificHeaders().getHeadersByRecipient())) - )) - .thenReturn(mailKey); - } - - private Consumer auditTrailStoredMail(Mail mail) { - return Throwing.consumer(any -> AuditTrail.entry() - .protocol("mailrepository") - .action("store") - .parameters(Throwing.supplier(() -> ImmutableMap.of("mailId", mail.getName(), - "mimeMessageId", Optional.ofNullable(mail.getMessage()) - .map(Throwing.function(MimeMessage::getMessageID)) - .orElse(""), - "sender", mail.getMaybeSender().asString(), - "recipients", StringUtils.join(mail.getRecipients())))) - .log("PostgresMailRepository stored mail.")); - } - - private String[] asStringArray(Collection mailAddresses) { - return mailAddresses.stream() - .map(MailAddress::asString) - .toArray(String[]::new); - } - - private Hstore asHstore(Multimap multimap) { - return Hstore.hstore(multimap - .asMap() - .entrySet() - .stream() - .map(recipientToHeaders -> Pair.of(recipientToHeaders.getKey().asString(), - asString(recipientToHeaders.getValue()))) - .collect(ImmutableMap.toImmutableMap(Pair::getLeft, Pair::getRight))); - } - - private String asString(Collection headers) { - return StringUtils.join(headers.stream() - .map(PerRecipientHeaders.Header::asString) - .collect(ImmutableList.toImmutableList()), HEADERS_SEPARATOR); - } - - private Hstore asHstore(Stream attributes) { - return Hstore.hstore(attributes - .flatMap(attribute -> attribute.getValue() - .toJson() - .map(JsonNode::toString) - .map(value -> Pair.of(attribute.getName().asString(), value)).stream()) - .collect(ImmutableMap.toImmutableMap(Pair::getLeft, Pair::getRight))); + return postgresMailRepositoryContentDAO.store(mail, url); } @Override public Iterator list() throws MessagingException { - return listMailKeys() - .toStream() - .iterator(); - } - - private Flux listMailKeys() { - return postgresExecutor.executeRows(context -> Flux.from(context.select(KEY) - .from(TABLE_NAME) - .where(URL.eq(url.asString())))) - .map(record -> new MailKey(record.get(KEY))); + return postgresMailRepositoryContentDAO.list(url); } @Override public Mail retrieve(MailKey key) { - return postgresExecutor.executeRow(context -> Mono.from(context.select() - .from(TABLE_NAME) - .where(URL.eq(url.asString())) - .and(KEY.eq(key.asString())))) - .flatMap(this::toMail) - .blockOptional() - .orElse(null); - } - - private Mono toMail(Record record) { - return mimeMessageStore.read(toMimeMessagePartsId(record)) - .map(Throwing.function(mimeMessage -> toMail(record, mimeMessage))); - } - - private Mail toMail(Record record, MimeMessage mimeMessage) throws MessagingException { - List recipients = Arrays.stream(record.get(RECIPIENTS)) - .map(Throwing.function(MailAddress::new)) - .collect(ImmutableList.toImmutableList()); - - PerRecipientHeaders perRecipientHeaders = getPerRecipientHeaders(record); - - List attributes = Hstore.hstore(record.get(ATTRIBUTES, LinkedHashMap.class)) - .data() - .entrySet() - .stream() - .map(Throwing.function(entry -> new Attribute(AttributeName.of(entry.getKey()), - AttributeValue.fromJsonString(entry.getValue())))) - .collect(ImmutableList.toImmutableList()); - - MailImpl mail = MailImpl.builder() - .name(record.get(KEY)) - .sender(MaybeSender.getMailSender(record.get(SENDER))) - .addRecipients(recipients) - .lastUpdated(LOCAL_DATE_TIME_DATE_FUNCTION.apply(record.get(LAST_UPDATED, LocalDateTime.class))) - .errorMessage(record.get(ERROR)) - .remoteHost(record.get(REMOTE_HOST)) - .remoteAddr(record.get(REMOTE_ADDRESS)) - .state(record.get(STATE)) - .addAllHeadersForRecipients(perRecipientHeaders) - .addAttributes(attributes) - .build(); - - if (mimeMessage instanceof MimeMessageWrapper) { - mail.setMessageNoCopy((MimeMessageWrapper) mimeMessage); - } else { - mail.setMessage(mimeMessage); - } - - return mail; - } - - private PerRecipientHeaders getPerRecipientHeaders(Record record) { - PerRecipientHeaders perRecipientHeaders = new PerRecipientHeaders(); - - Hstore.hstore(record.get(PER_RECIPIENT_SPECIFIC_HEADERS, LinkedHashMap.class)) - .data() - .entrySet() - .stream() - .flatMap(this::recipientToHeaderStream) - .forEach(recipientToHeaderPair -> perRecipientHeaders.addHeaderForRecipient( - recipientToHeaderPair.getRight(), - recipientToHeaderPair.getLeft())); - - return perRecipientHeaders; - } - - private Stream> recipientToHeaderStream(Map.Entry recipientToHeadersString) { - List headers = Splitter.on(HEADERS_SEPARATOR) - .splitToList(recipientToHeadersString.getValue()); - - return headers - .stream() - .map(headerAsString -> Pair.of( - asMailAddress(recipientToHeadersString.getKey()), - PerRecipientHeaders.Header.fromString(headerAsString))); - } - - private MailAddress asMailAddress(String mailAddress) { - return Throwing.supplier(() -> new MailAddress(mailAddress)) - .get(); - } - - private MimeMessagePartsId toMimeMessagePartsId(Record record) { - return MimeMessagePartsId.builder() - .headerBlobId(blobIdFactory.from(record.get(HEADER_BLOB_ID))) - .bodyBlobId(blobIdFactory.from(record.get(BODY_BLOB_ID))) - .build(); + return postgresMailRepositoryContentDAO.retrieve(key, url); } @Override public void remove(MailKey key) { - removeReactive(key).block(); - } - - private Mono removeReactive(MailKey key) { - return getMimeMessagePartsId(key) - .flatMap(mimeMessagePartsId -> deleteMailMetadata(key) - .then(deleteMailBlob(mimeMessagePartsId))); - } - - private Mono getMimeMessagePartsId(MailKey key) { - return postgresExecutor.executeRow(context -> Mono.from(context.select(HEADER_BLOB_ID, BODY_BLOB_ID) - .from(TABLE_NAME) - .where(URL.eq(url.asString())) - .and(KEY.eq(key.asString())))) - .map(this::toMimeMessagePartsId); - } - - private Mono deleteMailMetadata(MailKey key) { - return postgresExecutor.executeVoid(context -> Mono.from(context.deleteFrom(TABLE_NAME) - .where(URL.eq(url.asString())) - .and(KEY.eq(key.asString())))); - } - - private Mono deleteMailBlob(MimeMessagePartsId mimeMessagePartsId) { - return Mono.from(mimeMessageStore.delete(mimeMessagePartsId)); + postgresMailRepositoryContentDAO.remove(key, url); } @Override public void remove(Collection keys) { - Flux.fromIterable(keys) - .concatMap(this::removeReactive) - .then() - .block(); + postgresMailRepositoryContentDAO.remove(keys, url); } @Override public void removeAll() { - listMailKeys() - .flatMap(this::removeReactive, DEFAULT_CONCURRENCY) - .then() - .block(); + postgresMailRepositoryContentDAO.removeAll(url); } } diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryBlobReferenceSource.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryBlobReferenceSource.java new file mode 100644 index 00000000000..bd5a39f8f34 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryBlobReferenceSource.java @@ -0,0 +1,41 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailrepository.postgres; + +import javax.inject.Inject; + +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BlobReferenceSource; + +import reactor.core.publisher.Flux; + +public class PostgresMailRepositoryBlobReferenceSource implements BlobReferenceSource { + private final PostgresMailRepositoryContentDAO postgresMailRepositoryContentDAO; + + @Inject + public PostgresMailRepositoryBlobReferenceSource(PostgresMailRepositoryContentDAO postgresMailRepositoryContentDAO) { + this.postgresMailRepositoryContentDAO = postgresMailRepositoryContentDAO; + } + + @Override + public Flux listReferencedBlobs() { + return postgresMailRepositoryContentDAO.listBlobs(); + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryContentDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryContentDAO.java new file mode 100644 index 00000000000..2a52d4cb600 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryContentDAO.java @@ -0,0 +1,354 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailrepository.postgres; + +import static org.apache.james.backends.postgres.PostgresCommons.DATE_TO_LOCAL_DATE_TIME; +import static org.apache.james.backends.postgres.PostgresCommons.LOCAL_DATE_TIME_DATE_FUNCTION; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.ATTRIBUTES; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.BODY_BLOB_ID; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.ERROR; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.HEADER_BLOB_ID; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.KEY; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.LAST_UPDATED; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.PER_RECIPIENT_SPECIFIC_HEADERS; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.RECIPIENTS; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.REMOTE_ADDRESS; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.REMOTE_HOST; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.SENDER; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.STATE; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.TABLE_NAME; +import static org.apache.james.mailrepository.postgres.PostgresMailRepositoryModule.PostgresMailRepositoryContentTable.URL; +import static org.apache.james.util.ReactorUtils.DEFAULT_CONCURRENCY; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collection; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import javax.inject.Inject; +import javax.mail.MessagingException; +import javax.mail.internet.MimeMessage; + +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.backends.postgres.utils.PostgresUtils; +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.Store; +import org.apache.james.blob.mail.MimeMessagePartsId; +import org.apache.james.blob.mail.MimeMessageStore; +import org.apache.james.core.MailAddress; +import org.apache.james.core.MaybeSender; +import org.apache.james.mailrepository.api.MailKey; +import org.apache.james.mailrepository.api.MailRepositoryUrl; +import org.apache.james.server.core.MailImpl; +import org.apache.james.server.core.MimeMessageWrapper; +import org.apache.james.util.AuditTrail; +import org.apache.mailet.Attribute; +import org.apache.mailet.AttributeName; +import org.apache.mailet.AttributeValue; +import org.apache.mailet.Mail; +import org.apache.mailet.PerRecipientHeaders; +import org.jooq.Record; +import org.jooq.postgres.extensions.types.Hstore; + +import com.fasterxml.jackson.databind.JsonNode; +import com.github.fge.lambdas.Throwing; +import com.google.common.base.Splitter; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Multimap; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresMailRepositoryContentDAO { + private static final String HEADERS_SEPARATOR = "; "; + + private final PostgresExecutor postgresExecutor; + private final Store mimeMessageStore; + private final BlobId.Factory blobIdFactory; + + @Inject + public PostgresMailRepositoryContentDAO(PostgresExecutor postgresExecutor, + MimeMessageStore.Factory mimeMessageStoreFactory, + BlobId.Factory blobIdFactory) { + this.postgresExecutor = postgresExecutor; + this.mimeMessageStore = mimeMessageStoreFactory.mimeMessageStore(); + this.blobIdFactory = blobIdFactory; + } + + public long size(MailRepositoryUrl url) throws MessagingException { + return sizeReactive(url).block(); + } + + public Mono sizeReactive(MailRepositoryUrl url) { + return postgresExecutor.executeCount(context -> Mono.from(context.selectCount() + .from(TABLE_NAME) + .where(URL.eq(url.asString())))) + .map(Integer::longValue); + } + + public MailKey store(Mail mail, MailRepositoryUrl url) throws MessagingException { + MailKey mailKey = MailKey.forMail(mail); + + return storeMailBlob(mail) + .flatMap(mimeMessagePartsId -> storeMailMetadata(mail, mailKey, mimeMessagePartsId, url) + .doOnSuccess(auditTrailStoredMail(mail)) + .onErrorResume(PostgresUtils.UNIQUE_CONSTRAINT_VIOLATION_PREDICATE, e -> Mono.from(mimeMessageStore.delete(mimeMessagePartsId)) + .thenReturn(mailKey))) + .block(); + } + + private Mono storeMailBlob(Mail mail) throws MessagingException { + return mimeMessageStore.save(mail.getMessage()); + } + + private Mono storeMailMetadata(Mail mail, MailKey mailKey, MimeMessagePartsId mimeMessagePartsId, MailRepositoryUrl url) { + return postgresExecutor.executeVoid(context -> Mono.from(context.insertInto(TABLE_NAME) + .set(URL, url.asString()) + .set(KEY, mailKey.asString()) + .set(HEADER_BLOB_ID, mimeMessagePartsId.getHeaderBlobId().asString()) + .set(BODY_BLOB_ID, mimeMessagePartsId.getBodyBlobId().asString()) + .set(STATE, mail.getState()) + .set(ERROR, mail.getErrorMessage()) + .set(SENDER, mail.getMaybeSender().asString()) + .set(RECIPIENTS, asStringArray(mail.getRecipients())) + .set(REMOTE_ADDRESS, mail.getRemoteAddr()) + .set(REMOTE_HOST, mail.getRemoteHost()) + .set(LAST_UPDATED, DATE_TO_LOCAL_DATE_TIME.apply(mail.getLastUpdated())) + .set(ATTRIBUTES, asHstore(mail.attributes())) + .set(PER_RECIPIENT_SPECIFIC_HEADERS, asHstore(mail.getPerRecipientSpecificHeaders().getHeadersByRecipient())) + .onConflict(URL, KEY) + .doUpdate() + .set(HEADER_BLOB_ID, mimeMessagePartsId.getHeaderBlobId().asString()) + .set(BODY_BLOB_ID, mimeMessagePartsId.getBodyBlobId().asString()) + .set(STATE, mail.getState()) + .set(ERROR, mail.getErrorMessage()) + .set(SENDER, mail.getMaybeSender().asString()) + .set(RECIPIENTS, asStringArray(mail.getRecipients())) + .set(REMOTE_ADDRESS, mail.getRemoteAddr()) + .set(REMOTE_HOST, mail.getRemoteHost()) + .set(LAST_UPDATED, DATE_TO_LOCAL_DATE_TIME.apply(mail.getLastUpdated())) + .set(ATTRIBUTES, asHstore(mail.attributes())) + .set(PER_RECIPIENT_SPECIFIC_HEADERS, asHstore(mail.getPerRecipientSpecificHeaders().getHeadersByRecipient())) + )) + .thenReturn(mailKey); + } + + private Consumer auditTrailStoredMail(Mail mail) { + return Throwing.consumer(any -> AuditTrail.entry() + .protocol("mailrepository") + .action("store") + .parameters(Throwing.supplier(() -> ImmutableMap.of("mailId", mail.getName(), + "mimeMessageId", Optional.ofNullable(mail.getMessage()) + .map(Throwing.function(MimeMessage::getMessageID)) + .orElse(""), + "sender", mail.getMaybeSender().asString(), + "recipients", StringUtils.join(mail.getRecipients())))) + .log("PostgresMailRepository stored mail.")); + } + + private String[] asStringArray(Collection mailAddresses) { + return mailAddresses.stream() + .map(MailAddress::asString) + .toArray(String[]::new); + } + + private Hstore asHstore(Multimap multimap) { + return Hstore.hstore(multimap + .asMap() + .entrySet() + .stream() + .map(recipientToHeaders -> Pair.of(recipientToHeaders.getKey().asString(), + asString(recipientToHeaders.getValue()))) + .collect(ImmutableMap.toImmutableMap(Pair::getLeft, Pair::getRight))); + } + + private String asString(Collection headers) { + return StringUtils.join(headers.stream() + .map(PerRecipientHeaders.Header::asString) + .collect(ImmutableList.toImmutableList()), HEADERS_SEPARATOR); + } + + private Hstore asHstore(Stream attributes) { + return Hstore.hstore(attributes + .flatMap(attribute -> attribute.getValue() + .toJson() + .map(JsonNode::toString) + .map(value -> Pair.of(attribute.getName().asString(), value)).stream()) + .collect(ImmutableMap.toImmutableMap(Pair::getLeft, Pair::getRight))); + } + + public Iterator list(MailRepositoryUrl url) throws MessagingException { + return listMailKeys(url) + .toStream() + .iterator(); + } + + private Flux listMailKeys(MailRepositoryUrl url) { + return postgresExecutor.executeRows(context -> Flux.from(context.select(KEY) + .from(TABLE_NAME) + .where(URL.eq(url.asString())))) + .map(record -> new MailKey(record.get(KEY))); + } + + public Mail retrieve(MailKey key, MailRepositoryUrl url) { + return postgresExecutor.executeRow(context -> Mono.from(context.select() + .from(TABLE_NAME) + .where(URL.eq(url.asString())) + .and(KEY.eq(key.asString())))) + .flatMap(this::toMail) + .blockOptional() + .orElse(null); + } + + private Mono toMail(Record record) { + return mimeMessageStore.read(toMimeMessagePartsId(record)) + .map(Throwing.function(mimeMessage -> toMail(record, mimeMessage))); + } + + private Mail toMail(Record record, MimeMessage mimeMessage) throws MessagingException { + List recipients = Arrays.stream(record.get(RECIPIENTS)) + .map(Throwing.function(MailAddress::new)) + .collect(ImmutableList.toImmutableList()); + + PerRecipientHeaders perRecipientHeaders = getPerRecipientHeaders(record); + + List attributes = ((LinkedHashMap) record.get(ATTRIBUTES, LinkedHashMap.class)) + .entrySet() + .stream() + .map(Throwing.function(entry -> new Attribute(AttributeName.of(entry.getKey()), + AttributeValue.fromJsonString(entry.getValue())))) + .collect(ImmutableList.toImmutableList()); + + MailImpl mail = MailImpl.builder() + .name(record.get(KEY)) + .sender(MaybeSender.getMailSender(record.get(SENDER))) + .addRecipients(recipients) + .lastUpdated(LOCAL_DATE_TIME_DATE_FUNCTION.apply(record.get(LAST_UPDATED, LocalDateTime.class))) + .errorMessage(record.get(ERROR)) + .remoteHost(record.get(REMOTE_HOST)) + .remoteAddr(record.get(REMOTE_ADDRESS)) + .state(record.get(STATE)) + .addAllHeadersForRecipients(perRecipientHeaders) + .addAttributes(attributes) + .build(); + + if (mimeMessage instanceof MimeMessageWrapper) { + mail.setMessageNoCopy((MimeMessageWrapper) mimeMessage); + } else { + mail.setMessage(mimeMessage); + } + + return mail; + } + + private PerRecipientHeaders getPerRecipientHeaders(Record record) { + PerRecipientHeaders perRecipientHeaders = new PerRecipientHeaders(); + + ((LinkedHashMap) record.get(PER_RECIPIENT_SPECIFIC_HEADERS, LinkedHashMap.class)) + .entrySet() + .stream() + .flatMap(this::recipientToHeaderStream) + .forEach(recipientToHeaderPair -> perRecipientHeaders.addHeaderForRecipient( + recipientToHeaderPair.getRight(), + recipientToHeaderPair.getLeft())); + + return perRecipientHeaders; + } + + private Stream> recipientToHeaderStream(Map.Entry recipientToHeadersString) { + List headers = Splitter.on(HEADERS_SEPARATOR) + .splitToList(recipientToHeadersString.getValue()); + + return headers + .stream() + .map(headerAsString -> Pair.of( + asMailAddress(recipientToHeadersString.getKey()), + PerRecipientHeaders.Header.fromString(headerAsString))); + } + + private MailAddress asMailAddress(String mailAddress) { + return Throwing.supplier(() -> new MailAddress(mailAddress)) + .get(); + } + + private MimeMessagePartsId toMimeMessagePartsId(Record record) { + return MimeMessagePartsId.builder() + .headerBlobId(blobIdFactory.from(record.get(HEADER_BLOB_ID))) + .bodyBlobId(blobIdFactory.from(record.get(BODY_BLOB_ID))) + .build(); + } + + public void remove(MailKey key, MailRepositoryUrl url) { + removeReactive(key, url).block(); + } + + private Mono removeReactive(MailKey key, MailRepositoryUrl url) { + return getMimeMessagePartsId(key, url) + .flatMap(mimeMessagePartsId -> deleteMailMetadata(key, url) + .then(deleteMailBlob(mimeMessagePartsId))); + } + + private Mono getMimeMessagePartsId(MailKey key, MailRepositoryUrl url) { + return postgresExecutor.executeRow(context -> Mono.from(context.select(HEADER_BLOB_ID, BODY_BLOB_ID) + .from(TABLE_NAME) + .where(URL.eq(url.asString())) + .and(KEY.eq(key.asString())))) + .map(this::toMimeMessagePartsId); + } + + private Mono deleteMailMetadata(MailKey key, MailRepositoryUrl url) { + return postgresExecutor.executeVoid(context -> Mono.from(context.deleteFrom(TABLE_NAME) + .where(URL.eq(url.asString())) + .and(KEY.eq(key.asString())))); + } + + private Mono deleteMailBlob(MimeMessagePartsId mimeMessagePartsId) { + return Mono.from(mimeMessageStore.delete(mimeMessagePartsId)); + } + + public void remove(Collection keys, MailRepositoryUrl url) { + Flux.fromIterable(keys) + .concatMap(mailKey -> removeReactive(mailKey, url)) + .then() + .block(); + } + + public void removeAll(MailRepositoryUrl url) { + listMailKeys(url) + .flatMap(mailKey -> removeReactive(mailKey, url), DEFAULT_CONCURRENCY) + .then() + .block(); + } + + public Flux listBlobs() { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(HEADER_BLOB_ID, BODY_BLOB_ID) + .from(TABLE_NAME))) + .flatMapIterable(record -> ImmutableList.of(blobIdFactory.from(record.get(HEADER_BLOB_ID)), blobIdFactory.from(record.get(BODY_BLOB_ID)))); + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryFactory.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryFactory.java index d947775d9bf..5b85e7b0432 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryFactory.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryFactory.java @@ -47,6 +47,6 @@ public Class mailRepositoryClass() { @Override public MailRepository create(MailRepositoryUrl url) { - return new PostgresMailRepository(executor, url, mimeMessageStoreFactory, blobIdFactory); + return new PostgresMailRepository(url, new PostgresMailRepositoryContentDAO(executor, mimeMessageStoreFactory, blobIdFactory)); } } diff --git a/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryBlobReferenceSourceTest.java b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryBlobReferenceSourceTest.java new file mode 100644 index 00000000000..93b6fa513af --- /dev/null +++ b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryBlobReferenceSourceTest.java @@ -0,0 +1,94 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailrepository.postgres; + +import static org.assertj.core.api.Assertions.assertThat; + +import javax.mail.MessagingException; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BlobStore; +import org.apache.james.blob.api.HashBlobId; +import org.apache.james.blob.mail.MimeMessageStore; +import org.apache.james.blob.memory.MemoryBlobStoreFactory; +import org.apache.james.core.builder.MimeMessageBuilder; +import org.apache.james.mailrepository.api.MailKey; +import org.apache.james.mailrepository.api.MailRepositoryPath; +import org.apache.james.mailrepository.api.MailRepositoryUrl; +import org.apache.james.mailrepository.api.Protocol; +import org.apache.james.server.core.MailImpl; +import org.apache.mailet.Attribute; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresMailRepositoryBlobReferenceSourceTest { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresModule.aggregateModules(PostgresMailRepositoryModule.MODULE)); + + private static final MailRepositoryUrl URL = MailRepositoryUrl.fromPathAndProtocol(new Protocol("postgres"), MailRepositoryPath.from("testrepo")); + + PostgresMailRepositoryContentDAO postgresMailRepositoryContentDAO; + PostgresMailRepositoryBlobReferenceSource postgresMailRepositoryBlobReferenceSource; + + @BeforeEach + void beforeEach() { + BlobId.Factory factory = new HashBlobId.Factory(); + BlobStore blobStore = MemoryBlobStoreFactory.builder() + .blobIdFactory(factory) + .defaultBucketName() + .passthrough(); + postgresMailRepositoryContentDAO = new PostgresMailRepositoryContentDAO(postgresExtension.getPostgresExecutor(), MimeMessageStore.factory(blobStore), factory); + postgresMailRepositoryBlobReferenceSource = new PostgresMailRepositoryBlobReferenceSource(postgresMailRepositoryContentDAO); + } + + @Test + void blobReferencesShouldBeEmptyByDefault() { + assertThat(postgresMailRepositoryBlobReferenceSource.listReferencedBlobs().collectList().block()) + .isEmpty(); + } + + @Test + void blobReferencesShouldReturnAllBlobs() throws Exception { + postgresMailRepositoryContentDAO.store(createMail(new MailKey("mail1")), URL); + postgresMailRepositoryContentDAO.store(createMail(new MailKey("mail2")), URL); + + assertThat(postgresMailRepositoryBlobReferenceSource.listReferencedBlobs().collectList().block()) + .hasSize(4); + } + + private MailImpl createMail(MailKey key) throws MessagingException { + return MailImpl.builder() + .name(key.asString()) + .sender("sender@localhost") + .addRecipient("rec1@domain.com") + .addRecipient("rec2@domain.com") + .addAttribute(Attribute.convertToAttribute("testAttribute", "testValue")) + .mimeMessage(MimeMessageBuilder + .mimeMessageBuilder() + .setSubject("test") + .setText("original body") + .build()) + .build(); + } + +} diff --git a/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryTest.java b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryTest.java index 71ba41f5de8..35a17357d9f 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryTest.java @@ -58,6 +58,6 @@ public PostgresMailRepository retrieveRepository(MailRepositoryPath path) { .blobIdFactory(BLOB_ID_FACTORY) .defaultBucketName() .passthrough(); - return new PostgresMailRepository(postgresExtension.getPostgresExecutor(), url, MimeMessageStore.factory(blobStore), BLOB_ID_FACTORY); + return new PostgresMailRepository(url, new PostgresMailRepositoryContentDAO(postgresExtension.getPostgresExecutor(), MimeMessageStore.factory(blobStore), BLOB_ID_FACTORY)); } } From b2906997e92899911192364f2537c1924de1821a Mon Sep 17 00:00:00 2001 From: hung phan Date: Fri, 12 Jan 2024 17:48:02 +0700 Subject: [PATCH 183/341] JAMES-2586 add mailbox para for generateMessageUid method in MapperProvider --- .../mail/CassandraMapperProvider.java | 13 +- .../mail/CassandraMessageIdMapperTest.java | 12 +- .../mailbox/jpa/mail/JPAMapperProvider.java | 2 +- .../inmemory/mail/InMemoryMapperProvider.java | 2 +- .../store/mail/model/MapperProvider.java | 2 +- .../store/mail/model/MessageIdMapperTest.java | 134 +++++++++--------- 6 files changed, 85 insertions(+), 80 deletions(-) diff --git a/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/mail/CassandraMapperProvider.java b/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/mail/CassandraMapperProvider.java index e3e7cd9f2b8..730a2a836f1 100644 --- a/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/mail/CassandraMapperProvider.java +++ b/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/mail/CassandraMapperProvider.java @@ -40,6 +40,7 @@ import org.apache.james.mailbox.store.mail.MailboxMapper; import org.apache.james.mailbox.store.mail.MessageIdMapper; import org.apache.james.mailbox.store.mail.MessageMapper; +import org.apache.james.mailbox.store.mail.UidProvider; import org.apache.james.mailbox.store.mail.model.MapperProvider; import org.apache.james.mailbox.store.mail.model.MessageUidProvider; import org.apache.james.utils.UpdatableTickingClock; @@ -51,7 +52,7 @@ public class CassandraMapperProvider implements MapperProvider { private static final Factory MESSAGE_ID_FACTORY = new CassandraMessageId.Factory(); private final CassandraCluster cassandra; - private final MessageUidProvider messageUidProvider; + private final UidProvider messageUidProvider; private final CassandraModSeqProvider cassandraModSeqProvider; private final UpdatableTickingClock updatableTickingClock; private final MailboxSession mailboxSession = MailboxSessionUtil.create(Username.of("benwa")); @@ -60,7 +61,7 @@ public class CassandraMapperProvider implements MapperProvider { public CassandraMapperProvider(CassandraCluster cassandra, CassandraConfiguration cassandraConfiguration) { this.cassandra = cassandra; - messageUidProvider = new MessageUidProvider(); + messageUidProvider = new CassandraUidProvider(this.cassandra.getConf(), cassandraConfiguration); cassandraModSeqProvider = new CassandraModSeqProvider( this.cassandra.getConf(), cassandraConfiguration); @@ -116,8 +117,12 @@ public List getSupportedCapabilities() { } @Override - public MessageUid generateMessageUid() { - return messageUidProvider.next(); + public MessageUid generateMessageUid(Mailbox mailbox) { + try { + return messageUidProvider.nextUid(mailbox); + } catch (MailboxException e) { + throw new RuntimeException(e); + } } @Override diff --git a/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/mail/CassandraMessageIdMapperTest.java b/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/mail/CassandraMessageIdMapperTest.java index 00200d3b214..33e42502d04 100644 --- a/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/mail/CassandraMessageIdMapperTest.java +++ b/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/mail/CassandraMessageIdMapperTest.java @@ -152,7 +152,7 @@ void retrieveMessagesShouldNotReturnMessagesWhenFailToPersistInMessageDAO(Cassan .whenQueryStartsWith("UPDATE messagev3")); try { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); } catch (Exception e) { @@ -176,7 +176,7 @@ void retrieveMessagesShouldNotReturnMessagesWhenFailsToPersistBlobParts(Cassandr .whenQueryStartsWith("INSERT INTO blobparts (id,chunknumber,data)")); try { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); } catch (Exception e) { @@ -200,7 +200,7 @@ void retrieveMessagesShouldNotReturnMessagesWhenFailsToPersistBlobs(CassandraClu .whenQueryStartsWith("INSERT INTO blobs (id,position) VALUES (:id,:position)")); try { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); } catch (Exception e) { @@ -224,7 +224,7 @@ void retrieveMessagesShouldNotReturnMessagesWhenFailsToPersistInImapUidTable(Cas .whenQueryStartsWith("INSERT INTO imapuidtable")); try { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); } catch (Exception e) { @@ -248,7 +248,7 @@ void addShouldPersistInTableOfTruthWhenMessageIdTableWritesFails(CassandraCluste .whenQueryStartsWith("INSERT INTO messageidtable")); try { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); } catch (Exception e) { @@ -275,7 +275,7 @@ void addShouldRetryMessageDenormalization(CassandraCluster cassandra) throws Exc .times(5) .whenQueryStartsWith("INSERT INTO messageidtable")); - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); diff --git a/mailbox/jpa/src/test/java/org/apache/james/mailbox/jpa/mail/JPAMapperProvider.java b/mailbox/jpa/src/test/java/org/apache/james/mailbox/jpa/mail/JPAMapperProvider.java index fdad8e414ac..8bbf83238c0 100644 --- a/mailbox/jpa/src/test/java/org/apache/james/mailbox/jpa/mail/JPAMapperProvider.java +++ b/mailbox/jpa/src/test/java/org/apache/james/mailbox/jpa/mail/JPAMapperProvider.java @@ -105,7 +105,7 @@ public MessageIdMapper createMessageIdMapper() throws MailboxException { } @Override - public MessageUid generateMessageUid() { + public MessageUid generateMessageUid(Mailbox mailbox) { throw new NotImplementedException("not implemented"); } diff --git a/mailbox/memory/src/test/java/org/apache/james/mailbox/inmemory/mail/InMemoryMapperProvider.java b/mailbox/memory/src/test/java/org/apache/james/mailbox/inmemory/mail/InMemoryMapperProvider.java index e5f96ace5e6..e0eeeb3bfd0 100644 --- a/mailbox/memory/src/test/java/org/apache/james/mailbox/inmemory/mail/InMemoryMapperProvider.java +++ b/mailbox/memory/src/test/java/org/apache/james/mailbox/inmemory/mail/InMemoryMapperProvider.java @@ -90,7 +90,7 @@ public InMemoryId generateId() { } @Override - public MessageUid generateMessageUid() { + public MessageUid generateMessageUid(Mailbox mailbox) { return messageUidProvider.next(); } diff --git a/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MapperProvider.java b/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MapperProvider.java index b6bd054d2ef..36f5d72b0cf 100644 --- a/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MapperProvider.java +++ b/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MapperProvider.java @@ -59,7 +59,7 @@ enum Capabilities { MailboxId generateId(); - MessageUid generateMessageUid(); + MessageUid generateMessageUid(Mailbox mailbox); ModSeq generateModSeq(Mailbox mailbox) throws MailboxException; diff --git a/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MessageIdMapperTest.java b/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MessageIdMapperTest.java index c630872282f..0f26680875c 100644 --- a/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MessageIdMapperTest.java +++ b/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MessageIdMapperTest.java @@ -150,7 +150,7 @@ void findMailboxesShouldReturnTwoMailboxesWhenMessageExistsInTwoMailboxes() thro saveMessages(); SimpleMailboxMessage message1InOtherMailbox = SimpleMailboxMessage.copy(benwaWorkMailbox.getMailboxId(), message1); - message1InOtherMailbox.setUid(mapperProvider.generateMessageUid()); + message1InOtherMailbox.setUid(mapperProvider.generateMessageUid(benwaWorkMailbox)); message1InOtherMailbox.setModSeq(mapperProvider.generateModSeq(benwaWorkMailbox)); sut.save(message1InOtherMailbox); @@ -160,7 +160,7 @@ void findMailboxesShouldReturnTwoMailboxesWhenMessageExistsInTwoMailboxes() thro @Test void saveShouldSaveAMessage() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaWorkMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); List messages = sut.find(ImmutableList.of(message1.getMessageId()), FetchType.FULL); @@ -171,7 +171,7 @@ void saveShouldSaveAMessage() throws Exception { void saveShouldThrowWhenMailboxDoesntExist() throws Exception { Mailbox notPersistedMailbox = new Mailbox(MailboxPath.forUser(BENWA, "mybox"), UID_VALIDITY, mapperProvider.generateId()); SimpleMailboxMessage message = createMessage(notPersistedMailbox, "Subject: Test \n\nBody\n.\n", BODY_START, new PropertyBuilder()); - message.setUid(mapperProvider.generateMessageUid()); + message.setUid(mapperProvider.generateMessageUid(notPersistedMailbox)); message.setModSeq(mapperProvider.generateModSeq(notPersistedMailbox)); assertThatThrownBy(() -> sut.save(message)) @@ -180,12 +180,12 @@ void saveShouldThrowWhenMailboxDoesntExist() throws Exception { @Test void saveShouldSaveMessageInAnotherMailboxWhenMessageAlreadyInOneMailbox() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); SimpleMailboxMessage message1InOtherMailbox = SimpleMailboxMessage.copy(benwaWorkMailbox.getMailboxId(), message1); - message1InOtherMailbox.setUid(mapperProvider.generateMessageUid()); + message1InOtherMailbox.setUid(mapperProvider.generateMessageUid(benwaWorkMailbox)); message1InOtherMailbox.setModSeq(mapperProvider.generateModSeq(benwaWorkMailbox)); sut.save(message1InOtherMailbox); @@ -195,11 +195,11 @@ void saveShouldSaveMessageInAnotherMailboxWhenMessageAlreadyInOneMailbox() throw @Test void saveShouldWorkWhenSavingTwoTimesWithSameMessageIdAndSameMailboxId() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); SimpleMailboxMessage copiedMessage = SimpleMailboxMessage.copy(message1.getMailboxId(), message1); - copiedMessage.setUid(mapperProvider.generateMessageUid()); + copiedMessage.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); copiedMessage.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(copiedMessage); @@ -209,13 +209,13 @@ void saveShouldWorkWhenSavingTwoTimesWithSameMessageIdAndSameMailboxId() throws @Test void copyInMailboxShouldSaveMessageInAnotherMailbox() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); MailboxMessage message1InOtherMailbox = sut.find(ImmutableList.of(message1.getMessageId()), FetchType.METADATA).get(0) .copy(benwaWorkMailbox); - message1InOtherMailbox.setUid(mapperProvider.generateMessageUid()); + message1InOtherMailbox.setUid(mapperProvider.generateMessageUid(benwaWorkMailbox)); message1InOtherMailbox.setModSeq(mapperProvider.generateModSeq(benwaWorkMailbox)); sut.copyInMailbox(message1InOtherMailbox, benwaWorkMailbox); @@ -225,12 +225,12 @@ void copyInMailboxShouldSaveMessageInAnotherMailbox() throws Exception { @Test void copyInMailboxShouldWorkWhenSavingTwoTimesWithSameMessageIdAndSameMailboxId() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); MailboxMessage copiedMessage = sut.find(ImmutableList.of(message1.getMessageId()), FetchType.METADATA).get(0) .copy(benwaWorkMailbox); - copiedMessage.setUid(mapperProvider.generateMessageUid()); + copiedMessage.setUid(mapperProvider.generateMessageUid(benwaWorkMailbox)); copiedMessage.setModSeq(mapperProvider.generateModSeq(benwaWorkMailbox)); sut.copyInMailbox(copiedMessage, benwaWorkMailbox); @@ -250,7 +250,7 @@ void deleteShouldNotThrowWhenUnknownMessage() { @Test void deleteShouldDeleteAMessage() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); @@ -263,12 +263,12 @@ void deleteShouldDeleteAMessage() throws Exception { @Test void deleteShouldDeleteMessageIndicesWhenStoredInTwoMailboxes() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); SimpleMailboxMessage message1InOtherMailbox = SimpleMailboxMessage.copy(benwaWorkMailbox.getMailboxId(), message1); - message1InOtherMailbox.setUid(mapperProvider.generateMessageUid()); + message1InOtherMailbox.setUid(mapperProvider.generateMessageUid(benwaWorkMailbox)); message1InOtherMailbox.setModSeq(mapperProvider.generateModSeq(benwaWorkMailbox)); sut.save(message1InOtherMailbox); @@ -281,11 +281,11 @@ void deleteShouldDeleteMessageIndicesWhenStoredInTwoMailboxes() throws Exception @Test void deleteShouldDeleteMessageIndicesWhenStoredTwoTimesInTheSameMailbox() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); SimpleMailboxMessage copiedMessage = SimpleMailboxMessage.copy(message1.getMailboxId(), message1); - copiedMessage.setUid(mapperProvider.generateMessageUid()); + copiedMessage.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); copiedMessage.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(copiedMessage); @@ -298,12 +298,12 @@ void deleteShouldDeleteMessageIndicesWhenStoredTwoTimesInTheSameMailbox() throws @Test void deleteWithMailboxIdsShouldNotDeleteIndicesWhenMailboxIdsIsEmpty() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); SimpleMailboxMessage message1InOtherMailbox = SimpleMailboxMessage.copy(benwaWorkMailbox.getMailboxId(), message1); - message1InOtherMailbox.setUid(mapperProvider.generateMessageUid()); + message1InOtherMailbox.setUid(mapperProvider.generateMessageUid(benwaWorkMailbox)); message1InOtherMailbox.setModSeq(mapperProvider.generateModSeq(benwaWorkMailbox)); sut.save(message1InOtherMailbox); @@ -316,12 +316,12 @@ void deleteWithMailboxIdsShouldNotDeleteIndicesWhenMailboxIdsIsEmpty() throws Ex @Test void deleteWithMailboxIdsShouldDeleteOneIndexWhenMailboxIdsContainsOneElement() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); SimpleMailboxMessage message1InOtherMailbox = SimpleMailboxMessage.copy(benwaWorkMailbox.getMailboxId(), message1); - message1InOtherMailbox.setUid(mapperProvider.generateMessageUid()); + message1InOtherMailbox.setUid(mapperProvider.generateMessageUid(benwaWorkMailbox)); message1InOtherMailbox.setModSeq(mapperProvider.generateModSeq(benwaWorkMailbox)); sut.save(message1InOtherMailbox); @@ -334,12 +334,12 @@ void deleteWithMailboxIdsShouldDeleteOneIndexWhenMailboxIdsContainsOneElement() @Test void deleteWithMailboxIdsShouldDeleteIndicesWhenMailboxIdsContainsMultipleElements() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); SimpleMailboxMessage message1InOtherMailbox = SimpleMailboxMessage.copy(benwaWorkMailbox.getMailboxId(), message1); - message1InOtherMailbox.setUid(mapperProvider.generateMessageUid()); + message1InOtherMailbox.setUid(mapperProvider.generateMessageUid(benwaWorkMailbox)); message1InOtherMailbox.setModSeq(mapperProvider.generateModSeq(benwaWorkMailbox)); sut.save(message1InOtherMailbox); @@ -352,7 +352,7 @@ void deleteWithMailboxIdsShouldDeleteIndicesWhenMailboxIdsContainsMultipleElemen @Test void setFlagsShouldReturnUpdatedFlagsWhenMessageIsInOneMailbox() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); @@ -376,7 +376,7 @@ void setFlagsShouldReturnUpdatedFlagsWhenReplaceMode() throws Exception { Flags messageFlags = new FlagsBuilder().add(Flags.Flag.RECENT, Flags.Flag.FLAGGED) .build(); - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setFlags(messageFlags); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); @@ -406,7 +406,7 @@ void setFlagsShouldReturnUpdatedFlagsWhenRemoveMode() throws Exception { Flags messageFlags = new FlagsBuilder().add(Flags.Flag.RECENT, Flags.Flag.FLAGGED) .build(); - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setFlags(messageFlags); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); @@ -436,7 +436,7 @@ void setFlagsShouldUpdateMessageFlagsWhenRemoveMode() throws Exception { Flags messageFlags = new FlagsBuilder().add(Flags.Flag.RECENT, Flags.Flag.FLAGGED) .build(); - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setFlags(messageFlags); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); @@ -456,7 +456,7 @@ void setFlagsShouldUpdateMessageFlagsWhenRemoveMode() throws Exception { @Test void setFlagsShouldReturnEmptyWhenMailboxIdsIsEmpty() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); @@ -478,7 +478,7 @@ void setFlagsShouldReturnEmptyWhenMessageIdDoesntExist() throws Exception { @Test void setFlagsShouldAddFlagsWhenAddUpdateMode() throws Exception { Flags initialFlags = new Flags(Flag.RECENT); - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); message1.setFlags(initialFlags); sut.save(message1); @@ -505,12 +505,12 @@ void setFlagsShouldAddFlagsWhenAddUpdateMode() throws Exception { @Test void setFlagsShouldReturnUpdatedFlagsWhenMessageIsInTwoMailboxes() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); SimpleMailboxMessage message1InOtherMailbox = SimpleMailboxMessage.copy(benwaWorkMailbox.getMailboxId(), message1); - message1InOtherMailbox.setUid(mapperProvider.generateMessageUid()); + message1InOtherMailbox.setUid(mapperProvider.generateMessageUid(benwaWorkMailbox)); message1InOtherMailbox.setModSeq(mapperProvider.generateModSeq(benwaWorkMailbox)); sut.save(message1InOtherMailbox); @@ -541,7 +541,7 @@ void setFlagsShouldReturnUpdatedFlagsWhenMessageIsInTwoMailboxes() throws Except @Test void setFlagsShouldUpdateFlagsWhenMessageIsInOneMailbox() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); @@ -555,7 +555,7 @@ void setFlagsShouldUpdateFlagsWhenMessageIsInOneMailbox() throws Exception { @Test void setFlagsShouldNotModifyModSeqWhenMailboxIdsIsEmpty() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); ModSeq modSeq = mapperProvider.generateModSeq(benwaInboxMailbox); message1.setModSeq(modSeq); sut.save(message1); @@ -571,7 +571,7 @@ void setFlagsShouldNotModifyModSeqWhenMailboxIdsIsEmpty() throws Exception { @Test void setFlagsShouldUpdateModSeqWhenMessageIsInOneMailbox() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); ModSeq modSeq = mapperProvider.generateModSeq(benwaInboxMailbox); message1.setModSeq(modSeq); sut.save(message1); @@ -586,7 +586,7 @@ void setFlagsShouldUpdateModSeqWhenMessageIsInOneMailbox() throws Exception { @Test void setFlagsShouldNotModifyFlagsWhenMailboxIdsIsEmpty() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); ModSeq modSeq = mapperProvider.generateModSeq(benwaInboxMailbox); message1.setModSeq(modSeq); Flags initialFlags = new Flags(Flags.Flag.DRAFT); @@ -604,12 +604,12 @@ void setFlagsShouldNotModifyFlagsWhenMailboxIdsIsEmpty() throws Exception { @Test void setFlagsShouldUpdateFlagsWhenMessageIsInTwoMailboxes() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); SimpleMailboxMessage message1InOtherMailbox = SimpleMailboxMessage.copy(benwaWorkMailbox.getMailboxId(), message1); - message1InOtherMailbox.setUid(mapperProvider.generateMessageUid()); + message1InOtherMailbox.setUid(mapperProvider.generateMessageUid(benwaWorkMailbox)); message1InOtherMailbox.setModSeq(mapperProvider.generateModSeq(benwaWorkMailbox)); sut.save(message1InOtherMailbox); @@ -624,16 +624,16 @@ void setFlagsShouldUpdateFlagsWhenMessageIsInTwoMailboxes() throws Exception { @Test void setFlagsShouldWorkWhenCalledOnFirstMessage() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); - message2.setUid(mapperProvider.generateMessageUid()); + message2.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message2.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message2); - message3.setUid(mapperProvider.generateMessageUid()); + message3.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message3.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message3); - message4.setUid(mapperProvider.generateMessageUid()); + message4.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message4.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message4); @@ -647,16 +647,16 @@ void setFlagsShouldWorkWhenCalledOnFirstMessage() throws Exception { @Test void setFlagsShouldWorkWhenCalledOnDuplicatedMailbox() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); - message2.setUid(mapperProvider.generateMessageUid()); + message2.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message2.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message2); - message3.setUid(mapperProvider.generateMessageUid()); + message3.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message3.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message3); - message4.setUid(mapperProvider.generateMessageUid()); + message4.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message4.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message4); @@ -671,7 +671,7 @@ void setFlagsShouldWorkWhenCalledOnDuplicatedMailbox() throws Exception { @Test public void setFlagsShouldWorkWithConcurrencyWithAdd() throws Exception { Assume.assumeTrue(mapperProvider.getSupportedCapabilities().contains(MapperProvider.Capabilities.THREAD_SAFE_FLAGS_UPDATE)); - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); @@ -694,7 +694,7 @@ public void setFlagsShouldWorkWithConcurrencyWithAdd() throws Exception { @Test public void setFlagsShouldWorkWithConcurrencyWithRemove() throws Exception { Assume.assumeTrue(mapperProvider.getSupportedCapabilities().contains(MapperProvider.Capabilities.THREAD_SAFE_FLAGS_UPDATE)); - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); @@ -727,7 +727,7 @@ public void setFlagsShouldWorkWithConcurrencyWithRemove() throws Exception { @Test void countMessageShouldReturnWhenCreateNewMessage() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); @@ -736,7 +736,7 @@ void countMessageShouldReturnWhenCreateNewMessage() throws Exception { @Test void countUnseenMessageShouldBeEmptyWhenMessageIsSeen() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); message1.setFlags(new Flags(Flag.SEEN)); sut.save(message1); @@ -746,7 +746,7 @@ void countUnseenMessageShouldBeEmptyWhenMessageIsSeen() throws Exception { @Test void countUnseenMessageShouldReturnWhenMessageIsNotSeen() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); @@ -755,7 +755,7 @@ void countUnseenMessageShouldReturnWhenMessageIsNotSeen() throws Exception { @Test void countMessageShouldBeEmptyWhenDeleteMessage() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); @@ -766,7 +766,7 @@ void countMessageShouldBeEmptyWhenDeleteMessage() throws Exception { @Test void countUnseenMessageShouldBeEmptyWhenDeleteMessage() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); @@ -777,12 +777,12 @@ void countUnseenMessageShouldBeEmptyWhenDeleteMessage() throws Exception { @Test void countUnseenMessageShouldReturnWhenDeleteMessage() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); message1.setFlags(new Flags(Flag.SEEN)); sut.save(message1); - message2.setUid(mapperProvider.generateMessageUid()); + message2.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message2.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message2); @@ -793,7 +793,7 @@ void countUnseenMessageShouldReturnWhenDeleteMessage() throws Exception { @Test void countUnseenMessageShouldTakeCareOfMessagesMarkedAsRead() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); @@ -804,7 +804,7 @@ void countUnseenMessageShouldTakeCareOfMessagesMarkedAsRead() throws Exception { @Test void countUnseenMessageShouldTakeCareOfMessagesMarkedAsUnread() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); message1.setFlags(new Flags(Flag.SEEN)); sut.save(message1); @@ -816,7 +816,7 @@ void countUnseenMessageShouldTakeCareOfMessagesMarkedAsUnread() throws Exception @Test void setFlagsShouldNotUpdateModSeqWhenNoop() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); ModSeq modSeq = mapperProvider.generateModSeq(benwaInboxMailbox); message1.setModSeq(modSeq); message1.setFlags(new Flags(Flag.SEEN)); @@ -835,7 +835,7 @@ void setFlagsShouldNotUpdateModSeqWhenNoop() throws Exception { @Test void addingFlagToAMessageThatAlreadyHasThisFlagShouldResultInNoChange() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); ModSeq modSeq = mapperProvider.generateModSeq(benwaInboxMailbox); message1.setModSeq(modSeq); Flags flags = new Flags(Flag.SEEN); @@ -855,7 +855,7 @@ void addingFlagToAMessageThatAlreadyHasThisFlagShouldResultInNoChange() throws E @Test void setFlagsShouldReturnUpdatedFlagsWhenNoop() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); ModSeq modSeq = mapperProvider.generateModSeq(benwaInboxMailbox); message1.setModSeq(modSeq); Flags flags = new Flags(Flag.SEEN); @@ -881,7 +881,7 @@ void setFlagsShouldReturnUpdatedFlagsWhenNoop() throws Exception { @Test void countUnseenMessageShouldNotTakeCareOfOtherFlagsUpdates() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); message1.setFlags(new Flags(Flag.RECENT)); sut.save(message1); @@ -896,7 +896,7 @@ void deletesShouldOnlyRemoveConcernedMessages() throws Exception { saveMessages(); MailboxMessage copiedMessage = sut.find(ImmutableList.of(message1.getMessageId()), FetchType.METADATA).get(0); - copiedMessage.setUid(mapperProvider.generateMessageUid()); + copiedMessage.setUid(mapperProvider.generateMessageUid(benwaWorkMailbox)); copiedMessage.setModSeq(mapperProvider.generateModSeq(benwaWorkMailbox)); sut.copyInMailbox(copiedMessage, benwaWorkMailbox); @@ -921,7 +921,7 @@ void deletesShouldUpdateMessageCount() throws Exception { saveMessages(); MailboxMessage copiedMessage = sut.find(ImmutableList.of(message1.getMessageId()), FetchType.METADATA).get(0); - copiedMessage.setUid(mapperProvider.generateMessageUid()); + copiedMessage.setUid(mapperProvider.generateMessageUid(benwaWorkMailbox)); copiedMessage.setModSeq(mapperProvider.generateModSeq(benwaWorkMailbox)); sut.copyInMailbox(copiedMessage, benwaWorkMailbox); @@ -962,12 +962,12 @@ void setFlagsShouldReturnAllUp() throws Exception { @Test void deletesShouldUpdateUnreadCount() throws Exception { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); message1.setFlags(new Flags(Flag.SEEN)); sut.save(message1); - message2.setUid(mapperProvider.generateMessageUid()); + message2.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message2.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message2); @@ -993,11 +993,11 @@ void deletesShouldNotFailUponMissingMessage() { class SaveDateTests { @Test void saveMessagesShouldSetNewSaveDate() throws MailboxException { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); message1.setFlags(new Flags(Flag.SEEN)); - message2.setUid(mapperProvider.generateMessageUid()); + message2.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message2.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); sut.save(message1); @@ -1012,14 +1012,14 @@ void saveMessagesShouldSetNewSaveDate() throws MailboxException { @Test void copyInMailboxReactiveShouldSetNewSaveDate() throws MailboxException, InterruptedException { - message1.setUid(mapperProvider.generateMessageUid()); + message1.setUid(mapperProvider.generateMessageUid(benwaInboxMailbox)); message1.setModSeq(mapperProvider.generateModSeq(benwaInboxMailbox)); message1.setFlags(new Flags(Flag.SEEN)); sut.save(message1); MailboxMessage copy = sut.find(ImmutableList.of(message1.getMessageId()), FetchType.METADATA).get(0) .copy(benwaWorkMailbox); - copy.setUid(mapperProvider.generateMessageUid()); + copy.setUid(mapperProvider.generateMessageUid(benwaWorkMailbox)); copy.setModSeq(mapperProvider.generateModSeq(benwaWorkMailbox)); updatableTickingClock().setInstant(updatableTickingClock().instant().plus(8, ChronoUnit.DAYS)); From 732b2a2aa6292e00664f2e243e143e78268ec2f0 Mon Sep 17 00:00:00 2001 From: hung phan Date: Fri, 12 Jan 2024 17:49:21 +0700 Subject: [PATCH 184/341] JAMES-2586 Implement PostgresMessageIdMapper --- .../PostgresMailboxSessionMapperFactory.java | 12 +- .../MailboxDeleteDuringUpdateException.java | 23 ++ .../mail/PostgresMessageIdMapper.java | 274 ++++++++++++++++++ .../postgres/mail/PostgresMessageMapper.java | 7 +- .../mail/dao/PostgresMailboxMessageDAO.java | 68 +++-- .../postgres/mail/PostgresMapperProvider.java | 39 ++- .../mail/PostgresMessageIdMapperTest.java | 45 +++ 7 files changed, 440 insertions(+), 28 deletions(-) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/MailboxDeleteDuringUpdateException.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapper.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapperTest.java diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java index 7d78d275f49..7f54b435016 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java @@ -29,11 +29,14 @@ import org.apache.james.mailbox.MailboxSession; import org.apache.james.mailbox.postgres.mail.PostgresAnnotationMapper; import org.apache.james.mailbox.postgres.mail.PostgresMailboxMapper; +import org.apache.james.mailbox.postgres.mail.PostgresMessageIdMapper; import org.apache.james.mailbox.postgres.mail.PostgresMessageMapper; import org.apache.james.mailbox.postgres.mail.PostgresModSeqProvider; import org.apache.james.mailbox.postgres.mail.PostgresUidProvider; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxAnnotationDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.postgres.user.PostgresSubscriptionDAO; import org.apache.james.mailbox.postgres.user.PostgresSubscriptionMapper; import org.apache.james.mailbox.store.MailboxSessionMapperFactory; @@ -45,7 +48,6 @@ import org.apache.james.mailbox.store.mail.MessageMapper; import org.apache.james.mailbox.store.user.SubscriptionMapper; - public class PostgresMailboxSessionMapperFactory extends MailboxSessionMapperFactory implements AttachmentMapperFactory { private final PostgresExecutor.Factory executorFactory; @@ -82,7 +84,13 @@ public MessageMapper createMessageMapper(MailboxSession session) { @Override public MessageIdMapper createMessageIdMapper(MailboxSession session) { - throw new NotImplementedException("not implemented"); + return new PostgresMessageIdMapper(new PostgresMailboxDAO(executorFactory.create(session.getUser().getDomainPart())), + new PostgresMessageDAO(executorFactory.create(session.getUser().getDomainPart()), blobIdFactory), + new PostgresMailboxMessageDAO(executorFactory.create(session.getUser().getDomainPart())), + getModSeqProvider(session), + blobStore, + blobIdFactory, + clock); } @Override diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/MailboxDeleteDuringUpdateException.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/MailboxDeleteDuringUpdateException.java new file mode 100644 index 00000000000..e738905441a --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/MailboxDeleteDuringUpdateException.java @@ -0,0 +1,23 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +public class MailboxDeleteDuringUpdateException extends Exception { +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapper.java new file mode 100644 index 00000000000..b3233f83453 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapper.java @@ -0,0 +1,274 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import static org.apache.james.blob.api.BlobStore.StoragePolicy.LOW_COST; +import static org.apache.james.blob.api.BlobStore.StoragePolicy.SIZE_BASED; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.BODY_BLOB_ID; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.HEADER_CONTENT; + +import java.io.IOException; +import java.io.InputStream; +import java.time.Clock; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.function.Function; + +import javax.mail.Flags; + +import org.apache.commons.lang3.tuple.Pair; +import org.apache.james.backends.postgres.utils.PostgresUtils; +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BlobStore; +import org.apache.james.mailbox.MessageManager; +import org.apache.james.mailbox.MessageUid; +import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.exception.MailboxNotFoundException; +import org.apache.james.mailbox.model.ComposedMessageIdWithMetaData; +import org.apache.james.mailbox.model.Content; +import org.apache.james.mailbox.model.HeaderAndBodyByteContent; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.model.UpdatedFlags; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.apache.james.mailbox.store.FlagsUpdateCalculator; +import org.apache.james.mailbox.store.MailboxReactorUtils; +import org.apache.james.mailbox.store.mail.MessageIdMapper; +import org.apache.james.mailbox.store.mail.MessageMapper; +import org.apache.james.mailbox.store.mail.model.MailboxMessage; +import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; +import org.apache.james.util.ReactorUtils; +import org.jooq.Record; +import org.reactivestreams.Publisher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableListMultimap; +import com.google.common.collect.Multimap; +import com.google.common.io.ByteSource; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresMessageIdMapper implements MessageIdMapper { + private static final Function MESSAGE_BODY_CONTENT_LOADER = (mailboxMessage) -> new ByteSource() { + @Override + public InputStream openStream() { + try { + return mailboxMessage.getBodyContent(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Override + public long size() { + return mailboxMessage.getBodyOctets(); + } + }; + + public static final int NUM_RETRIES = 5; + public static final Logger LOGGER = LoggerFactory.getLogger(PostgresMessageIdMapper.class); + + private final PostgresMailboxDAO mailboxDAO; + private final PostgresMessageDAO messageDAO; + private final PostgresMailboxMessageDAO mailboxMessageDAO; + private final PostgresModSeqProvider modSeqProvider; + private final BlobStore blobStore; + private final BlobId.Factory blobIdFactory; + private final Clock clock; + + public PostgresMessageIdMapper(PostgresMailboxDAO mailboxDAO, PostgresMessageDAO messageDAO, + PostgresMailboxMessageDAO mailboxMessageDAO, PostgresModSeqProvider modSeqProvider, + BlobStore blobStore, BlobId.Factory blobIdFactory, + Clock clock) { + this.mailboxDAO = mailboxDAO; + this.messageDAO = messageDAO; + this.mailboxMessageDAO = mailboxMessageDAO; + this.modSeqProvider = modSeqProvider; + this.blobStore = blobStore; + this.blobIdFactory = blobIdFactory; + this.clock = clock; + } + + @Override + public List find(Collection messageIds, MessageMapper.FetchType fetchType) { + return findReactive(messageIds, fetchType) + .collectList() + .block(); + } + + @Override + public Publisher findMetadata(MessageId messageId) { + return mailboxMessageDAO.findMetadataByMessageId(PostgresMessageId.class.cast(messageId)); + } + + @Override + public Flux findReactive(Collection messageIds, MessageMapper.FetchType fetchType) { + return mailboxMessageDAO.findMessagesByMessageIds(messageIds.stream().map(PostgresMessageId.class::cast).collect(ImmutableList.toImmutableList()), + fetchType) + .flatMap(messageBuilderAndRecord -> { + SimpleMailboxMessage.Builder messageBuilder = messageBuilderAndRecord.getLeft(); + if (fetchType == MessageMapper.FetchType.FULL) { + return retrieveFullContent(messageBuilderAndRecord.getRight()) + .map(headerAndBodyContent -> messageBuilder.content(headerAndBodyContent).build()); + } + return Mono.just(messageBuilder.build()); + }, ReactorUtils.DEFAULT_CONCURRENCY); + } + + @Override + public List findMailboxes(MessageId messageId) { + return mailboxMessageDAO.findMailboxes(PostgresMessageId.class.cast(messageId)) + .collect(ImmutableList.toImmutableList()) + .block(); + } + + @Override + public void save(MailboxMessage mailboxMessage) throws MailboxException { + PostgresMailboxId mailboxId = PostgresMailboxId.class.cast(mailboxMessage.getMailboxId()); + mailboxMessage.setSaveDate(Date.from(clock.instant())); + MailboxReactorUtils.block(mailboxDAO.findMailboxById(mailboxId) + .switchIfEmpty(Mono.error(() -> new MailboxNotFoundException(mailboxId))) + .then(saveBodyContent(mailboxMessage)) + .flatMap(blobId -> messageDAO.insert(mailboxMessage, blobId.asString()) + .onErrorResume(PostgresUtils.UNIQUE_CONSTRAINT_VIOLATION_PREDICATE, e -> Mono.empty())) + .then(mailboxMessageDAO.insert(mailboxMessage))); + } + + @Override + public void copyInMailbox(MailboxMessage mailboxMessage, Mailbox mailbox) throws MailboxException { + MailboxReactorUtils.block(copyInMailboxReactive(mailboxMessage, mailbox)); + } + + @Override + public Mono copyInMailboxReactive(MailboxMessage mailboxMessage, Mailbox mailbox) { + mailboxMessage.setSaveDate(Date.from(clock.instant())); + PostgresMailboxId mailboxId = (PostgresMailboxId) mailbox.getMailboxId(); + return mailboxMessageDAO.insert(mailboxMessage, mailboxId) + .onErrorResume(PostgresUtils.UNIQUE_CONSTRAINT_VIOLATION_PREDICATE, e -> Mono.empty()); + } + + @Override + public void delete(MessageId messageId) { + mailboxMessageDAO.deleteByMessageId((PostgresMessageId) messageId).block(); + } + + @Override + public void delete(MessageId messageId, Collection mailboxIds) { + mailboxMessageDAO.deleteByMessageIdAndMailboxIds((PostgresMessageId) messageId, + mailboxIds.stream().map(PostgresMailboxId.class::cast).collect(ImmutableList.toImmutableList())).block(); + } + + @Override + public Mono> setFlags(MessageId messageId, List mailboxIds, Flags newState, MessageManager.FlagsUpdateMode updateMode) { + return Flux.fromIterable(mailboxIds) + .distinct() + .map(PostgresMailboxId.class::cast) + .concatMap(mailboxId -> flagsUpdateWithRetry(newState, updateMode, mailboxId, messageId)) + .collect(ImmutableListMultimap.toImmutableListMultimap(Pair::getLeft, Pair::getRight)); + } + + private Flux> flagsUpdateWithRetry(Flags newState, MessageManager.FlagsUpdateMode updateMode, MailboxId mailboxId, MessageId messageId) { + return updateFlags(mailboxId, messageId, newState, updateMode) + .retry(NUM_RETRIES) + .onErrorResume(MailboxDeleteDuringUpdateException.class, e -> { + LOGGER.info("Mailbox {} was deleted during flag update", mailboxId); + return Mono.empty(); + }) + .flatMapIterable(Function.identity()) + .map(pair -> buildUpdatedFlags(pair.getRight(), pair.getLeft())); + } + + private Pair buildUpdatedFlags(ComposedMessageIdWithMetaData composedMessageIdWithMetaData, Flags oldFlags) { + return Pair.of(composedMessageIdWithMetaData.getComposedMessageId().getMailboxId(), + UpdatedFlags.builder() + .uid(composedMessageIdWithMetaData.getComposedMessageId().getUid()) + .messageId(composedMessageIdWithMetaData.getComposedMessageId().getMessageId()) + .modSeq(composedMessageIdWithMetaData.getModSeq()) + .oldFlags(oldFlags) + .newFlags(composedMessageIdWithMetaData.getFlags()) + .build()); + } + + private Mono>> updateFlags(MailboxId mailboxId, MessageId messageId, Flags newState, MessageManager.FlagsUpdateMode updateMode) { + PostgresMailboxId postgresMailboxId = (PostgresMailboxId) mailboxId; + PostgresMessageId postgresMessageId = (PostgresMessageId) messageId; + return mailboxMessageDAO.findMetadataByMessageId(postgresMessageId, postgresMailboxId) + .flatMap(oldComposedId -> updateFlags(newState, updateMode, postgresMailboxId, oldComposedId), ReactorUtils.DEFAULT_CONCURRENCY) + .switchIfEmpty(Mono.error(MailboxDeleteDuringUpdateException::new)) + .collectList(); + } + + private Mono> updateFlags(Flags newState, MessageManager.FlagsUpdateMode updateMode, PostgresMailboxId mailboxId, ComposedMessageIdWithMetaData oldComposedId) { + FlagsUpdateCalculator flagsUpdateCalculator = new FlagsUpdateCalculator(newState, updateMode); + Flags newFlags = flagsUpdateCalculator.buildNewFlags(oldComposedId.getFlags()); + if (identicalFlags(oldComposedId, newFlags)) { + return Mono.just(Pair.of(oldComposedId.getFlags(), oldComposedId)); + } else { + return modSeqProvider.nextModSeqReactive(mailboxId) + .flatMap(newModSeq -> updateFlags(mailboxId, flagsUpdateCalculator, newModSeq, oldComposedId.getComposedMessageId().getUid()) + .map(flags -> Pair.of(oldComposedId.getFlags(), new ComposedMessageIdWithMetaData( + oldComposedId.getComposedMessageId(), + flags, + newModSeq, + oldComposedId.getThreadId())))); + } + } + + private Mono updateFlags(PostgresMailboxId mailboxId, FlagsUpdateCalculator flagsUpdateCalculator, ModSeq newModSeq, MessageUid uid) { + + switch (flagsUpdateCalculator.getMode()) { + case ADD: + return mailboxMessageDAO.addFlags(mailboxId, uid, flagsUpdateCalculator.providedFlags(), newModSeq); + case REMOVE: + return mailboxMessageDAO.removeFlags(mailboxId, uid, flagsUpdateCalculator.providedFlags(), newModSeq); + case REPLACE: + return mailboxMessageDAO.replaceFlags(mailboxId, uid, flagsUpdateCalculator.providedFlags(), newModSeq); + default: + return Mono.error(() -> new RuntimeException("Unknown MessageRange type " + flagsUpdateCalculator.getMode())); + } + } + + private boolean identicalFlags(ComposedMessageIdWithMetaData oldComposedId, Flags newFlags) { + return oldComposedId.getFlags().equals(newFlags); + } + + private Mono retrieveFullContent(Record messageRecord) { + byte[] headerBytes = messageRecord.get(HEADER_CONTENT); + return Mono.from(blobStore.readBytes(blobStore.getDefaultBucketName(), + blobIdFactory.from(messageRecord.get(BODY_BLOB_ID)), + SIZE_BASED)) + .map(bodyBytes -> new HeaderAndBodyByteContent(headerBytes, bodyBytes)); + } + + private Mono saveBodyContent(MailboxMessage message) { + return Mono.fromCallable(() -> MESSAGE_BODY_CONTENT_LOADER.apply(message)) + .flatMap(bodyByteSource -> Mono.from(blobStore.save(blobStore.getDefaultBucketName(), bodyByteSource, LOW_COST))); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java index 6c45e89432b..7d4385995e4 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java @@ -39,6 +39,7 @@ import org.apache.commons.lang3.tuple.Pair; import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.backends.postgres.utils.PostgresUtils; import org.apache.james.blob.api.BlobId; import org.apache.james.blob.api.BlobStore; import org.apache.james.mailbox.ApplicableFlagBuilder; @@ -64,6 +65,7 @@ import org.apache.james.mailbox.store.mail.MessageMapper; import org.apache.james.mailbox.store.mail.model.MailboxMessage; import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; +import org.apache.james.util.ReactorUtils; import org.apache.james.util.streams.Limit; import org.jooq.Record; @@ -138,7 +140,7 @@ public Flux findInMailboxReactive(Mailbox mailbox, MessageRange SimpleMailboxMessage.Builder messageBuilder = messageBuilderAndRecord.getLeft(); return retrieveFullContent(messageBuilderAndRecord.getRight()) .map(headerAndBodyContent -> messageBuilder.content(headerAndBodyContent).build()); - }) + }, ReactorUtils.DEFAULT_CONCURRENCY) .sort(Comparator.comparing(MailboxMessage::getUid)) .map(message -> message); } else { @@ -278,7 +280,8 @@ public Mono addReactive(Mailbox mailbox, MailboxMessage message }) .flatMap(this::setNewUidAndModSeq) .then(saveBodyContent(message) - .flatMap(bodyBlobId -> messageDAO.insert(message, bodyBlobId.asString()))) + .flatMap(bodyBlobId -> messageDAO.insert(message, bodyBlobId.asString()) + .onErrorResume(PostgresUtils.UNIQUE_CONSTRAINT_VIOLATION_PREDICATE, e -> Mono.empty()))) .then(Mono.defer(() -> mailboxMessageDAO.insert(message))) .then(Mono.fromCallable(message::metaData)); } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java index a267dfc3aa3..61e48ea6372 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java @@ -49,6 +49,7 @@ import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.RECORD_TO_MESSAGE_METADATA_FUNCTION; import static org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAOUtils.RECORD_TO_MESSAGE_UID_FUNCTION; +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; @@ -64,6 +65,7 @@ import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.ModSeq; import org.apache.james.mailbox.model.ComposedMessageIdWithMetaData; +import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.MessageMetaData; import org.apache.james.mailbox.model.MessageRange; import org.apache.james.mailbox.postgres.PostgresMailboxId; @@ -88,6 +90,7 @@ import org.jooq.impl.DSL; import org.jooq.util.postgres.PostgresDSL; +import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; import reactor.core.publisher.Flux; @@ -235,6 +238,17 @@ public Flux deleteByMailboxId(PostgresMailboxId mailboxId) { .map(record -> PostgresMessageId.Factory.of(record.get(MESSAGE_ID))); } + public Mono deleteByMessageIdAndMailboxIds(PostgresMessageId messageId, Collection mailboxIds) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(TABLE_NAME) + .where(MESSAGE_ID.eq(messageId.asUuid())) + .and(MAILBOX_ID.in(mailboxIds.stream().map(PostgresMailboxId::asUuid).collect(ImmutableList.toImmutableList()))))); + } + + public Mono deleteByMessageId(PostgresMessageId messageId) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(TABLE_NAME) + .where(MESSAGE_ID.eq(messageId.asUuid())))); + } + public Mono countTotalMessagesByMailboxId(PostgresMailboxId mailboxId) { return postgresExecutor.executeCount(dslContext -> Mono.from(dslContext.selectCount() .from(TABLE_NAME) @@ -388,22 +402,6 @@ public Flux findMessagesMetadata(PostgresMailboxI .map(RECORD_TO_COMPOSED_MESSAGE_ID_WITH_META_DATA_FUNCTION); } - public Flux findMessagesMetadata(PostgresMailboxId mailboxId, List messageUids) { - Function, Flux> queryPublisherFunction = uidsToFetch -> postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select() - .from(TABLE_NAME) - .where(MAILBOX_ID.eq(mailboxId.asUuid())) - .and(MESSAGE_UID.in(uidsToFetch.stream().map(MessageUid::asLong).toArray(Long[]::new))) - .orderBy(DEFAULT_SORT_ORDER_BY))) - .map(RECORD_TO_COMPOSED_MESSAGE_ID_WITH_META_DATA_FUNCTION); - - if (messageUids.size() <= IN_CLAUSE_MAX_SIZE) { - return queryPublisherFunction.apply(messageUids); - } else { - return Flux.fromIterable(Iterables.partition(messageUids, IN_CLAUSE_MAX_SIZE)) - .flatMap(queryPublisherFunction); - } - } - public Flux findAllRecentMessageMetadata(PostgresMailboxId mailboxId) { return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select() .from(TABLE_NAME) @@ -527,8 +525,12 @@ public Flux resetRecentFlag(PostgresMailboxId mailboxId, List insert(MailboxMessage mailboxMessage) { + return insert(mailboxMessage, PostgresMailboxId.class.cast(mailboxMessage.getMailboxId())); + } + + public Mono insert(MailboxMessage mailboxMessage, PostgresMailboxId mailboxId) { return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME) - .set(MAILBOX_ID, ((PostgresMailboxId) mailboxMessage.getMailboxId()).asUuid()) + .set(MAILBOX_ID, mailboxId.asUuid()) .set(MESSAGE_UID, mailboxMessage.getUid().asLong()) .set(MOD_SEQ, mailboxMessage.getModSeq().asLong()) .set(MESSAGE_ID, ((PostgresMessageId) mailboxMessage.getMessageId()).asUuid()) @@ -545,4 +547,36 @@ public Mono insert(MailboxMessage mailboxMessage) { .set(SAVE_DATE, mailboxMessage.getSaveDate().map(DATE_TO_LOCAL_DATE_TIME).orElse(null)))); } + public Flux findMailboxes(PostgresMessageId messageId) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(MAILBOX_ID) + .from(TABLE_NAME) + .where(MESSAGE_ID.eq(messageId.asUuid())))) + .map(record -> PostgresMailboxId.of(record.get(MAILBOX_ID))); + } + + public Flux> findMessagesByMessageIds(Collection messageIds, MessageMapper.FetchType fetchType) { + PostgresMailboxMessageFetchStrategy fetchStrategy = FETCH_TYPE_TO_FETCH_STRATEGY.apply(fetchType); + + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(fetchStrategy.fetchFields()) + .from(MESSAGES_JOIN_MAILBOX_MESSAGES_CONDITION_STEP) + .where(DSL.field(TABLE_NAME.getName() + "." + MESSAGE_ID.getName()) + .in(messageIds.stream().map(PostgresMessageId::asUuid).collect(ImmutableList.toImmutableList()))))) + .map(record -> Pair.of(fetchStrategy.toMessageBuilder().apply(record), record)); + } + + public Flux findMetadataByMessageId(PostgresMessageId messageId) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select() + .from(TABLE_NAME) + .where(MESSAGE_ID.eq(messageId.asUuid())))) + .map(RECORD_TO_COMPOSED_MESSAGE_ID_WITH_META_DATA_FUNCTION); + } + + public Flux findMetadataByMessageId(PostgresMessageId messageId, PostgresMailboxId mailboxId) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select() + .from(TABLE_NAME) + .where(MESSAGE_ID.eq(messageId.asUuid())) + .and(MAILBOX_ID.eq(mailboxId.asUuid())))) + .map(RECORD_TO_COMPOSED_MESSAGE_ID_WITH_META_DATA_FUNCTION); + } + } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMapperProvider.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMapperProvider.java index c4705bf2598..ebd3a51cf0a 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMapperProvider.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMapperProvider.java @@ -31,20 +31,27 @@ import org.apache.james.blob.memory.MemoryBlobStoreDAO; import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.ModSeq; +import org.apache.james.mailbox.exception.MailboxException; import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.MessageId; import org.apache.james.mailbox.postgres.PostgresMailboxId; import org.apache.james.mailbox.postgres.PostgresMessageId; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.store.mail.AttachmentMapper; import org.apache.james.mailbox.store.mail.MailboxMapper; import org.apache.james.mailbox.store.mail.MessageIdMapper; import org.apache.james.mailbox.store.mail.MessageMapper; +import org.apache.james.mailbox.store.mail.UidProvider; import org.apache.james.mailbox.store.mail.model.MapperProvider; +import org.apache.james.mailbox.store.mail.model.MessageUidProvider; import org.apache.james.server.blob.deduplication.DeDuplicationBlobStore; import org.apache.james.utils.UpdatableTickingClock; +import org.testcontainers.utility.ThrowingFunction; +import com.github.fge.lambdas.Throwing; import com.google.common.collect.ImmutableList; public class PostgresMapperProvider implements MapperProvider { @@ -54,6 +61,7 @@ public class PostgresMapperProvider implements MapperProvider { private final UpdatableTickingClock updatableTickingClock; private final BlobStore blobStore; private final BlobId.Factory blobIdFactory; + private final UidProvider messageUidProvider; public PostgresMapperProvider(PostgresExtension postgresExtension) { this.postgresExtension = postgresExtension; @@ -61,11 +69,13 @@ public PostgresMapperProvider(PostgresExtension postgresExtension) { this.messageIdFactory = new PostgresMessageId.Factory(); this.blobIdFactory = new HashBlobId.Factory(); this.blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory); + this.messageUidProvider = new PostgresUidProvider(new PostgresMailboxDAO(postgresExtension.getPostgresExecutor())); } @Override public List getSupportedCapabilities() { - return ImmutableList.of(Capabilities.ANNOTATION, Capabilities.MAILBOX, Capabilities.MESSAGE, Capabilities.MOVE, Capabilities.ATTACHMENT, Capabilities.THREAD_SAFE_FLAGS_UPDATE); + return ImmutableList.of(Capabilities.ANNOTATION, Capabilities.MAILBOX, Capabilities.MESSAGE, Capabilities.MOVE, + Capabilities.ATTACHMENT, Capabilities.THREAD_SAFE_FLAGS_UPDATE, Capabilities.UNIQUE_MESSAGE_ID); } @Override @@ -91,7 +101,12 @@ public MessageMapper createMessageMapper() { @Override public MessageIdMapper createMessageIdMapper() { - throw new NotImplementedException("not implemented"); + PostgresMailboxDAO mailboxDAO = new PostgresMailboxDAO(postgresExtension.getPostgresExecutor()); + return new PostgresMessageIdMapper(mailboxDAO, + new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), blobIdFactory), + new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()), + new PostgresModSeqProvider(mailboxDAO), + blobStore, blobIdFactory, updatableTickingClock); } @Override @@ -105,18 +120,28 @@ public MailboxId generateId() { } @Override - public MessageUid generateMessageUid() { - throw new NotImplementedException("not implemented"); + public MessageUid generateMessageUid(Mailbox mailbox) { + try { + return messageUidProvider.nextUid(mailbox); + } catch (MailboxException e) { + throw new RuntimeException(e); + } } @Override public ModSeq generateModSeq(Mailbox mailbox) { - throw new NotImplementedException("not implemented"); + try { + return new PostgresModSeqProvider(new PostgresMailboxDAO(postgresExtension.getPostgresExecutor())) + .nextModSeq(mailbox); + } catch (MailboxException e) { + throw new RuntimeException(e); + } } @Override public ModSeq highestModSeq(Mailbox mailbox) { - throw new NotImplementedException("not implemented"); + return new PostgresModSeqProvider(new PostgresMailboxDAO(postgresExtension.getPostgresExecutor())) + .highestModSeq(mailbox); } @Override @@ -132,4 +157,4 @@ public MessageId generateMessageId() { public UpdatableTickingClock getUpdatableTickingClock() { return updatableTickingClock; } -} +} \ No newline at end of file diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapperTest.java new file mode 100644 index 00000000000..873e7b66332 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapperTest.java @@ -0,0 +1,45 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; +import org.apache.james.mailbox.store.mail.model.MapperProvider; +import org.apache.james.mailbox.store.mail.model.MessageIdMapperTest; +import org.apache.james.utils.UpdatableTickingClock; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresMessageIdMapperTest extends MessageIdMapperTest { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); + + private PostgresMapperProvider postgresMapperProvider; + + @Override + protected MapperProvider provideMapper() { + postgresMapperProvider = new PostgresMapperProvider(postgresExtension); + return postgresMapperProvider; + } + + @Override + protected UpdatableTickingClock updatableTickingClock() { + return postgresMapperProvider.getUpdatableTickingClock(); + } +} From ab533488ea67a5c96c129d4f4b8639aab991c579 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Mon, 15 Jan 2024 15:51:47 +0700 Subject: [PATCH 185/341] JAMES-2586 Introduce data-jmap-postgres module The module where we can implement Postgres storage layer for JMAP. --- server/data/data-jmap-postgres/pom.xml | 136 +++++++++++++++++++++++++ server/pom.xml | 1 + 2 files changed, 137 insertions(+) create mode 100644 server/data/data-jmap-postgres/pom.xml diff --git a/server/data/data-jmap-postgres/pom.xml b/server/data/data-jmap-postgres/pom.xml new file mode 100644 index 00000000000..aedca6cde26 --- /dev/null +++ b/server/data/data-jmap-postgres/pom.xml @@ -0,0 +1,136 @@ + + + + + 4.0.0 + + + org.apache.james + james-server + 3.9.0-SNAPSHOT + ../../pom.xml + + + james-server-data-jmap-postgres + jar + + Apache James :: Server :: Data :: JMAP :: PostgreSQL persistence + + + + ${james.groupId} + apache-james-backends-postgres + + + ${james.groupId} + apache-james-backends-postgres + test-jar + test + + + ${james.groupId} + apache-james-mailbox-api + test-jar + test + + + ${james.groupId} + apache-james-mailbox-postgres + + + ${james.groupId} + blob-memory + test + + + ${james.groupId} + blob-storage-strategy + test + + + ${james.groupId} + james-json + test-jar + test + + + ${james.groupId} + james-server-data-jmap + + + ${james.groupId} + james-server-data-jmap + test-jar + test + + + ${james.groupId} + james-server-testing + test + + + ${james.groupId} + metrics-tests + test + + + ${james.groupId} + testing-base + test + + + com.google.guava + guava + + + net.javacrumbs.json-unit + json-unit-assertj + test + + + org.awaitility + awaitility + test + + + org.testcontainers + postgresql + test + + + + + + + net.alchim31.maven + scala-maven-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + true + 2 + + + + + + diff --git a/server/pom.xml b/server/pom.xml index bd896caf5af..f8aeb96de43 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -68,6 +68,7 @@ data/data-file data/data-jmap data/data-jmap-cassandra + data/data-jmap-postgres data/data-jpa data/data-ldap data/data-library From 030e681eb4a02686fefefb0ffed938f2a584de25 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Mon, 15 Jan 2024 15:53:47 +0700 Subject: [PATCH 186/341] JAMES-2586 DeleteMessageListener: better concurrency control upon mailbox deletion --- .../apache/james/mailbox/postgres/DeleteMessageListener.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java index 590e57a2d96..f3c44dc5ff4 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java @@ -98,7 +98,8 @@ private Mono handleMailboxDeletion(MailboxDeletion event) { PostgresMailboxMessageDAO postgresMailboxMessageDAO = mailboxMessageDAOFactory.create(event.getUsername().getDomainPart()); return postgresMailboxMessageDAO.deleteByMailboxId((PostgresMailboxId) event.getMailboxId()) - .flatMap(msgId -> handleMessageDeletion(postgresMessageDAO, postgresMailboxMessageDAO, msgId, event.getMailboxId(), event.getMailboxPath().getUser())) + .flatMap(msgId -> handleMessageDeletion(postgresMessageDAO, postgresMailboxMessageDAO, msgId, event.getMailboxId(), event.getMailboxPath().getUser()), + LOW_CONCURRENCY) .then(); } From 978bb48f0ac57cad46f8ac4da142c2b74a244106 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Mon, 15 Jan 2024 16:23:15 +0700 Subject: [PATCH 187/341] JAMES-2586 Jenkinsfile: run tests for `server/data/data-jmap-postgres` module --- Jenkinsfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Jenkinsfile b/Jenkinsfile index 6178026cb62..abee6197ddf 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -42,6 +42,7 @@ pipeline { POSTGRES_MODULES = 'backends-common/postgres,' + 'mailbox/postgres,' + 'server/data/data-postgres,' + + 'server/data/data-jmap-postgres,' + 'server/container/guice/postgres-common,' + 'server/container/guice/mailbox-postgres,' + 'server/apps/postgres-app,' + From 9721856921a39c4a39435db5b72473299a99533b Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Fri, 12 Jan 2024 12:59:19 +0700 Subject: [PATCH 188/341] JAMES-2586 - CLEAN CODE - Guice binding for Postgres User Repository modules - Making it easier to install the binding when a user chooses this option. --- .../apache/james/PostgresJamesServerMain.java | 9 +++++-- .../data/PostgresDelegationStoreModule.java | 23 ---------------- .../data/PostgresUsersRepositoryModule.java | 26 +++++++++++++++++++ .../postgres/PostgresDelegationStore.java | 2 +- 4 files changed, 34 insertions(+), 26 deletions(-) diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index 76f6244de2c..bf4efd24f32 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -129,14 +129,19 @@ public static GuiceJamesServer createServer(PostgresJamesConfiguration configura return GuiceJamesServer.forConfiguration(configuration) .combineWith(SearchModuleChooser.chooseModules(searchConfiguration)) - .combineWith(new UsersRepositoryModuleChooser(new PostgresUsersRepositoryModule()) - .chooseModules(configuration.getUsersRepositoryImplementation())) + .combineWith(chooseUsersRepositoryModule(configuration)) .combineWith(chooseBlobStoreModules(configuration)) .combineWith(chooseEventBusModules(configuration)) .combineWith(chooseDeletedMessageVaultModules(configuration.getDeletedMessageVaultConfiguration())) .combineWith(POSTGRES_MODULE_AGGREGATE); } + private static List chooseUsersRepositoryModule(PostgresJamesConfiguration configuration) { + return List.of(PostgresUsersRepositoryModule.USER_CONFIGURATION_MODULE, + Modules.combine(new UsersRepositoryModuleChooser(new PostgresUsersRepositoryModule()) + .chooseModules(configuration.getUsersRepositoryImplementation()))); + } + private static List chooseBlobStoreModules(PostgresJamesConfiguration configuration) { ImmutableList.Builder builder = ImmutableList.builder() .addAll(BlobStoreModulesChooser.chooseModules(configuration.blobStoreConfiguration())) diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDelegationStoreModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDelegationStoreModule.java index f6e5521ead7..886b21c7386 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDelegationStoreModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDelegationStoreModule.java @@ -19,22 +19,12 @@ package org.apache.james.modules.data; -import org.apache.commons.configuration2.ex.ConfigurationException; -import org.apache.james.backends.postgres.PostgresModule; -import org.apache.james.server.core.configuration.ConfigurationProvider; import org.apache.james.user.api.DelegationStore; import org.apache.james.user.api.DelegationUsernameChangeTaskStep; import org.apache.james.user.api.UsernameChangeTaskStep; -import org.apache.james.user.lib.UsersDAO; import org.apache.james.user.postgres.PostgresDelegationStore; -import org.apache.james.user.postgres.PostgresUserModule; -import org.apache.james.user.postgres.PostgresUsersDAO; -import org.apache.james.user.postgres.PostgresUsersRepositoryConfiguration; import com.google.inject.AbstractModule; -import com.google.inject.Provides; -import com.google.inject.Scopes; -import com.google.inject.Singleton; import com.google.inject.multibindings.Multibinder; public class PostgresDelegationStoreModule extends AbstractModule { @@ -45,18 +35,5 @@ public void configure() { Multibinder.newSetBinder(binder(), UsernameChangeTaskStep.class) .addBinding().to(DelegationUsernameChangeTaskStep.class); - - bind(PostgresUsersDAO.class).in(Scopes.SINGLETON); - bind(UsersDAO.class).to(PostgresUsersDAO.class); - - Multibinder postgresDataDefinitions = Multibinder.newSetBinder(binder(), PostgresModule.class); - postgresDataDefinitions.addBinding().toInstance(PostgresUserModule.MODULE); - } - - @Provides - @Singleton - public PostgresUsersRepositoryConfiguration provideConfiguration(ConfigurationProvider configurationProvider) throws ConfigurationException { - return PostgresUsersRepositoryConfiguration.from( - configurationProvider.getConfiguration("usersrepository")); } } diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresUsersRepositoryModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresUsersRepositoryModule.java index ff30223bb8c..506258c5344 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresUsersRepositoryModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresUsersRepositoryModule.java @@ -19,21 +19,46 @@ package org.apache.james.modules.data; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.server.core.configuration.ConfigurationProvider; import org.apache.james.user.api.UsersRepository; +import org.apache.james.user.lib.UsersDAO; +import org.apache.james.user.postgres.PostgresUserModule; +import org.apache.james.user.postgres.PostgresUsersDAO; import org.apache.james.user.postgres.PostgresUsersRepository; +import org.apache.james.user.postgres.PostgresUsersRepositoryConfiguration; import org.apache.james.utils.InitializationOperation; import org.apache.james.utils.InitilizationOperationBuilder; import com.google.inject.AbstractModule; +import com.google.inject.Provides; import com.google.inject.Scopes; +import com.google.inject.Singleton; +import com.google.inject.multibindings.Multibinder; import com.google.inject.multibindings.ProvidesIntoSet; public class PostgresUsersRepositoryModule extends AbstractModule { + + public static AbstractModule USER_CONFIGURATION_MODULE = new AbstractModule() { + @Provides + @Singleton + public PostgresUsersRepositoryConfiguration provideConfiguration(ConfigurationProvider configurationProvider) throws ConfigurationException { + return PostgresUsersRepositoryConfiguration.from( + configurationProvider.getConfiguration("usersrepository")); + } + }; + @Override public void configure() { bind(PostgresUsersRepository.class).in(Scopes.SINGLETON); bind(UsersRepository.class).to(PostgresUsersRepository.class); + + bind(PostgresUsersDAO.class).in(Scopes.SINGLETON); + bind(UsersDAO.class).to(PostgresUsersDAO.class); + + Multibinder postgresDataDefinitions = Multibinder.newSetBinder(binder(), PostgresModule.class); + postgresDataDefinitions.addBinding().toInstance(PostgresUserModule.MODULE); } @ProvidesIntoSet @@ -42,4 +67,5 @@ InitializationOperation configureInitialization(ConfigurationProvider configurat .forClass(PostgresUsersRepository.class) .init(() -> usersRepository.configure(configurationProvider.getConfiguration("usersrepository"))); } + } diff --git a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresDelegationStore.java b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresDelegationStore.java index 4f04f450752..0eb4fe4a117 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresDelegationStore.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresDelegationStore.java @@ -47,7 +47,7 @@ public Mono exists(Username username) { } } - private PostgresUsersDAO postgresUsersDAO; + private final PostgresUsersDAO postgresUsersDAO; private final UserExistencePredicate userExistencePredicate; @Inject From 8a0df71577a99c55b1c37828dccf9831b7e21366 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 16 Jan 2024 15:38:38 +0700 Subject: [PATCH 189/341] JAMES-2586 Implement PostgresMessageFastViewProjection --- server/data/data-jmap-postgres/pom.xml | 6 + .../PostgresMessageFastViewProjection.java | 105 ++++++++++++++++++ ...stgresMessageFastViewProjectionModule.java | 56 ++++++++++ ...PostgresMessageFastViewProjectionTest.java | 62 +++++++++++ .../MessageFastViewProjection.java | 2 + 5 files changed, 231 insertions(+) create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresMessageFastViewProjection.java create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresMessageFastViewProjectionModule.java create mode 100644 server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/projections/PostgresMessageFastViewProjectionTest.java diff --git a/server/data/data-jmap-postgres/pom.xml b/server/data/data-jmap-postgres/pom.xml index aedca6cde26..c69be1f1074 100644 --- a/server/data/data-jmap-postgres/pom.xml +++ b/server/data/data-jmap-postgres/pom.xml @@ -80,6 +80,12 @@ test-jar test + + ${james.groupId} + james-server-guice-common + test-jar + test + ${james.groupId} james-server-testing diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresMessageFastViewProjection.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresMessageFastViewProjection.java new file mode 100644 index 00000000000..8e122be5281 --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresMessageFastViewProjection.java @@ -0,0 +1,105 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.projections; + +import static org.apache.james.jmap.postgres.projections.PostgresMessageFastViewProjectionModule.MessageFastViewProjectionTable.HAS_ATTACHMENT; +import static org.apache.james.jmap.postgres.projections.PostgresMessageFastViewProjectionModule.MessageFastViewProjectionTable.MESSAGE_ID; +import static org.apache.james.jmap.postgres.projections.PostgresMessageFastViewProjectionModule.MessageFastViewProjectionTable.PREVIEW; +import static org.apache.james.jmap.postgres.projections.PostgresMessageFastViewProjectionModule.MessageFastViewProjectionTable.TABLE_NAME; + +import javax.inject.Inject; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.jmap.api.model.Preview; +import org.apache.james.jmap.api.projections.MessageFastViewPrecomputedProperties; +import org.apache.james.jmap.api.projections.MessageFastViewProjection; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.metrics.api.Metric; +import org.apache.james.metrics.api.MetricFactory; +import org.jooq.Record; +import org.reactivestreams.Publisher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.common.base.Preconditions; + +import reactor.core.publisher.Mono; + +public class PostgresMessageFastViewProjection implements MessageFastViewProjection { + public static final Logger LOGGER = LoggerFactory.getLogger(PostgresMessageFastViewProjection.class); + + private final PostgresExecutor postgresExecutor; + private final Metric metricRetrieveHitCount; + private final Metric metricRetrieveMissCount; + + @Inject + public PostgresMessageFastViewProjection(PostgresExecutor postgresExecutor, MetricFactory metricFactory) { + this.postgresExecutor = postgresExecutor; + this.metricRetrieveHitCount = metricFactory.generate(METRIC_RETRIEVE_HIT_COUNT); + this.metricRetrieveMissCount = metricFactory.generate(METRIC_RETRIEVE_MISS_COUNT); + } + + @Override + public Publisher store(MessageId messageId, MessageFastViewPrecomputedProperties precomputedProperties) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME) + .set(MESSAGE_ID, ((PostgresMessageId) messageId).asUuid()) + .set(PREVIEW, precomputedProperties.getPreview().getValue()) + .set(HAS_ATTACHMENT, precomputedProperties.hasAttachment()) + .onConflict(MESSAGE_ID) + .doUpdate() + .set(PREVIEW, precomputedProperties.getPreview().getValue()) + .set(HAS_ATTACHMENT, precomputedProperties.hasAttachment()))); + } + + @Override + public Publisher retrieve(MessageId messageId) { + Preconditions.checkNotNull(messageId); + + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select(PREVIEW, HAS_ATTACHMENT) + .from(TABLE_NAME) + .where(MESSAGE_ID.eq(((PostgresMessageId) messageId).asUuid())))) + .doOnNext(preview -> metricRetrieveHitCount.increment()) + .switchIfEmpty(Mono.fromRunnable(metricRetrieveMissCount::increment)) + .map(this::toMessageFastViewPrecomputedProperties) + .onErrorResume(e -> { + LOGGER.error("Error while retrieving MessageFastView projection item for {}", messageId, e); + return Mono.empty(); + }); + } + + private MessageFastViewPrecomputedProperties toMessageFastViewPrecomputedProperties(Record record) { + return MessageFastViewPrecomputedProperties.builder() + .preview(Preview.from(record.get(PREVIEW))) + .hasAttachment(record.get(HAS_ATTACHMENT)) + .build(); + } + + @Override + public Publisher delete(MessageId messageId) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(TABLE_NAME) + .where(MESSAGE_ID.eq(((PostgresMessageId) messageId).asUuid())))); + } + + @Override + public Publisher clear() { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.dropTableIfExists(TABLE_NAME))); + } +} diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresMessageFastViewProjectionModule.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresMessageFastViewProjectionModule.java new file mode 100644 index 00000000000..ef1e0cb885d --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresMessageFastViewProjectionModule.java @@ -0,0 +1,56 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.projections; + +import static org.apache.james.jmap.postgres.projections.PostgresMessageFastViewProjectionModule.MessageFastViewProjectionTable.TABLE; + +import java.util.UUID; + +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresMessageFastViewProjectionModule { + interface MessageFastViewProjectionTable { + Table TABLE_NAME = DSL.table("message_fast_view_projection"); + + Field MESSAGE_ID = DSL.field("messageId", SQLDataType.UUID.notNull()); + Field PREVIEW = DSL.field("preview", SQLDataType.VARCHAR.notNull()); + Field HAS_ATTACHMENT = DSL.field("has_attachment", SQLDataType.BOOLEAN.notNull()); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(MESSAGE_ID) + .column(PREVIEW) + .column(HAS_ATTACHMENT) + .primaryKey(MESSAGE_ID) + .comment("Storing the JMAP projections for MessageFastView, an aggregation of JMAP properties expected to be fast to fetch."))) + .disableRowLevelSecurity() + .build(); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(TABLE) + .build(); +} diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/projections/PostgresMessageFastViewProjectionTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/projections/PostgresMessageFastViewProjectionTest.java new file mode 100644 index 00000000000..80fd09de74b --- /dev/null +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/projections/PostgresMessageFastViewProjectionTest.java @@ -0,0 +1,62 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.projections; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.jmap.api.projections.MessageFastViewProjection; +import org.apache.james.jmap.api.projections.MessageFastViewProjectionContract; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.metrics.tests.RecordingMetricFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; + +class PostgresMessageFastViewProjectionTest implements MessageFastViewProjectionContract { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity( + PostgresModule.aggregateModules(PostgresMessageFastViewProjectionModule.MODULE)); + + private PostgresMessageFastViewProjection testee; + private PostgresMessageId.Factory postgresMessageIdFactory; + private RecordingMetricFactory metricFactory; + + @BeforeEach + void setUp() { + metricFactory = new RecordingMetricFactory(); + postgresMessageIdFactory = new PostgresMessageId.Factory(); + testee = new PostgresMessageFastViewProjection(postgresExtension.getPostgresExecutor(), metricFactory); + } + + @Override + public MessageFastViewProjection testee() { + return testee; + } + + @Override + public MessageId newMessageId() { + return postgresMessageIdFactory.generate(); + } + + @Override + public RecordingMetricFactory metricFactory() { + return metricFactory; + } +} \ No newline at end of file diff --git a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/projections/MessageFastViewProjection.java b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/projections/MessageFastViewProjection.java index a0c54e5ad05..c5f0ba15971 100644 --- a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/projections/MessageFastViewProjection.java +++ b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/projections/MessageFastViewProjection.java @@ -28,6 +28,7 @@ import org.apache.james.mailbox.model.MessageId; import org.reactivestreams.Publisher; +import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import reactor.core.publisher.Flux; @@ -45,6 +46,7 @@ public interface MessageFastViewProjection { Publisher delete(MessageId messageId); + @VisibleForTesting Publisher clear(); default Publisher> retrieve(Collection messageIds) { From b05a5e4c5cc9651e1a847f8fdeaf239e5f7dec8c Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Wed, 17 Jan 2024 16:22:57 +0700 Subject: [PATCH 190/341] JAMES-2586 Moving Managers out of the mail package Matching logic with tests package location and cassandra mailbox module --- .../mailbox/postgres/{mail => }/PostgresMailboxManager.java | 3 +-- .../mailbox/postgres/{mail => }/PostgresMessageManager.java | 3 ++- .../james/mailbox/postgres/DeleteMessageListenerContract.java | 1 - .../james/mailbox/postgres/DeleteMessageListenerTest.java | 1 - .../mailbox/postgres/DeleteMessageListenerWithRLSTest.java | 1 - .../james/mailbox/postgres/PostgresMailboxManagerProvider.java | 1 - .../mailbox/postgres/PostgresMailboxManagerStressTest.java | 1 - .../james/mailbox/postgres/PostgresMailboxManagerTest.java | 1 - .../mpt/imapmailbox/postgres/host/PostgresHostSystem.java | 2 +- .../apache/james/modules/mailbox/PostgresMailboxModule.java | 2 +- 10 files changed, 5 insertions(+), 11 deletions(-) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/{mail => }/PostgresMailboxManager.java (97%) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/{mail => }/PostgresMessageManager.java (98%) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxManager.java similarity index 97% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxManager.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxManager.java index 0f25e6bc081..5e0cf653256 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxManager.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxManager.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.postgres.mail; +package org.apache.james.mailbox.postgres; import java.time.Clock; import java.util.EnumSet; @@ -29,7 +29,6 @@ import org.apache.james.mailbox.SessionProvider; import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MessageId; -import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; import org.apache.james.mailbox.store.MailboxManagerConfiguration; import org.apache.james.mailbox.store.NoMailboxPathLocker; import org.apache.james.mailbox.store.PreDeletionHooks; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMessageManager.java similarity index 98% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageManager.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMessageManager.java index 4bf0c237bd0..b7d15fcdceb 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageManager.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMessageManager.java @@ -17,7 +17,7 @@ * under the License. * ****************************************************************/ -package org.apache.james.mailbox.postgres.mail; +package org.apache.james.mailbox.postgres; import java.time.Clock; import java.util.EnumSet; @@ -35,6 +35,7 @@ import org.apache.james.mailbox.model.MailboxACL; import org.apache.james.mailbox.model.MailboxCounters; import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.postgres.mail.PostgresMailbox; import org.apache.james.mailbox.quota.QuotaManager; import org.apache.james.mailbox.quota.QuotaRootResolver; import org.apache.james.mailbox.store.BatchSizes; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerContract.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerContract.java index 2ebb80f843c..5555c5c26ad 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerContract.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerContract.java @@ -29,7 +29,6 @@ import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.MailboxPath; import org.apache.james.mailbox.model.MessageRange; -import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.util.ClassLoaderUtils; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java index 7e93f82be6f..2e1a14ea16f 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java @@ -22,7 +22,6 @@ import static org.apache.james.mailbox.postgres.PostgresMailboxManagerProvider.BLOB_ID_FACTORY; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.store.PreDeletionHooks; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java index 3d76c756867..822a454bfa4 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java @@ -25,7 +25,6 @@ import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.core.Username; -import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.store.PreDeletionHooks; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java index 99377923daf..f8c520abf48 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java @@ -35,7 +35,6 @@ import org.apache.james.mailbox.Authorizator; import org.apache.james.mailbox.acl.MailboxACLResolver; import org.apache.james.mailbox.acl.UnionMailboxACLResolver; -import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.store.MailboxSessionMapperFactory; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerStressTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerStressTest.java index 036d08f079b..46dd5731757 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerStressTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerStressTest.java @@ -24,7 +24,6 @@ import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.events.EventBus; import org.apache.james.mailbox.MailboxManagerStressContract; -import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; import org.apache.james.mailbox.store.PreDeletionHooks; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.RegisterExtension; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerTest.java index 320e5c8d252..f7d3436214f 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerTest.java @@ -24,7 +24,6 @@ import org.apache.james.events.EventBus; import org.apache.james.mailbox.MailboxManagerTest; import org.apache.james.mailbox.SubscriptionManager; -import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; import org.apache.james.mailbox.store.PreDeletionHooks; import org.apache.james.mailbox.store.StoreSubscriptionManager; import org.apache.james.metrics.tests.RecordingMetricFactory; diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java index 3bdb05e1cee..8882526eaaf 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java @@ -46,7 +46,7 @@ import org.apache.james.mailbox.acl.UnionMailboxACLResolver; import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; import org.apache.james.mailbox.postgres.PostgresMessageId; -import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; +import org.apache.james.mailbox.postgres.PostgresMailboxManager; import org.apache.james.mailbox.postgres.quota.PostgresCurrentQuotaManager; import org.apache.james.mailbox.postgres.quota.PostgresPerUserMaxQuotaManager; import org.apache.james.mailbox.quota.CurrentQuotaManager; diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java index b1a955f6ac5..80d18bd5177 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java @@ -47,9 +47,9 @@ import org.apache.james.mailbox.postgres.PostgresAttachmentContentLoader; import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.PostgresMailboxManager; import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; import org.apache.james.mailbox.postgres.PostgresMessageId; -import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; import org.apache.james.mailbox.postgres.mail.PostgresMessageBlobReferenceSource; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.store.MailboxManagerConfiguration; From a0e8ab0f9f0a5c0c0adf22421dd47aeee2b51f29 Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Wed, 17 Jan 2024 16:27:08 +0700 Subject: [PATCH 191/341] JAMES-2586 Wire StoreMessageIdManager on top of the PostgresMessageIdMapper + tests --- .../PostgresMailboxSessionMapperFactory.java | 9 ++ .../PostgresCombinationManagerTest.java | 42 +++++++ .../PostgresCombinationManagerTestSystem.java | 66 ++++++++++ .../PostgresMessageIdManagerQuotaTest.java | 58 +++++++++ ...ostgresMessageIdManagerSideEffectTest.java | 40 +++++++ .../PostgresMessageIdManagerStorageTest.java | 43 +++++++ .../PostgresMessageIdManagerTestSystem.java | 60 ++++++++++ .../postgres/PostgresTestSystemFixture.java | 113 ++++++++++++++++++ .../mailbox/PostgresMailboxModule.java | 8 ++ 9 files changed, 439 insertions(+) create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresCombinationManagerTest.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresCombinationManagerTestSystem.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMessageIdManagerQuotaTest.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMessageIdManagerSideEffectTest.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMessageIdManagerStorageTest.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMessageIdManagerTestSystem.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresTestSystemFixture.java diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java index 7f54b435016..93b17a14862 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java @@ -48,6 +48,8 @@ import org.apache.james.mailbox.store.mail.MessageMapper; import org.apache.james.mailbox.store.user.SubscriptionMapper; +import com.google.common.collect.ImmutableSet; + public class PostgresMailboxSessionMapperFactory extends MailboxSessionMapperFactory implements AttachmentMapperFactory { private final PostgresExecutor.Factory executorFactory; @@ -122,4 +124,11 @@ public AttachmentMapper createAttachmentMapper(MailboxSession session) { public AttachmentMapper getAttachmentMapper(MailboxSession session) { throw new NotImplementedException("not implemented"); } + + public DeleteMessageListener deleteMessageListener() { + PostgresMessageDAO.Factory postgresMessageDAOFactory = new PostgresMessageDAO.Factory(blobIdFactory, executorFactory); + PostgresMailboxMessageDAO.Factory postgresMailboxMessageDAOFactory = new PostgresMailboxMessageDAO.Factory(executorFactory); + + return new DeleteMessageListener(blobStore, postgresMailboxMessageDAOFactory, postgresMessageDAOFactory, ImmutableSet.of()); + } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresCombinationManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresCombinationManagerTest.java new file mode 100644 index 00000000000..b2bf09c39c1 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresCombinationManagerTest.java @@ -0,0 +1,42 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.events.EventBusTestFixture; +import org.apache.james.events.InVMEventBus; +import org.apache.james.events.MemoryEventDeadLetters; +import org.apache.james.events.delivery.InVmEventDelivery; +import org.apache.james.mailbox.store.AbstractCombinationManagerTest; +import org.apache.james.mailbox.store.CombinationManagerTestSystem; +import org.apache.james.mailbox.store.quota.NoQuotaManager; +import org.apache.james.metrics.tests.RecordingMetricFactory; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresCombinationManagerTest extends AbstractCombinationManagerTest { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); + + @Override + public CombinationManagerTestSystem createTestingData() { + InVMEventBus eventBus = new InVMEventBus(new InVmEventDelivery(new RecordingMetricFactory()), EventBusTestFixture.RETRY_BACKOFF_CONFIGURATION, new MemoryEventDeadLetters()); + return PostgresCombinationManagerTestSystem.createTestingData(postgresExtension, new NoQuotaManager(), eventBus); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresCombinationManagerTestSystem.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresCombinationManagerTestSystem.java new file mode 100644 index 00000000000..d0421c9c4f2 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresCombinationManagerTestSystem.java @@ -0,0 +1,66 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.events.EventBus; +import org.apache.james.mailbox.MailboxManager; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.MessageIdManager; +import org.apache.james.mailbox.MessageManager; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.exception.MailboxNotFoundException; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.quota.QuotaManager; +import org.apache.james.mailbox.store.CombinationManagerTestSystem; +import org.apache.james.mailbox.store.PreDeletionHooks; + +public class PostgresCombinationManagerTestSystem extends CombinationManagerTestSystem { + private final PostgresMailboxSessionMapperFactory mapperFactory; + private final PostgresMailboxManager postgresMailboxManager; + + public static CombinationManagerTestSystem createTestingData(PostgresExtension postgresExtension, QuotaManager quotaManager, EventBus eventBus) { + PostgresMailboxSessionMapperFactory mapperFactory = PostgresTestSystemFixture.createMapperFactory(postgresExtension); + + return new PostgresCombinationManagerTestSystem(PostgresTestSystemFixture.createMessageIdManager(mapperFactory, quotaManager, eventBus, PreDeletionHooks.NO_PRE_DELETION_HOOK), + mapperFactory, + PostgresTestSystemFixture.createMailboxManager(mapperFactory)); + } + + private PostgresCombinationManagerTestSystem(MessageIdManager messageIdManager, PostgresMailboxSessionMapperFactory mapperFactory, MailboxManager postgresMailboxManager) { + super(postgresMailboxManager, messageIdManager); + this.mapperFactory = mapperFactory; + this.postgresMailboxManager = (PostgresMailboxManager) postgresMailboxManager; + } + + @Override + public Mailbox createMailbox(MailboxPath mailboxPath, MailboxSession session) throws MailboxException { + postgresMailboxManager.createMailbox(mailboxPath, session); + return mapperFactory.getMailboxMapper(session).findMailboxByPath(mailboxPath) + .blockOptional() + .orElseThrow(() -> new MailboxNotFoundException(mailboxPath)); + } + + @Override + public MessageManager createMessageManager(Mailbox mailbox, MailboxSession session) { + return postgresMailboxManager.createMessageManager(mailbox, session); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMessageIdManagerQuotaTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMessageIdManagerQuotaTest.java new file mode 100644 index 00000000000..40bb250da09 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMessageIdManagerQuotaTest.java @@ -0,0 +1,58 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.quota.PostgresQuotaModule; +import org.apache.james.mailbox.quota.CurrentQuotaManager; +import org.apache.james.mailbox.quota.MaxQuotaManager; +import org.apache.james.mailbox.quota.QuotaManager; +import org.apache.james.mailbox.store.AbstractMessageIdManagerQuotaTest; +import org.apache.james.mailbox.store.MessageIdManagerTestSystem; +import org.apache.james.mailbox.store.quota.StoreQuotaManager; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresMessageIdManagerQuotaTest extends AbstractMessageIdManagerQuotaTest { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresModule.aggregateModules( + PostgresMailboxAggregateModule.MODULE, + PostgresQuotaModule.MODULE)); + + @Override + protected MessageIdManagerTestSystem createTestSystem(QuotaManager quotaManager, CurrentQuotaManager currentQuotaManager) throws Exception { + return PostgresMessageIdManagerTestSystem.createTestingDataWithQuota(postgresExtension, quotaManager, currentQuotaManager); + } + + @Override + protected MaxQuotaManager createMaxQuotaManager() { + return PostgresTestSystemFixture.createMaxQuotaManager(postgresExtension); + } + + @Override + protected QuotaManager createQuotaManager(MaxQuotaManager maxQuotaManager, CurrentQuotaManager currentQuotaManager) { + return new StoreQuotaManager(currentQuotaManager, maxQuotaManager); + } + + @Override + protected CurrentQuotaManager createCurrentQuotaManager() { + return PostgresTestSystemFixture.createCurrentQuotaManager(postgresExtension); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMessageIdManagerSideEffectTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMessageIdManagerSideEffectTest.java new file mode 100644 index 00000000000..35824217c7b --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMessageIdManagerSideEffectTest.java @@ -0,0 +1,40 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres; + +import java.util.Set; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.events.EventBus; +import org.apache.james.mailbox.extension.PreDeletionHook; +import org.apache.james.mailbox.quota.QuotaManager; +import org.apache.james.mailbox.store.AbstractMessageIdManagerSideEffectTest; +import org.apache.james.mailbox.store.MessageIdManagerTestSystem; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresMessageIdManagerSideEffectTest extends AbstractMessageIdManagerSideEffectTest { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); + + @Override + protected MessageIdManagerTestSystem createTestSystem(QuotaManager quotaManager, EventBus eventBus, Set preDeletionHooks) { + return PostgresMessageIdManagerTestSystem.createTestingData(postgresExtension, quotaManager, eventBus, preDeletionHooks); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMessageIdManagerStorageTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMessageIdManagerStorageTest.java new file mode 100644 index 00000000000..180a3780393 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMessageIdManagerStorageTest.java @@ -0,0 +1,43 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.events.EventBusTestFixture; +import org.apache.james.events.InVMEventBus; +import org.apache.james.events.MemoryEventDeadLetters; +import org.apache.james.events.delivery.InVmEventDelivery; +import org.apache.james.mailbox.extension.PreDeletionHook; +import org.apache.james.mailbox.store.AbstractMessageIdManagerStorageTest; +import org.apache.james.mailbox.store.MessageIdManagerTestSystem; +import org.apache.james.mailbox.store.quota.NoQuotaManager; +import org.apache.james.metrics.tests.RecordingMetricFactory; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresMessageIdManagerStorageTest extends AbstractMessageIdManagerStorageTest { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); + + @Override + protected MessageIdManagerTestSystem createTestingData() { + InVMEventBus eventBus = new InVMEventBus(new InVmEventDelivery(new RecordingMetricFactory()), EventBusTestFixture.RETRY_BACKOFF_CONFIGURATION, new MemoryEventDeadLetters()); + return PostgresMessageIdManagerTestSystem.createTestingData(postgresExtension, new NoQuotaManager(), eventBus, PreDeletionHook.NO_PRE_DELETION_HOOK); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMessageIdManagerTestSystem.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMessageIdManagerTestSystem.java new file mode 100644 index 00000000000..1e04f94ce0a --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMessageIdManagerTestSystem.java @@ -0,0 +1,60 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres; + +import java.util.Set; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.events.EventBus; +import org.apache.james.mailbox.extension.PreDeletionHook; +import org.apache.james.mailbox.quota.CurrentQuotaManager; +import org.apache.james.mailbox.quota.QuotaManager; +import org.apache.james.mailbox.store.MessageIdManagerTestSystem; +import org.apache.james.mailbox.store.PreDeletionHooks; +import org.apache.james.mailbox.store.quota.ListeningCurrentQuotaUpdater; +import org.apache.james.metrics.tests.RecordingMetricFactory; + +public class PostgresMessageIdManagerTestSystem { + static MessageIdManagerTestSystem createTestingData(PostgresExtension postgresExtension, QuotaManager quotaManager, EventBus eventBus, + Set preDeletionHooks) { + PostgresMailboxSessionMapperFactory mapperFactory = PostgresTestSystemFixture.createMapperFactory(postgresExtension); + + return new MessageIdManagerTestSystem(PostgresTestSystemFixture.createMessageIdManager(mapperFactory, quotaManager, eventBus, new PreDeletionHooks(preDeletionHooks, new RecordingMetricFactory())), + new PostgresMessageId.Factory(), + mapperFactory, + PostgresTestSystemFixture.createMailboxManager(mapperFactory)) { + }; + } + + static MessageIdManagerTestSystem createTestingDataWithQuota(PostgresExtension postgresExtension, QuotaManager quotaManager, CurrentQuotaManager currentQuotaManager) { + PostgresMailboxSessionMapperFactory mapperFactory = PostgresTestSystemFixture.createMapperFactory(postgresExtension); + + PostgresMailboxManager mailboxManager = PostgresTestSystemFixture.createMailboxManager(mapperFactory); + ListeningCurrentQuotaUpdater listeningCurrentQuotaUpdater = new ListeningCurrentQuotaUpdater( + currentQuotaManager, + mailboxManager.getQuotaComponents().getQuotaRootResolver(), mailboxManager.getEventBus(), quotaManager); + mailboxManager.getEventBus().register(listeningCurrentQuotaUpdater); + return new MessageIdManagerTestSystem(PostgresTestSystemFixture.createMessageIdManager(mapperFactory, quotaManager, mailboxManager.getEventBus(), + PreDeletionHooks.NO_PRE_DELETION_HOOK), + new PostgresMessageId.Factory(), + mapperFactory, + mailboxManager); + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresTestSystemFixture.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresTestSystemFixture.java new file mode 100644 index 00000000000..3d954ffb649 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresTestSystemFixture.java @@ -0,0 +1,113 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres; + +import static org.mockito.Mockito.mock; + +import java.time.Clock; +import java.time.Instant; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.quota.PostgresQuotaCurrentValueDAO; +import org.apache.james.backends.postgres.quota.PostgresQuotaLimitDAO; +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BucketName; +import org.apache.james.blob.api.HashBlobId; +import org.apache.james.blob.memory.MemoryBlobStoreDAO; +import org.apache.james.events.EventBus; +import org.apache.james.events.EventBusTestFixture; +import org.apache.james.events.InVMEventBus; +import org.apache.james.events.MemoryEventDeadLetters; +import org.apache.james.events.delivery.InVmEventDelivery; +import org.apache.james.mailbox.AttachmentContentLoader; +import org.apache.james.mailbox.Authenticator; +import org.apache.james.mailbox.Authorizator; +import org.apache.james.mailbox.acl.UnionMailboxACLResolver; +import org.apache.james.mailbox.postgres.quota.PostgresCurrentQuotaManager; +import org.apache.james.mailbox.postgres.quota.PostgresPerUserMaxQuotaManager; +import org.apache.james.mailbox.quota.CurrentQuotaManager; +import org.apache.james.mailbox.quota.MaxQuotaManager; +import org.apache.james.mailbox.quota.QuotaManager; +import org.apache.james.mailbox.store.PreDeletionHooks; +import org.apache.james.mailbox.store.SessionProviderImpl; +import org.apache.james.mailbox.store.StoreMailboxAnnotationManager; +import org.apache.james.mailbox.store.StoreMessageIdManager; +import org.apache.james.mailbox.store.StoreRightManager; +import org.apache.james.mailbox.store.event.MailboxAnnotationListener; +import org.apache.james.mailbox.store.extractor.DefaultTextExtractor; +import org.apache.james.mailbox.store.mail.NaiveThreadIdGuessingAlgorithm; +import org.apache.james.mailbox.store.mail.model.impl.MessageParser; +import org.apache.james.mailbox.store.quota.DefaultUserQuotaRootResolver; +import org.apache.james.mailbox.store.quota.QuotaComponents; +import org.apache.james.mailbox.store.search.MessageSearchIndex; +import org.apache.james.mailbox.store.search.SimpleMessageSearchIndex; +import org.apache.james.metrics.tests.RecordingMetricFactory; +import org.apache.james.server.blob.deduplication.DeDuplicationBlobStore; +import org.apache.james.utils.UpdatableTickingClock; + +public class PostgresTestSystemFixture { + public static PostgresMailboxSessionMapperFactory createMapperFactory(PostgresExtension postgresExtension) { + BlobId.Factory blobIdFactory = new HashBlobId.Factory(); + DeDuplicationBlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory); + + return new PostgresMailboxSessionMapperFactory(postgresExtension.getExecutorFactory(), Clock.systemUTC(), blobStore, blobIdFactory); + } + + public static PostgresMailboxManager createMailboxManager(PostgresMailboxSessionMapperFactory mapperFactory) { + InVMEventBus eventBus = new InVMEventBus(new InVmEventDelivery(new RecordingMetricFactory()), EventBusTestFixture.RETRY_BACKOFF_CONFIGURATION, new MemoryEventDeadLetters()); + StoreRightManager storeRightManager = new StoreRightManager(mapperFactory, new UnionMailboxACLResolver(), eventBus); + StoreMailboxAnnotationManager annotationManager = new StoreMailboxAnnotationManager(mapperFactory, storeRightManager); + + SessionProviderImpl sessionProvider = new SessionProviderImpl(mock(Authenticator.class), mock(Authorizator.class)); + + QuotaComponents quotaComponents = QuotaComponents.disabled(sessionProvider, mapperFactory); + AttachmentContentLoader attachmentContentLoader = null; + MessageSearchIndex index = new SimpleMessageSearchIndex(mapperFactory, mapperFactory, new DefaultTextExtractor(), attachmentContentLoader); + PostgresMailboxManager postgresMailboxManager = new PostgresMailboxManager(mapperFactory, sessionProvider, + new MessageParser(), new PostgresMessageId.Factory(), + eventBus, annotationManager, storeRightManager, quotaComponents, index, new NaiveThreadIdGuessingAlgorithm(), PreDeletionHooks.NO_PRE_DELETION_HOOK, + new UpdatableTickingClock(Instant.now())); + + eventBus.register(new MailboxAnnotationListener(mapperFactory, sessionProvider)); + eventBus.register(mapperFactory.deleteMessageListener()); + + return postgresMailboxManager; + } + + static StoreMessageIdManager createMessageIdManager(PostgresMailboxSessionMapperFactory mapperFactory, QuotaManager quotaManager, EventBus eventBus, + PreDeletionHooks preDeletionHooks) { + PostgresMailboxManager mailboxManager = createMailboxManager(mapperFactory); + return new StoreMessageIdManager( + mailboxManager, + mapperFactory, + eventBus, + quotaManager, + new DefaultUserQuotaRootResolver(mailboxManager.getSessionProvider(), mapperFactory), + preDeletionHooks); + } + + static MaxQuotaManager createMaxQuotaManager(PostgresExtension postgresExtension) { + return new PostgresPerUserMaxQuotaManager(new PostgresQuotaLimitDAO(postgresExtension.getPostgresExecutor())); + } + + public static CurrentQuotaManager createCurrentQuotaManager(PostgresExtension postgresExtension) { + return new PostgresCurrentQuotaManager(new PostgresQuotaCurrentValueDAO(postgresExtension.getPostgresExecutor())); + } +} diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java index 80d18bd5177..9886cdbbe97 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java @@ -36,6 +36,8 @@ import org.apache.james.mailbox.Authorizator; import org.apache.james.mailbox.MailboxManager; import org.apache.james.mailbox.MailboxPathLocker; +import org.apache.james.mailbox.MessageIdManager; +import org.apache.james.mailbox.RightManager; import org.apache.james.mailbox.SessionProvider; import org.apache.james.mailbox.SubscriptionManager; import org.apache.james.mailbox.acl.MailboxACLResolver; @@ -57,6 +59,8 @@ import org.apache.james.mailbox.store.NoMailboxPathLocker; import org.apache.james.mailbox.store.SessionProviderImpl; import org.apache.james.mailbox.store.StoreMailboxManager; +import org.apache.james.mailbox.store.StoreMessageIdManager; +import org.apache.james.mailbox.store.StoreRightManager; import org.apache.james.mailbox.store.StoreSubscriptionManager; import org.apache.james.mailbox.store.event.MailboxAnnotationListener; import org.apache.james.mailbox.store.event.MailboxSubscriptionListener; @@ -99,6 +103,8 @@ protected void configure() { bind(NaiveThreadIdGuessingAlgorithm.class).in(Scopes.SINGLETON); bind(ReIndexerImpl.class).in(Scopes.SINGLETON); bind(SessionProviderImpl.class).in(Scopes.SINGLETON); + bind(StoreMessageIdManager.class).in(Scopes.SINGLETON); + bind(StoreRightManager.class).in(Scopes.SINGLETON); bind(SubscriptionMapperFactory.class).to(PostgresMailboxSessionMapperFactory.class); bind(MessageMapperFactory.class).to(PostgresMailboxSessionMapperFactory.class); @@ -117,6 +123,8 @@ protected void configure() { bind(MailboxId.Factory.class).to(PostgresMailboxId.Factory.class); bind(MailboxACLResolver.class).to(UnionMailboxACLResolver.class); bind(AttachmentContentLoader.class).to(PostgresAttachmentContentLoader.class); + bind(MessageIdManager.class).to(StoreMessageIdManager.class); + bind(RightManager.class).to(StoreRightManager.class); bind(ReIndexer.class).to(ReIndexerImpl.class); From d4972212e61ed152be2bf73f2e1c639e8d36eec1 Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Thu, 18 Jan 2024 11:24:57 +0700 Subject: [PATCH 192/341] JAMES-2586 Little refactoring around DeleteMessageListener binding in posgres mailbox tests --- .../PostgresMailboxSessionMapperFactory.java | 2 +- .../PostgresMailboxManagerProvider.java | 27 +++++++------------ 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java index 93b17a14862..26de4e4a88c 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java @@ -125,7 +125,7 @@ public AttachmentMapper getAttachmentMapper(MailboxSession session) { throw new NotImplementedException("not implemented"); } - public DeleteMessageListener deleteMessageListener() { + protected DeleteMessageListener deleteMessageListener() { PostgresMessageDAO.Factory postgresMessageDAOFactory = new PostgresMessageDAO.Factory(blobIdFactory, executorFactory); PostgresMailboxMessageDAO.Factory postgresMailboxMessageDAOFactory = new PostgresMailboxMessageDAO.Factory(executorFactory); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java index f8c520abf48..20507d26498 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java @@ -35,9 +35,6 @@ import org.apache.james.mailbox.Authorizator; import org.apache.james.mailbox.acl.MailboxACLResolver; import org.apache.james.mailbox.acl.UnionMailboxACLResolver; -import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; -import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; -import org.apache.james.mailbox.store.MailboxSessionMapperFactory; import org.apache.james.mailbox.store.PreDeletionHooks; import org.apache.james.mailbox.store.SessionProviderImpl; import org.apache.james.mailbox.store.StoreMailboxAnnotationManager; @@ -52,8 +49,6 @@ import org.apache.james.server.blob.deduplication.DeDuplicationBlobStore; import org.apache.james.utils.UpdatableTickingClock; -import com.google.common.collect.ImmutableSet; - public class PostgresMailboxManagerProvider { private static final int LIMIT_ANNOTATIONS = 3; @@ -63,7 +58,7 @@ public class PostgresMailboxManagerProvider { public static PostgresMailboxManager provideMailboxManager(PostgresExtension postgresExtension, PreDeletionHooks preDeletionHooks) { DeDuplicationBlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, BLOB_ID_FACTORY); - MailboxSessionMapperFactory mf = provideMailboxSessionMapperFactory(postgresExtension, BLOB_ID_FACTORY, blobStore); + PostgresMailboxSessionMapperFactory mapperFactory = provideMailboxSessionMapperFactory(postgresExtension, BLOB_ID_FACTORY, blobStore); MailboxACLResolver aclResolver = new UnionMailboxACLResolver(); MessageParser messageParser = new MessageParser(); @@ -72,34 +67,30 @@ public static PostgresMailboxManager provideMailboxManager(PostgresExtension pos Authorizator noAuthorizator = null; InVMEventBus eventBus = new InVMEventBus(new InVmEventDelivery(new RecordingMetricFactory()), EventBusTestFixture.RETRY_BACKOFF_CONFIGURATION, new MemoryEventDeadLetters()); - StoreRightManager storeRightManager = new StoreRightManager(mf, aclResolver, eventBus); - StoreMailboxAnnotationManager annotationManager = new StoreMailboxAnnotationManager(mf, storeRightManager, + StoreRightManager storeRightManager = new StoreRightManager(mapperFactory, aclResolver, eventBus); + StoreMailboxAnnotationManager annotationManager = new StoreMailboxAnnotationManager(mapperFactory, storeRightManager, LIMIT_ANNOTATIONS, LIMIT_ANNOTATION_SIZE); SessionProviderImpl sessionProvider = new SessionProviderImpl(noAuthenticator, noAuthorizator); - QuotaComponents quotaComponents = QuotaComponents.disabled(sessionProvider, mf); - MessageSearchIndex index = new SimpleMessageSearchIndex(mf, mf, new DefaultTextExtractor(), new PostgresAttachmentContentLoader()); - - PostgresMessageDAO.Factory postgresMessageDAOFactory = new PostgresMessageDAO.Factory(BLOB_ID_FACTORY, postgresExtension.getExecutorFactory()); - PostgresMailboxMessageDAO.Factory postgresMailboxMessageDAOFactory = new PostgresMailboxMessageDAO.Factory(postgresExtension.getExecutorFactory()); + QuotaComponents quotaComponents = QuotaComponents.disabled(sessionProvider, mapperFactory); + MessageSearchIndex index = new SimpleMessageSearchIndex(mapperFactory, mapperFactory, new DefaultTextExtractor(), new PostgresAttachmentContentLoader()); - eventBus.register(new DeleteMessageListener(blobStore, postgresMailboxMessageDAOFactory, postgresMessageDAOFactory, - ImmutableSet.of())); + eventBus.register(mapperFactory.deleteMessageListener()); - return new PostgresMailboxManager((PostgresMailboxSessionMapperFactory) mf, sessionProvider, + return new PostgresMailboxManager(mapperFactory, sessionProvider, messageParser, new PostgresMessageId.Factory(), eventBus, annotationManager, storeRightManager, quotaComponents, index, new NaiveThreadIdGuessingAlgorithm(), preDeletionHooks, new UpdatableTickingClock(Instant.now())); } - public static MailboxSessionMapperFactory provideMailboxSessionMapperFactory(PostgresExtension postgresExtension) { + public static PostgresMailboxSessionMapperFactory provideMailboxSessionMapperFactory(PostgresExtension postgresExtension) { BlobId.Factory blobIdFactory = new HashBlobId.Factory(); DeDuplicationBlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory); return provideMailboxSessionMapperFactory(postgresExtension, blobIdFactory, blobStore); } - public static MailboxSessionMapperFactory provideMailboxSessionMapperFactory(PostgresExtension postgresExtension, + public static PostgresMailboxSessionMapperFactory provideMailboxSessionMapperFactory(PostgresExtension postgresExtension, BlobId.Factory blobIdFactory, DeDuplicationBlobStore blobStore) { return new PostgresMailboxSessionMapperFactory( From 252a977a0a74666356e1b79f9244131b54e22a74 Mon Sep 17 00:00:00 2001 From: hung phan Date: Tue, 16 Jan 2024 10:28:37 +0700 Subject: [PATCH 193/341] JAMES-2586 JMAP Guice bindings modules to pg-app --- server/apps/postgres-app/pom.xml | 10 +++ .../apache/james/PostgresJamesServerMain.java | 23 +++-- .../org/apache/james/PostgresJmapModule.java | 82 ++++++++++++++++++ .../james/PostgresJmapJamesServerTest.java | 48 +++++++++++ .../src/test/resources/mailetcontainer.xml | 8 ++ .../modules/data/PostgresDataJmapModule.java | 86 +++++++++++++++++++ 6 files changed, 250 insertions(+), 7 deletions(-) create mode 100644 server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java create mode 100644 server/apps/postgres-app/src/test/java/org/apache/james/PostgresJmapJamesServerTest.java create mode 100644 server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java diff --git a/server/apps/postgres-app/pom.xml b/server/apps/postgres-app/pom.xml index 632d107d095..e60d5e1d38a 100644 --- a/server/apps/postgres-app/pom.xml +++ b/server/apps/postgres-app/pom.xml @@ -135,6 +135,12 @@ ${james.groupId} james-server-guice-imap + + ${james.groupId} + james-server-guice-jmap + test-jar + test + ${james.groupId} james-server-guice-jmx @@ -189,6 +195,10 @@ ${james.groupId} james-server-guice-webadmin-data + + ${james.groupId} + james-server-guice-webadmin-jmap + ${james.groupId} james-server-guice-webadmin-mailbox diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index bf4efd24f32..45da2178968 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -22,12 +22,15 @@ import java.util.List; import org.apache.james.data.UsersRepositoryModuleChooser; +import org.apache.james.jmap.draft.JMAPListenerModule; +import org.apache.james.jmap.memory.pushsubscription.MemoryPushSubscriptionModule; import org.apache.james.modules.BlobExportMechanismModule; import org.apache.james.modules.MailboxModule; import org.apache.james.modules.MailetProcessingModule; import org.apache.james.modules.RunArgumentsModule; import org.apache.james.modules.blobstore.BlobStoreCacheModulesChooser; import org.apache.james.modules.blobstore.BlobStoreModulesChooser; +import org.apache.james.modules.data.PostgresDataJmapModule; import org.apache.james.modules.data.PostgresDataModule; import org.apache.james.modules.data.PostgresDelegationStoreModule; import org.apache.james.modules.data.PostgresUsersRepositoryModule; @@ -40,6 +43,8 @@ import org.apache.james.modules.mailbox.PostgresMailboxModule; import org.apache.james.modules.mailbox.TikaMailboxModule; import org.apache.james.modules.protocols.IMAPServerModule; +import org.apache.james.modules.protocols.JMAPServerModule; +import org.apache.james.modules.protocols.JmapEventBusModule; import org.apache.james.modules.protocols.LMTPServerModule; import org.apache.james.modules.protocols.ManageSieveServerModule; import org.apache.james.modules.protocols.POP3ServerModule; @@ -48,14 +53,12 @@ import org.apache.james.modules.queue.activemq.ActiveMQQueueModule; import org.apache.james.modules.queue.rabbitmq.RabbitMQModule; import org.apache.james.modules.server.DataRoutesModules; -import org.apache.james.modules.server.DefaultProcessorsConfigurationProviderModule; import org.apache.james.modules.server.InconsistencyQuotasSolvingRoutesModule; import org.apache.james.modules.server.JMXServerModule; +import org.apache.james.modules.server.JmapTasksModule; import org.apache.james.modules.server.MailQueueRoutesModule; import org.apache.james.modules.server.MailRepositoriesRoutesModule; import org.apache.james.modules.server.MailboxRoutesModule; -import org.apache.james.modules.server.NoJwtModule; -import org.apache.james.modules.server.RawPostDequeueDecoratorModule; import org.apache.james.modules.server.ReIndexingModule; import org.apache.james.modules.server.SieveRoutesModule; import org.apache.james.modules.server.TaskManagerModule; @@ -94,20 +97,26 @@ public class PostgresJamesServerMain implements JamesServerMain { new ActiveMQQueueModule(), new BlobExportMechanismModule(), new PostgresDelegationStoreModule(), - new DefaultProcessorsConfigurationProviderModule(), new PostgresMailboxModule(), new PostgresDeadLetterModule(), new PostgresDataModule(), new MailboxModule(), - new NoJwtModule(), - new RawPostDequeueDecoratorModule(), new SievePostgresRepositoryModules(), new TaskManagerModule(), new MemoryEventStoreModule(), new TikaMailboxModule()); + public static final Module JMAP = Modules.combine( + new PostgresJmapModule(), + new JmapEventBusModule(), + new PostgresDataJmapModule(), + new MemoryPushSubscriptionModule(), + new JMAPServerModule(), + new JmapTasksModule(), + new JMAPListenerModule()); + private static final Module POSTGRES_MODULE_AGGREGATE = Modules.combine( - new MailetProcessingModule(), POSTGRES_SERVER_MODULE, PROTOCOLS); + new MailetProcessingModule(), POSTGRES_SERVER_MODULE, PROTOCOLS, JMAP); public static void main(String[] args) throws Exception { ExtraProperties.initialize(); diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java new file mode 100644 index 00000000000..c5883b1aac1 --- /dev/null +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java @@ -0,0 +1,82 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james; + +import org.apache.james.jmap.api.change.EmailChangeRepository; +import org.apache.james.jmap.api.change.Limit; +import org.apache.james.jmap.api.change.MailboxChangeRepository; +import org.apache.james.jmap.api.change.State; +import org.apache.james.jmap.api.upload.UploadUsageRepository; +import org.apache.james.jmap.memory.change.MemoryEmailChangeRepository; +import org.apache.james.jmap.memory.change.MemoryMailboxChangeRepository; +import org.apache.james.jmap.memory.upload.InMemoryUploadUsageRepository; +import org.apache.james.mailbox.AttachmentManager; +import org.apache.james.mailbox.MessageIdManager; +import org.apache.james.mailbox.RightManager; +import org.apache.james.mailbox.inmemory.InMemoryMailboxSessionMapperFactory; +import org.apache.james.mailbox.store.StoreAttachmentManager; +import org.apache.james.mailbox.store.StoreMessageIdManager; +import org.apache.james.mailbox.store.StoreRightManager; +import org.apache.james.mailbox.store.mail.AttachmentMapperFactory; +import org.apache.james.vacation.api.NotificationRegistry; +import org.apache.james.vacation.api.VacationRepository; +import org.apache.james.vacation.api.VacationService; +import org.apache.james.vacation.memory.MemoryNotificationRegistry; +import org.apache.james.vacation.memory.MemoryVacationRepository; + +import com.google.inject.AbstractModule; +import com.google.inject.Scopes; +import com.google.inject.name.Names; + +public class PostgresJmapModule extends AbstractModule { + + @Override + protected void configure() { + bind(EmailChangeRepository.class).to(MemoryEmailChangeRepository.class); + bind(MemoryEmailChangeRepository.class).in(Scopes.SINGLETON); + + bind(MailboxChangeRepository.class).to(MemoryMailboxChangeRepository.class); + bind(MemoryMailboxChangeRepository.class).in(Scopes.SINGLETON); + + bind(Limit.class).annotatedWith(Names.named(MemoryEmailChangeRepository.LIMIT_NAME)).toInstance(Limit.of(256)); + bind(Limit.class).annotatedWith(Names.named(MemoryMailboxChangeRepository.LIMIT_NAME)).toInstance(Limit.of(256)); + + bind(UploadUsageRepository.class).to(InMemoryUploadUsageRepository.class); + + bind(DefaultVacationService.class).in(Scopes.SINGLETON); + bind(VacationService.class).to(DefaultVacationService.class); + + bind(MemoryNotificationRegistry.class).in(Scopes.SINGLETON); + bind(NotificationRegistry.class).to(MemoryNotificationRegistry.class); + + bind(MemoryVacationRepository.class).in(Scopes.SINGLETON); + bind(VacationRepository.class).to(MemoryVacationRepository.class); + + bind(MessageIdManager.class).to(StoreMessageIdManager.class); + bind(AttachmentManager.class).to(StoreAttachmentManager.class); + bind(StoreMessageIdManager.class).in(Scopes.SINGLETON); + bind(StoreAttachmentManager.class).in(Scopes.SINGLETON); + bind(AttachmentMapperFactory.class).to(InMemoryMailboxSessionMapperFactory.class); + bind(RightManager.class).to(StoreRightManager.class); + bind(StoreRightManager.class).in(Scopes.SINGLETON); + + bind(State.Factory.class).toInstance(State.Factory.DEFAULT); + } +} diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJmapJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJmapJamesServerTest.java new file mode 100644 index 00000000000..da28ff401b6 --- /dev/null +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJmapJamesServerTest.java @@ -0,0 +1,48 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.jmap.draft.JmapJamesServerContract; +import org.apache.james.modules.TestJMAPServerModule; +import org.apache.james.vault.VaultConfiguration; +import org.junit.jupiter.api.extension.RegisterExtension; + +class PostgresJmapJamesServerTest implements JmapJamesServerContract { + static PostgresExtension postgresExtension = PostgresExtension.empty(); + + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.scanning()) + .usersRepository(DEFAULT) + .eventBusImpl(PostgresJamesConfiguration.EventBusImpl.IN_MEMORY) + .deletedMessageVaultConfiguration(VaultConfiguration.ENABLED_DEFAULT) + .build()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(new TestJMAPServerModule())) + .extension(postgresExtension) + .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) + .build(); +} diff --git a/server/apps/postgres-app/src/test/resources/mailetcontainer.xml b/server/apps/postgres-app/src/test/resources/mailetcontainer.xml index d03783d1b3e..b9b7a7eba44 100644 --- a/server/apps/postgres-app/src/test/resources/mailetcontainer.xml +++ b/server/apps/postgres-app/src/test/resources/mailetcontainer.xml @@ -63,6 +63,14 @@ rrt-error + + + ignore + + + ignore + + local-address-error diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java new file mode 100644 index 00000000000..e156b153ddd --- /dev/null +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java @@ -0,0 +1,86 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.data; + +import org.apache.james.core.healthcheck.HealthCheck; +import org.apache.james.jmap.api.access.AccessTokenRepository; +import org.apache.james.jmap.api.filtering.FilteringManagement; +import org.apache.james.jmap.api.filtering.FiltersDeleteUserDataTaskStep; +import org.apache.james.jmap.api.filtering.impl.EventSourcingFilteringManagement; +import org.apache.james.jmap.api.filtering.impl.FilterUsernameChangeTaskStep; +import org.apache.james.jmap.api.identity.CustomIdentityDAO; +import org.apache.james.jmap.api.identity.IdentityUserDeletionTaskStep; +import org.apache.james.jmap.api.projections.EmailQueryView; +import org.apache.james.jmap.api.projections.MessageFastViewProjection; +import org.apache.james.jmap.api.projections.MessageFastViewProjectionHealthCheck; +import org.apache.james.jmap.api.pushsubscription.PushDeleteUserDataTaskStep; +import org.apache.james.jmap.api.upload.UploadRepository; +import org.apache.james.jmap.memory.access.MemoryAccessTokenRepository; +import org.apache.james.jmap.memory.identity.MemoryCustomIdentityDAO; +import org.apache.james.jmap.memory.projections.MemoryEmailQueryView; +import org.apache.james.jmap.memory.projections.MemoryMessageFastViewProjection; +import org.apache.james.jmap.memory.upload.InMemoryUploadRepository; +import org.apache.james.mailbox.store.extractor.DefaultTextExtractor; +import org.apache.james.user.api.DeleteUserDataTaskStep; +import org.apache.james.user.api.UsernameChangeTaskStep; + +import com.google.inject.AbstractModule; +import com.google.inject.Scopes; +import com.google.inject.multibindings.Multibinder; + +public class PostgresDataJmapModule extends AbstractModule { + + @Override + protected void configure() { + bind(MemoryAccessTokenRepository.class).in(Scopes.SINGLETON); + bind(AccessTokenRepository.class).to(MemoryAccessTokenRepository.class); + + bind(InMemoryUploadRepository.class).in(Scopes.SINGLETON); + bind(UploadRepository.class).to(InMemoryUploadRepository.class); + + bind(MemoryCustomIdentityDAO.class).in(Scopes.SINGLETON); + bind(CustomIdentityDAO.class).to(MemoryCustomIdentityDAO.class); + + bind(EventSourcingFilteringManagement.class).in(Scopes.SINGLETON); + bind(FilteringManagement.class).to(EventSourcingFilteringManagement.class); + bind(EventSourcingFilteringManagement.ReadProjection.class).to(EventSourcingFilteringManagement.NoReadProjection.class); + + bind(DefaultTextExtractor.class).in(Scopes.SINGLETON); + + bind(MemoryMessageFastViewProjection.class).in(Scopes.SINGLETON); + bind(MessageFastViewProjection.class).to(MemoryMessageFastViewProjection.class); + + bind(MemoryEmailQueryView.class).in(Scopes.SINGLETON); + bind(EmailQueryView.class).to(MemoryEmailQueryView.class); + + bind(MessageFastViewProjectionHealthCheck.class).in(Scopes.SINGLETON); + Multibinder.newSetBinder(binder(), HealthCheck.class) + .addBinding() + .to(MessageFastViewProjectionHealthCheck.class); + Multibinder.newSetBinder(binder(), UsernameChangeTaskStep.class) + .addBinding() + .to(FilterUsernameChangeTaskStep.class); + + Multibinder deleteUserDataTaskSteps = Multibinder.newSetBinder(binder(), DeleteUserDataTaskStep.class); + deleteUserDataTaskSteps.addBinding().to(FiltersDeleteUserDataTaskStep.class); + deleteUserDataTaskSteps.addBinding().to(IdentityUserDeletionTaskStep.class); + deleteUserDataTaskSteps.addBinding().to(PushDeleteUserDataTaskStep.class); + } +} From 154f4c98607b69d3eb64c0188d2fc149286bd27b Mon Sep 17 00:00:00 2001 From: hung phan Date: Tue, 16 Jan 2024 11:30:49 +0700 Subject: [PATCH 194/341] fixup! JAMES-2586 JMAP Guice bindings modules to pg-app --- .../java/org/apache/james/JamesCapabilitiesServerTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java index 66204488350..347f4f1a8a1 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/JamesCapabilitiesServerTest.java @@ -53,8 +53,7 @@ private static MailboxManager mailboxManager() { .usersRepository(DEFAULT) .eventBusImpl(EventBusImpl.IN_MEMORY) .build()) - .server(configuration -> PostgresJamesServerMain.createServer(configuration) - .overrideWith(binder -> binder.bind(MailboxManager.class).toInstance(mailboxManager()))) + .server(configuration -> PostgresJamesServerMain.createServer(configuration)) .extension(postgresExtension) .build(); From e5c202297698aae6f3cbe135a7a7134e940a0042 Mon Sep 17 00:00:00 2001 From: hung phan Date: Tue, 16 Jan 2024 16:22:07 +0700 Subject: [PATCH 195/341] fixup! JAMES-2586 JMAP Guice bindings modules to pg-app --- .../james/PostgresJamesConfiguration.java | 30 +++++++++++++++++-- .../apache/james/PostgresJamesServerMain.java | 17 ++++++++--- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java index dbf65c350f9..4562716d296 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java @@ -27,6 +27,7 @@ import org.apache.james.data.UsersRepositoryModuleChooser; import org.apache.james.filesystem.api.FileSystem; import org.apache.james.filesystem.api.JamesDirectoriesProvider; +import org.apache.james.jmap.draft.JMAPModule; import org.apache.james.modules.blobstore.BlobStoreConfiguration; import org.apache.james.server.core.JamesServerResourceLoader; import org.apache.james.server.core.MissingArgumentException; @@ -72,6 +73,7 @@ public static class Builder { private Optional blobStoreConfiguration; private Optional eventBusImpl; private Optional deletedMessageVaultConfiguration; + private Optional jmapEnabled; private Builder() { searchConfiguration = Optional.empty(); @@ -81,6 +83,7 @@ private Builder() { blobStoreConfiguration = Optional.empty(); eventBusImpl = Optional.empty(); deletedMessageVaultConfiguration = Optional.empty(); + jmapEnabled = Optional.empty(); } public Builder workingDirectory(String path) { @@ -136,6 +139,11 @@ public Builder deletedMessageVaultConfiguration(VaultConfiguration vaultConfigur return this; } + public Builder jmapEnabled(Optional jmapEnabled) { + this.jmapEnabled = jmapEnabled; + return this; + } + public PostgresJamesConfiguration build() { ConfigurationPath configurationPath = this.configurationPath.orElse(new ConfigurationPath(FileSystem.FILE_PROTOCOL_AND_CONF)); JamesServerResourceLoader directories = new JamesServerResourceLoader(rootDirectory @@ -171,6 +179,16 @@ public PostgresJamesConfiguration build() { } }); + boolean jmapEnabled = this.jmapEnabled.orElseGet(() -> { + try { + return JMAPModule.parseConfiguration(propertiesProvider).isEnabled(); + } catch (FileNotFoundException e) { + return false; + } catch (ConfigurationException e) { + throw new RuntimeException(e); + } + }); + LOGGER.info("BlobStore configuration {}", blobStoreConfiguration); return new PostgresJamesConfiguration( configurationPath, @@ -179,7 +197,8 @@ public PostgresJamesConfiguration build() { usersRepositoryChoice, blobStoreConfiguration, eventBusImpl, - deletedMessageVaultConfiguration); + deletedMessageVaultConfiguration, + jmapEnabled); } } @@ -194,6 +213,7 @@ public static Builder builder() { private final BlobStoreConfiguration blobStoreConfiguration; private final EventBusImpl eventBusImpl; private final VaultConfiguration deletedMessageVaultConfiguration; + private final boolean jmapEnabled; private PostgresJamesConfiguration(ConfigurationPath configurationPath, JamesDirectoriesProvider directories, @@ -201,7 +221,8 @@ private PostgresJamesConfiguration(ConfigurationPath configurationPath, UsersRepositoryModuleChooser.Implementation usersRepositoryImplementation, BlobStoreConfiguration blobStoreConfiguration, EventBusImpl eventBusImpl, - VaultConfiguration deletedMessageVaultConfiguration) { + VaultConfiguration deletedMessageVaultConfiguration, + boolean jmapEnabled) { this.configurationPath = configurationPath; this.directories = directories; this.searchConfiguration = searchConfiguration; @@ -209,6 +230,7 @@ private PostgresJamesConfiguration(ConfigurationPath configurationPath, this.blobStoreConfiguration = blobStoreConfiguration; this.eventBusImpl = eventBusImpl; this.deletedMessageVaultConfiguration = deletedMessageVaultConfiguration; + this.jmapEnabled = jmapEnabled; } @Override @@ -240,4 +262,8 @@ public EventBusImpl eventBusImpl() { public VaultConfiguration getDeletedMessageVaultConfiguration() { return deletedMessageVaultConfiguration; } + + public boolean isJmapEnabled() { + return jmapEnabled; + } } diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index 45da2178968..7c5e85866bd 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -35,6 +35,7 @@ import org.apache.james.modules.data.PostgresDelegationStoreModule; import org.apache.james.modules.data.PostgresUsersRepositoryModule; import org.apache.james.modules.data.SievePostgresRepositoryModules; +import org.apache.james.modules.event.JMAPEventBusModule; import org.apache.james.modules.event.RabbitMQEventBusModule; import org.apache.james.modules.events.PostgresDeadLetterModule; import org.apache.james.modules.eventstore.MemoryEventStoreModule; @@ -108,12 +109,11 @@ public class PostgresJamesServerMain implements JamesServerMain { public static final Module JMAP = Modules.combine( new PostgresJmapModule(), - new JmapEventBusModule(), new PostgresDataJmapModule(), new MemoryPushSubscriptionModule(), + new JmapEventBusModule(), new JMAPServerModule(), - new JmapTasksModule(), - new JMAPListenerModule()); + new JmapTasksModule()); private static final Module POSTGRES_MODULE_AGGREGATE = Modules.combine( new MailetProcessingModule(), POSTGRES_SERVER_MODULE, PROTOCOLS, JMAP); @@ -142,7 +142,8 @@ public static GuiceJamesServer createServer(PostgresJamesConfiguration configura .combineWith(chooseBlobStoreModules(configuration)) .combineWith(chooseEventBusModules(configuration)) .combineWith(chooseDeletedMessageVaultModules(configuration.getDeletedMessageVaultConfiguration())) - .combineWith(POSTGRES_MODULE_AGGREGATE); + .combineWith(POSTGRES_MODULE_AGGREGATE) + .overrideWith(chooseJmapModules(configuration)); } private static List chooseUsersRepositoryModule(PostgresJamesConfiguration configuration) { @@ -178,4 +179,12 @@ private static Module chooseDeletedMessageVaultModules(VaultConfiguration vaultC return Modules.EMPTY_MODULE; } + + private static Module chooseJmapModules(PostgresJamesConfiguration configuration) { + if (configuration.isJmapEnabled()) { + return Modules.combine(new JMAPEventBusModule(), new JMAPListenerModule()); + } + return binder -> { + }; + } } From 1e7907a29b30631c945d2376f1693a11ad710a2e Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Tue, 16 Jan 2024 11:40:24 +0700 Subject: [PATCH 196/341] JAMES-2586 Implement PostgresAttachmentMapper, DAO and binding --- .../DeletedMessageVaultDeletionCallback.java | 2 +- mailbox/postgres/pom.xml | 4 + .../PostgresMailboxAggregateModule.java | 4 +- .../postgres/PostgresMailboxManager.java | 6 +- .../PostgresMailboxSessionMapperFactory.java | 8 +- .../postgres/PostgresMessageManager.java | 6 +- ... => UnsupportAttachmentContentLoader.java} | 2 +- .../postgres/mail/AttachmentLoader.java | 70 +++++++++ .../postgres/mail/MessageRepresentation.java | 70 ++++++++- .../mail/PostgresAttachmentMapper.java | 124 +++++++++++++++ .../mail/PostgresAttachmentModule.java | 58 +++++++ .../postgres/mail/PostgresMessageMapper.java | 12 +- .../postgres/mail/PostgresMessageModule.java | 6 + .../mail/dao/PostgresAttachmentDAO.java | 81 ++++++++++ .../PostgresMailboxMessageFetchStrategy.java | 1 + .../postgres/mail/dao/PostgresMessageDAO.java | 6 +- .../postgres/mail/dto/AttachmentsDTO.java | 140 +++++++++++++++++ .../PostgresMailboxManagerAttachmentTest.java | 148 ++++++++++++++++++ .../mail/PostgresAttachmentMapperTest.java | 54 +++++++ .../org/apache/james/PostgresJmapModule.java | 3 - .../mailbox/PostgresMailboxModule.java | 7 +- 21 files changed, 793 insertions(+), 19 deletions(-) rename mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/{PostgresAttachmentContentLoader.java => UnsupportAttachmentContentLoader.java} (95%) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/AttachmentLoader.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentMapper.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentModule.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresAttachmentDAO.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dto/AttachmentsDTO.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerAttachmentTest.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentMapperTest.java diff --git a/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/DeletedMessageVaultDeletionCallback.java b/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/DeletedMessageVaultDeletionCallback.java index 18a7027c469..36eb516a376 100644 --- a/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/DeletedMessageVaultDeletionCallback.java +++ b/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/DeletedMessageVaultDeletionCallback.java @@ -82,7 +82,7 @@ public Mono forMessage(MessageRepresentation message, MailboxId mailboxId, .deletionDate(ZonedDateTime.ofInstant(clock.instant(), ZoneOffset.UTC)) .sender(retrieveSender(mimeMessage)) .recipients(retrieveRecipients(mimeMessage)) - .hasAttachment(false) // todo return actual value in ticket: https://github.com/linagora/james-project/issues/5011 + .hasAttachment(!message.getAttachments().isEmpty()) .size(message.getSize()) .subject(mimeMessage.map(Message::getSubject)) .build(); diff --git a/mailbox/postgres/pom.xml b/mailbox/postgres/pom.xml index 461018541fc..33edb9ed015 100644 --- a/mailbox/postgres/pom.xml +++ b/mailbox/postgres/pom.xml @@ -138,6 +138,10 @@ testing-base test + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + com.sun.mail javax.mail diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAggregateModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAggregateModule.java index 807adddbd4f..2635555b6d6 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAggregateModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAggregateModule.java @@ -20,6 +20,7 @@ package org.apache.james.mailbox.postgres; import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.mailbox.postgres.mail.PostgresAttachmentModule; import org.apache.james.mailbox.postgres.mail.PostgresMailboxModule; import org.apache.james.mailbox.postgres.mail.PostgresMessageModule; import org.apache.james.mailbox.postgres.user.PostgresSubscriptionModule; @@ -30,5 +31,6 @@ public interface PostgresMailboxAggregateModule { PostgresMailboxModule.MODULE, PostgresSubscriptionModule.MODULE, PostgresMessageModule.MODULE, - PostgresMailboxAnnotationModule.MODULE); + PostgresMailboxAnnotationModule.MODULE, + PostgresAttachmentModule.MODULE); } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxManager.java index 5e0cf653256..ad9cbff57ef 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxManager.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxManager.java @@ -50,6 +50,8 @@ public class PostgresMailboxManager extends StoreMailboxManager { MailboxCapabilities.Annotation, MailboxCapabilities.ACL); + private final PostgresMailboxSessionMapperFactory mapperFactory; + @Inject public PostgresMailboxManager(PostgresMailboxSessionMapperFactory mapperFactory, SessionProvider sessionProvider, @@ -67,17 +69,19 @@ public PostgresMailboxManager(PostgresMailboxSessionMapperFactory mapperFactory, messageParser, messageIdFactory, annotationManager, eventBus, storeRightManager, quotaComponents, index, MailboxManagerConfiguration.DEFAULT, preDeletionHooks, threadIdGuessingAlgorithm, clock); + this.mapperFactory = mapperFactory; } @Override protected StoreMessageManager createMessageManager(Mailbox mailboxRow, MailboxSession session) { - return new PostgresMessageManager(getMapperFactory(), + return new PostgresMessageManager(mapperFactory, getMessageSearchIndex(), getEventBus(), getLocker(), mailboxRow, getQuotaComponents().getQuotaManager(), getQuotaComponents().getQuotaRootResolver(), + getMessageParser(), getMessageIdFactory(), configuration.getBatchSizes(), getStoreRightManager(), diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java index 26de4e4a88c..4a904faf201 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java @@ -22,17 +22,18 @@ import javax.inject.Inject; -import org.apache.commons.lang3.NotImplementedException; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.blob.api.BlobId; import org.apache.james.blob.api.BlobStore; import org.apache.james.mailbox.MailboxSession; import org.apache.james.mailbox.postgres.mail.PostgresAnnotationMapper; +import org.apache.james.mailbox.postgres.mail.PostgresAttachmentMapper; import org.apache.james.mailbox.postgres.mail.PostgresMailboxMapper; import org.apache.james.mailbox.postgres.mail.PostgresMessageIdMapper; import org.apache.james.mailbox.postgres.mail.PostgresMessageMapper; import org.apache.james.mailbox.postgres.mail.PostgresModSeqProvider; import org.apache.james.mailbox.postgres.mail.PostgresUidProvider; +import org.apache.james.mailbox.postgres.mail.dao.PostgresAttachmentDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxAnnotationDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; @@ -117,12 +118,13 @@ public PostgresModSeqProvider getModSeqProvider(MailboxSession session) { @Override public AttachmentMapper createAttachmentMapper(MailboxSession session) { - throw new NotImplementedException("not implemented"); + PostgresAttachmentDAO postgresAttachmentDAO = new PostgresAttachmentDAO(executorFactory.create(session.getUser().getDomainPart()), blobIdFactory); + return new PostgresAttachmentMapper(postgresAttachmentDAO, blobStore); } @Override public AttachmentMapper getAttachmentMapper(MailboxSession session) { - throw new NotImplementedException("not implemented"); + return createAttachmentMapper(session); } protected DeleteMessageListener deleteMessageListener() { diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMessageManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMessageManager.java index b7d15fcdceb..282017bb864 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMessageManager.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMessageManager.java @@ -48,6 +48,7 @@ import org.apache.james.mailbox.store.StoreRightManager; import org.apache.james.mailbox.store.mail.MessageMapper; import org.apache.james.mailbox.store.mail.ThreadIdGuessingAlgorithm; +import org.apache.james.mailbox.store.mail.model.impl.MessageParser; import org.apache.james.mailbox.store.search.MessageSearchIndex; import reactor.core.publisher.Mono; @@ -59,16 +60,17 @@ public class PostgresMessageManager extends StoreMessageManager { private final StoreRightManager storeRightManager; private final Mailbox mailbox; - public PostgresMessageManager(MailboxSessionMapperFactory mapperFactory, + public PostgresMessageManager(PostgresMailboxSessionMapperFactory mapperFactory, MessageSearchIndex index, EventBus eventBus, MailboxPathLocker locker, Mailbox mailbox, QuotaManager quotaManager, QuotaRootResolver quotaRootResolver, + MessageParser messageParser, MessageId.Factory messageIdFactory, BatchSizes batchSizes, StoreRightManager storeRightManager, ThreadIdGuessingAlgorithm threadIdGuessingAlgorithm, Clock clock, PreDeletionHooks preDeletionHooks) { super(StoreMailboxManager.DEFAULT_NO_MESSAGE_CAPABILITIES, mapperFactory, index, eventBus, locker, mailbox, quotaManager, quotaRootResolver, batchSizes, storeRightManager, preDeletionHooks, - new MessageStorer.WithoutAttachment(mapperFactory, messageIdFactory, new MessageFactory.StoreMessageFactory(), threadIdGuessingAlgorithm, clock)); + new MessageStorer.WithAttachment(mapperFactory, messageIdFactory, new MessageFactory.StoreMessageFactory(), mapperFactory, messageParser, threadIdGuessingAlgorithm, clock)); this.storeRightManager = storeRightManager; this.mapperFactory = mapperFactory; this.mailbox = mailbox; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresAttachmentContentLoader.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/UnsupportAttachmentContentLoader.java similarity index 95% rename from mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresAttachmentContentLoader.java rename to mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/UnsupportAttachmentContentLoader.java index f78d3e35a94..e4954999eca 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresAttachmentContentLoader.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/UnsupportAttachmentContentLoader.java @@ -26,7 +26,7 @@ import org.apache.james.mailbox.MailboxSession; import org.apache.james.mailbox.model.AttachmentMetadata; -public class PostgresAttachmentContentLoader implements AttachmentContentLoader { +public class UnsupportAttachmentContentLoader implements AttachmentContentLoader { @Override public InputStream load(AttachmentMetadata attachment, MailboxSession mailboxSession) { throw new NotImplementedException("Postgresql doesn't support loading attachment separately from Message"); diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/AttachmentLoader.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/AttachmentLoader.java new file mode 100644 index 00000000000..3e2b2b7f118 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/AttachmentLoader.java @@ -0,0 +1,70 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.ATTACHMENT_METADATA; + +import org.apache.commons.lang3.tuple.Pair; +import org.apache.james.mailbox.model.AttachmentMetadata; +import org.apache.james.mailbox.model.MessageAttachmentMetadata; +import org.apache.james.mailbox.store.mail.MessageMapper; +import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; +import org.apache.james.util.ReactorUtils; +import org.jooq.Record; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class AttachmentLoader { + + private final PostgresAttachmentMapper attachmentMapper; + + public AttachmentLoader(PostgresAttachmentMapper attachmentMapper) { + this.attachmentMapper = attachmentMapper; + } + + + public Flux> addAttachmentToMessage(Flux> findMessagePublisher, MessageMapper.FetchType fetchType) { + return findMessagePublisher.flatMap(pair -> { + if (fetchType == MessageMapper.FetchType.FULL || fetchType == MessageMapper.FetchType.ATTACHMENTS_METADATA) { + return Mono.fromCallable(() -> pair.getRight().get(ATTACHMENT_METADATA)) + .flatMapMany(Flux::fromIterable) + .flatMapSequential(attachmentRepresentation -> attachmentMapper.getAttachmentReactive(attachmentRepresentation.getAttachmentId()) + .map(attachment -> constructMessageAttachment(attachment, attachmentRepresentation))) + .collectList() + .map(messageAttachmentMetadata -> { + pair.getLeft().addAttachments(messageAttachmentMetadata); + return pair; + }).switchIfEmpty(Mono.just(pair)); + } else { + return Mono.just(pair); + } + }, ReactorUtils.DEFAULT_CONCURRENCY); + } + + private MessageAttachmentMetadata constructMessageAttachment(AttachmentMetadata attachment, MessageRepresentation.AttachmentRepresentation messageAttachmentRepresentation) { + return MessageAttachmentMetadata.builder() + .attachment(attachment) + .name(messageAttachmentRepresentation.getName().orElse(null)) + .cid(messageAttachmentRepresentation.getCid()) + .isInline(messageAttachmentRepresentation.isInline()) + .build(); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/MessageRepresentation.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/MessageRepresentation.java index b960f7dde54..dd24c5dd60d 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/MessageRepresentation.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/MessageRepresentation.java @@ -20,14 +20,65 @@ package org.apache.james.mailbox.postgres.mail; import java.util.Date; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; import org.apache.james.blob.api.BlobId; +import org.apache.james.mailbox.model.AttachmentId; +import org.apache.james.mailbox.model.Cid; import org.apache.james.mailbox.model.Content; +import org.apache.james.mailbox.model.MessageAttachmentMetadata; import org.apache.james.mailbox.model.MessageId; import com.google.common.base.Preconditions; public class MessageRepresentation { + public static class AttachmentRepresentation { + public static AttachmentRepresentation from(MessageAttachmentMetadata messageAttachmentMetadata) { + return new AttachmentRepresentation( + messageAttachmentMetadata.getAttachment().getAttachmentId(), + messageAttachmentMetadata.getName(), + messageAttachmentMetadata.getCid(), + messageAttachmentMetadata.isInline()); + } + + public static List from(List messageAttachmentMetadata) { + return messageAttachmentMetadata.stream() + .map(AttachmentRepresentation::from) + .collect(Collectors.toList()); + } + + private final AttachmentId attachmentId; + private final Optional name; + private final Optional cid; + private final boolean isInline; + + public AttachmentRepresentation(AttachmentId attachmentId, Optional name, Optional cid, boolean isInline) { + Preconditions.checkNotNull(attachmentId, "attachmentId is required"); + this.attachmentId = attachmentId; + this.name = name; + this.cid = cid; + this.isInline = isInline; + } + + public AttachmentId getAttachmentId() { + return attachmentId; + } + + public Optional getName() { + return name; + } + + public Optional getCid() { + return cid; + } + + public boolean isInline() { + return isInline; + } + } + public static MessageRepresentation.Builder builder() { return new MessageRepresentation.Builder(); } @@ -39,6 +90,8 @@ public static class Builder { private Content headerContent; private BlobId bodyBlobId; + private List attachments = List.of(); + public MessageRepresentation.Builder messageId(MessageId messageId) { this.messageId = messageId; return this; @@ -65,6 +118,11 @@ public MessageRepresentation.Builder bodyBlobId(BlobId bodyBlobId) { return this; } + public MessageRepresentation.Builder attachments(List attachments) { + this.attachments = attachments; + return this; + } + public MessageRepresentation build() { Preconditions.checkNotNull(messageId, "messageId is required"); Preconditions.checkNotNull(internalDate, "internalDate is required"); @@ -72,7 +130,7 @@ public MessageRepresentation build() { Preconditions.checkNotNull(headerContent, "headerContent is required"); Preconditions.checkNotNull(bodyBlobId, "mailboxId is required"); - return new MessageRepresentation(messageId, internalDate, size, headerContent, bodyBlobId); + return new MessageRepresentation(messageId, internalDate, size, headerContent, bodyBlobId, attachments); } } @@ -82,13 +140,17 @@ public MessageRepresentation build() { private final Content headerContent; private final BlobId bodyBlobId; + private final List attachments; + private MessageRepresentation(MessageId messageId, Date internalDate, Long size, - Content headerContent, BlobId bodyBlobId) { + Content headerContent, BlobId bodyBlobId, + List attachments) { this.messageId = messageId; this.internalDate = internalDate; this.size = size; this.headerContent = headerContent; this.bodyBlobId = bodyBlobId; + this.attachments = attachments; } public Date getInternalDate() { @@ -110,4 +172,8 @@ public Content getHeaderContent() { public BlobId getBodyBlobId() { return bodyBlobId; } + + public List getAttachments() { + return attachments; + } } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentMapper.java new file mode 100644 index 00000000000..e1d187f2361 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentMapper.java @@ -0,0 +1,124 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import static org.apache.james.blob.api.BlobStore.StoragePolicy.LOW_COST; +import static org.apache.james.util.ReactorUtils.DEFAULT_CONCURRENCY; + +import java.io.InputStream; +import java.util.Collection; +import java.util.List; + +import org.apache.commons.lang3.tuple.Pair; +import org.apache.james.blob.api.BlobStore; +import org.apache.james.mailbox.exception.AttachmentNotFoundException; +import org.apache.james.mailbox.model.AttachmentId; +import org.apache.james.mailbox.model.AttachmentMetadata; +import org.apache.james.mailbox.model.MessageAttachmentMetadata; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.model.ParsedAttachment; +import org.apache.james.mailbox.postgres.mail.dao.PostgresAttachmentDAO; +import org.apache.james.mailbox.store.mail.AttachmentMapper; + +import com.github.fge.lambdas.Throwing; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresAttachmentMapper implements AttachmentMapper { + + private final PostgresAttachmentDAO postgresAttachmentDAO; + private final BlobStore blobStore; + + public PostgresAttachmentMapper(PostgresAttachmentDAO postgresAttachmentDAO, BlobStore blobStore) { + this.postgresAttachmentDAO = postgresAttachmentDAO; + this.blobStore = blobStore; + } + + @Override + public InputStream loadAttachmentContent(AttachmentId attachmentId) { + return loadAttachmentContentReactive(attachmentId) + .block(); + } + + @Override + public Mono loadAttachmentContentReactive(AttachmentId attachmentId) { + return postgresAttachmentDAO.getAttachment(attachmentId) + .flatMap(pair -> Mono.from(blobStore.readReactive(blobStore.getDefaultBucketName(), pair.getRight(), LOW_COST))) + .switchIfEmpty(Mono.error(() -> new AttachmentNotFoundException(attachmentId.toString()))); + } + + @Override + public AttachmentMetadata getAttachment(AttachmentId attachmentId) throws AttachmentNotFoundException { + Preconditions.checkArgument(attachmentId != null); + return postgresAttachmentDAO.getAttachment(attachmentId) + .map(Pair::getLeft) + .blockOptional() + .orElseThrow(() -> new AttachmentNotFoundException(attachmentId.getId())); + } + + @Override + public Mono getAttachmentReactive(AttachmentId attachmentId) { + Preconditions.checkArgument(attachmentId != null); + return postgresAttachmentDAO.getAttachment(attachmentId) + .map(Pair::getLeft) + .switchIfEmpty(Mono.error(() -> new AttachmentNotFoundException(attachmentId.getId()))); + } + + @Override + public List getAttachments(Collection attachmentIds) { + Preconditions.checkArgument(attachmentIds != null); + return Flux.fromIterable(attachmentIds) + .flatMap(id -> postgresAttachmentDAO.getAttachment(id) + .map(Pair::getLeft), DEFAULT_CONCURRENCY) + .collect(ImmutableList.toImmutableList()) + .block(); + } + + @Override + public List storeAttachments(Collection attachments, MessageId ownerMessageId) { + return storeAttachmentsReactive(attachments, ownerMessageId) + .block(); + } + + @Override + public Mono> storeAttachmentsReactive(Collection attachments, MessageId ownerMessageId) { + return Flux.fromIterable(attachments) + .concatMap(attachment -> storeAttachmentAsync(attachment, ownerMessageId)) + .collectList(); + } + + private Mono storeAttachmentAsync(ParsedAttachment parsedAttachment, MessageId ownerMessageId) { + return Mono.fromCallable(parsedAttachment::getContent) + .flatMap(content -> Mono.from(blobStore.save(blobStore.getDefaultBucketName(), parsedAttachment.getContent(), BlobStore.StoragePolicy.LOW_COST)) + .flatMap(blobId -> { + AttachmentId attachmentId = AttachmentId.random(); + return postgresAttachmentDAO.storeAttachment(AttachmentMetadata.builder() + .attachmentId(attachmentId) + .type(parsedAttachment.getContentType()) + .size(Throwing.supplier(content::size).get()) + .messageId(ownerMessageId) + .build(), blobId) + .thenReturn(Throwing.supplier(() -> parsedAttachment.asMessageAttachment(attachmentId, ownerMessageId)).get()); + })); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentModule.java new file mode 100644 index 00000000000..e085f608517 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentModule.java @@ -0,0 +1,58 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import java.util.UUID; + +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresAttachmentModule { + + interface PostgresAttachmentTable { + + Table TABLE_NAME = DSL.table("attachment"); + Field ID = DSL.field("id", SQLDataType.UUID.notNull()); + Field BLOB_ID = DSL.field("blob_id", SQLDataType.VARCHAR); + Field TYPE = DSL.field("type", SQLDataType.VARCHAR); + Field MESSAGE_ID = DSL.field("message_id", SQLDataType.UUID); + Field SIZE = DSL.field("size", SQLDataType.BIGINT); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(ID) + .column(BLOB_ID) + .column(TYPE) + .column(MESSAGE_ID) + .column(SIZE) + .constraint(DSL.primaryKey(ID)))) + .supportsRowLevelSecurity() + .build(); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(PostgresAttachmentTable.TABLE) + .build(); +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java index 7d4385995e4..9fc948a2bf9 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java @@ -57,6 +57,7 @@ import org.apache.james.mailbox.model.MessageRange; import org.apache.james.mailbox.model.UpdatedFlags; import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresAttachmentDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; @@ -101,6 +102,7 @@ public long size() { private final BlobStore blobStore; private final Clock clock; private final BlobId.Factory blobIdFactory; + private final AttachmentLoader attachmentLoader; public PostgresMessageMapper(PostgresExecutor postgresExecutor, PostgresModSeqProvider modSeqProvider, @@ -116,6 +118,7 @@ public PostgresMessageMapper(PostgresExecutor postgresExecutor, this.blobStore = blobStore; this.clock = clock; this.blobIdFactory = blobIdFactory; + this.attachmentLoader = new AttachmentLoader(new PostgresAttachmentMapper(new PostgresAttachmentDAO(postgresExecutor, blobIdFactory), blobStore)); } @@ -134,8 +137,10 @@ public Flux listMessagesMetadata(Mailbox mailbox, @Override public Flux findInMailboxReactive(Mailbox mailbox, MessageRange messageRange, FetchType fetchType, int limitAsInt) { Flux> fetchMessageWithoutFullContentPublisher = fetchMessageWithoutFullContent(mailbox, messageRange, fetchType, limitAsInt); + Flux> fetchMessagePublisher = attachmentLoader.addAttachmentToMessage(fetchMessageWithoutFullContentPublisher, fetchType); + if (fetchType == FetchType.FULL) { - return fetchMessageWithoutFullContentPublisher + return fetchMessagePublisher .flatMap(messageBuilderAndRecord -> { SimpleMailboxMessage.Builder messageBuilder = messageBuilderAndRecord.getLeft(); return retrieveFullContent(messageBuilderAndRecord.getRight()) @@ -144,8 +149,9 @@ public Flux findInMailboxReactive(Mailbox mailbox, MessageRange .sort(Comparator.comparing(MailboxMessage::getUid)) .map(message -> message); } else { - return fetchMessageWithoutFullContentPublisher - .map(messageBuilderAndBlobId -> messageBuilderAndBlobId.getLeft().build()); + return fetchMessagePublisher + .map(messageBuilderAndBlobId -> messageBuilderAndBlobId.getLeft() + .build()); } } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java index eca81fec550..87499f2ad84 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageModule.java @@ -28,6 +28,7 @@ import org.apache.james.backends.postgres.PostgresIndex; import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.backends.postgres.PostgresTable; +import org.apache.james.mailbox.postgres.mail.dto.AttachmentsDTO; import org.jooq.Field; import org.jooq.Record; import org.jooq.Table; @@ -62,6 +63,10 @@ interface MessageTable { Field CONTENT_LANGUAGE = DSL.field("content_language", DataTypes.STRING_ARRAY); Field CONTENT_TYPE_PARAMETERS = DSL.field("content_type_parameters", DataTypes.HSTORE); Field CONTENT_DISPOSITION_PARAMETERS = DSL.field("content_disposition_parameters", DataTypes.HSTORE); + Field ATTACHMENT_METADATA = DSL.field("attachment_metadata", + SQLDataType.JSONB + .asConvertedDataType(new AttachmentsDTO.AttachmentsDTOBinding())); + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) @@ -83,6 +88,7 @@ interface MessageTable { .column(CONTENT_LANGUAGE) .column(CONTENT_TYPE_PARAMETERS) .column(CONTENT_DISPOSITION_PARAMETERS) + .column(ATTACHMENT_METADATA) .constraint(DSL.primaryKey(MESSAGE_ID)) .comment("Holds the metadata of a mail"))) .supportsRowLevelSecurity() diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresAttachmentDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresAttachmentDAO.java new file mode 100644 index 00000000000..910afddb4c2 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresAttachmentDAO.java @@ -0,0 +1,81 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail.dao; + + +import org.apache.commons.lang3.tuple.Pair; +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.blob.api.BlobId; +import org.apache.james.mailbox.model.AttachmentId; +import org.apache.james.mailbox.model.AttachmentMetadata; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.mailbox.postgres.mail.PostgresAttachmentModule.PostgresAttachmentTable; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresAttachmentDAO { + + private final PostgresExecutor postgresExecutor; + private final BlobId.Factory blobIdFactory; + + public PostgresAttachmentDAO(PostgresExecutor postgresExecutor, BlobId.Factory blobIdFactory) { + this.postgresExecutor = postgresExecutor; + this.blobIdFactory = blobIdFactory; + } + + public Mono> getAttachment(AttachmentId attachmentId) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select( + PostgresAttachmentTable.TYPE, + PostgresAttachmentTable.BLOB_ID, + PostgresAttachmentTable.MESSAGE_ID, + PostgresAttachmentTable.SIZE) + .from(PostgresAttachmentTable.TABLE_NAME) + .where(PostgresAttachmentTable.ID.eq(attachmentId.asUUID())))) + .map(row -> Pair.of( + AttachmentMetadata.builder() + .attachmentId(attachmentId) + .type(row.get(PostgresAttachmentTable.TYPE)) + .messageId(PostgresMessageId.Factory.of(row.get(PostgresAttachmentTable.MESSAGE_ID))) + .size(row.get(PostgresAttachmentTable.SIZE)) + .build(), + blobIdFactory.from(row.get(PostgresAttachmentTable.BLOB_ID)))); + } + + public Mono storeAttachment(AttachmentMetadata attachment, BlobId blobId) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(PostgresAttachmentTable.TABLE_NAME) + .set(PostgresAttachmentTable.ID, attachment.getAttachmentId().asUUID()) + .set(PostgresAttachmentTable.BLOB_ID, blobId.asString()) + .set(PostgresAttachmentTable.TYPE, attachment.getType().asString()) + .set(PostgresAttachmentTable.MESSAGE_ID, ((PostgresMessageId) attachment.getMessageId()).asUuid()) + .set(PostgresAttachmentTable.SIZE, attachment.getSize()))); + } + + public Mono delete(AttachmentId attachmentId) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(PostgresAttachmentTable.TABLE_NAME) + .where(PostgresAttachmentTable.ID.eq(attachmentId.asUUID())))); + } + + public Flux listBlobs() { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(PostgresAttachmentTable.BLOB_ID) + .from(PostgresAttachmentTable.TABLE_NAME))) + .map(row -> blobIdFactory.from(row.get(PostgresAttachmentTable.BLOB_ID))); + } +} \ No newline at end of file diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageFetchStrategy.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageFetchStrategy.java index f6cc82d4ca4..eb2049d4575 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageFetchStrategy.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageFetchStrategy.java @@ -81,6 +81,7 @@ static Field[] fetchFieldsMetadata() { MessageTable.MIME_SUBTYPE, MessageTable.BODY_START_OCTET, MessageTable.TEXTUAL_LINE_COUNT, + MessageTable.ATTACHMENT_METADATA, MessageToMailboxTable.MAILBOX_ID, MessageToMailboxTable.MESSAGE_UID, MessageToMailboxTable.MOD_SEQ, diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java index d4aca8b5a99..b572d9b3ccb 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java @@ -21,6 +21,7 @@ import static org.apache.james.backends.postgres.PostgresCommons.DATE_TO_LOCAL_DATE_TIME; import static org.apache.james.backends.postgres.PostgresCommons.LOCAL_DATE_TIME_DATE_FUNCTION; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.ATTACHMENT_METADATA; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.BODY_BLOB_ID; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.BODY_START_OCTET; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.CONTENT_DESCRIPTION; @@ -57,6 +58,7 @@ import org.apache.james.mailbox.postgres.PostgresMessageId; import org.apache.james.mailbox.postgres.mail.MessageRepresentation; import org.apache.james.mailbox.postgres.mail.PostgresMessageModule; +import org.apache.james.mailbox.postgres.mail.dto.AttachmentsDTO; import org.apache.james.mailbox.store.mail.model.MailboxMessage; import org.jooq.Record; import org.jooq.postgres.extensions.types.Hstore; @@ -114,12 +116,13 @@ public Mono insert(MailboxMessage message, String bodyBlobId) { .set(CONTENT_TRANSFER_ENCODING, message.getProperties().getContentTransferEncoding()) .set(CONTENT_TYPE_PARAMETERS, Hstore.hstore(message.getProperties().getContentTypeParameters())) .set(CONTENT_DISPOSITION_PARAMETERS, Hstore.hstore(message.getProperties().getContentDispositionParameters())) + .set(ATTACHMENT_METADATA, AttachmentsDTO.from(message.getAttachments())) .set(HEADER_CONTENT, headerContentAsByte)))); } public Mono retrieveMessage(PostgresMessageId messageId) { return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select( - INTERNAL_DATE, SIZE, BODY_START_OCTET, HEADER_CONTENT, BODY_BLOB_ID) + INTERNAL_DATE, SIZE, BODY_START_OCTET, HEADER_CONTENT, BODY_BLOB_ID, ATTACHMENT_METADATA) .from(TABLE_NAME) .where(MESSAGE_ID.eq(messageId.asUuid())))) .map(record -> toMessageRepresentation(record, messageId)); @@ -132,6 +135,7 @@ private MessageRepresentation toMessageRepresentation(Record record, MessageId m .size(record.get(PostgresMessageModule.MessageTable.SIZE)) .headerContent(BYTE_TO_CONTENT_FUNCTION.apply(record.get(HEADER_CONTENT))) .bodyBlobId(blobIdFactory.from(record.get(BODY_BLOB_ID))) + .attachments(record.get(ATTACHMENT_METADATA)) .build(); } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dto/AttachmentsDTO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dto/AttachmentsDTO.java new file mode 100644 index 00000000000..10c1d8eebc5 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dto/AttachmentsDTO.java @@ -0,0 +1,140 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail.dto; + +import java.io.Serializable; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +import org.apache.james.mailbox.model.AttachmentId; +import org.apache.james.mailbox.model.Cid; +import org.apache.james.mailbox.model.MessageAttachmentMetadata; +import org.apache.james.mailbox.postgres.mail.MessageRepresentation; +import org.jooq.BindingGetResultSetContext; +import org.jooq.BindingSetStatementContext; +import org.jooq.Converter; +import org.jooq.impl.AbstractConverter; +import org.jooq.postgres.extensions.bindings.AbstractPostgresBinding; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; + +import io.r2dbc.postgresql.codec.Json; + +public class AttachmentsDTO extends ArrayList implements Serializable { + + public static class AttachmentsDTOConverter extends AbstractConverter { + private static final long serialVersionUID = 1L; + private static final String ATTACHMENT_ID_PROPERTY = "attachment_id"; + private static final String NAME_PROPERTY = "name"; + private static final String CID_PROPERTY = "cid"; + private static final String IN_LINE_PROPERTY = "in_line"; + private final ObjectMapper objectMapper; + + public AttachmentsDTOConverter() { + super(Object.class, AttachmentsDTO.class); + this.objectMapper = new ObjectMapper(); + this.objectMapper.registerModule(new Jdk8Module()); + } + + @Override + public AttachmentsDTO from(Object databaseObject) { + if (databaseObject instanceof Json) { + try { + JsonNode arrayNode = objectMapper.readTree(((Json) databaseObject).asArray()); + List collect = StreamSupport.stream(arrayNode.spliterator(), false) + .map(this::fromJsonNode) + .collect(Collectors.toList()); + return new AttachmentsDTO(collect); + } catch (Exception e) { + throw new RuntimeException("Error while deserializing attachment representation", e); + } + } + throw new RuntimeException("Error while deserializing attachment representation. Unknown type: " + databaseObject.getClass().getName()); + } + + @Override + public Object to(AttachmentsDTO userObject) { + try { + byte[] jsonAsByte = objectMapper.writeValueAsBytes(userObject + .stream().map(attachment -> Map.of( + ATTACHMENT_ID_PROPERTY, attachment.getAttachmentId().getId(), + NAME_PROPERTY, attachment.getName(), + CID_PROPERTY, attachment.getCid().map(Cid::getValue), + IN_LINE_PROPERTY, attachment.isInline())).collect(Collectors.toList())); + return Json.of(jsonAsByte); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + private MessageRepresentation.AttachmentRepresentation fromJsonNode(JsonNode jsonNode) { + AttachmentId attachmentId = AttachmentId.from(jsonNode.get(ATTACHMENT_ID_PROPERTY).asText()); + Optional name = Optional.ofNullable(jsonNode.get(NAME_PROPERTY)).map(JsonNode::asText); + Optional cid = Optional.ofNullable(jsonNode.get(CID_PROPERTY)).map(JsonNode::asText).map(Cid::from); + boolean isInline = jsonNode.get(IN_LINE_PROPERTY).asBoolean(); + + return new MessageRepresentation.AttachmentRepresentation(attachmentId, name, cid, isInline); + } + } + + public static class AttachmentsDTOBinding extends AbstractPostgresBinding { + private static final long serialVersionUID = 1L; + private static final Converter CONVERTER = new AttachmentsDTOConverter(); + + @Override + public Converter converter() { + return CONVERTER; + } + + @Override + public void set(final BindingSetStatementContext ctx) throws SQLException { + Object value = ctx.convert(converter()).value(); + + ctx.statement().setObject(ctx.index(), value == null ? null : value); + } + + + @Override + public void get(final BindingGetResultSetContext ctx) throws SQLException { + ctx.convert(converter()).value((Json) ctx.resultSet().getObject(ctx.index())); + } + } + + public static AttachmentsDTO from(List messageAttachmentMetadata) { + return new AttachmentsDTO(MessageRepresentation.AttachmentRepresentation.from(messageAttachmentMetadata)); + } + + private static final long serialVersionUID = 1L; + + public AttachmentsDTO(Collection c) { + super(c); + } + + +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerAttachmentTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerAttachmentTest.java new file mode 100644 index 00000000000..631ff887701 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerAttachmentTest.java @@ -0,0 +1,148 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.InputStream; +import java.time.Clock; +import java.time.Instant; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BucketName; +import org.apache.james.blob.api.HashBlobId; +import org.apache.james.blob.memory.MemoryBlobStoreDAO; +import org.apache.james.events.EventBusTestFixture; +import org.apache.james.events.InVMEventBus; +import org.apache.james.events.MemoryEventDeadLetters; +import org.apache.james.events.delivery.InVmEventDelivery; +import org.apache.james.mailbox.MailboxManager; +import org.apache.james.mailbox.MessageIdManager; +import org.apache.james.mailbox.acl.MailboxACLResolver; +import org.apache.james.mailbox.acl.UnionMailboxACLResolver; +import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.apache.james.mailbox.quota.QuotaRootResolver; +import org.apache.james.mailbox.store.AbstractMailboxManagerAttachmentTest; +import org.apache.james.mailbox.store.MailboxSessionMapperFactory; +import org.apache.james.mailbox.store.PreDeletionHooks; +import org.apache.james.mailbox.store.SessionProviderImpl; +import org.apache.james.mailbox.store.StoreAttachmentManager; +import org.apache.james.mailbox.store.StoreMailboxAnnotationManager; +import org.apache.james.mailbox.store.StoreMessageIdManager; +import org.apache.james.mailbox.store.StoreRightManager; +import org.apache.james.mailbox.store.extractor.DefaultTextExtractor; +import org.apache.james.mailbox.store.mail.AttachmentMapperFactory; +import org.apache.james.mailbox.store.mail.NaiveThreadIdGuessingAlgorithm; +import org.apache.james.mailbox.store.mail.model.impl.MessageParser; +import org.apache.james.mailbox.store.quota.NoQuotaManager; +import org.apache.james.mailbox.store.quota.QuotaComponents; +import org.apache.james.mailbox.store.search.MessageSearchIndex; +import org.apache.james.mailbox.store.search.SimpleMessageSearchIndex; +import org.apache.james.metrics.tests.RecordingMetricFactory; +import org.apache.james.server.blob.deduplication.DeDuplicationBlobStore; +import org.apache.james.utils.UpdatableTickingClock; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.google.common.collect.ImmutableSet; + +public class PostgresMailboxManagerAttachmentTest extends AbstractMailboxManagerAttachmentTest { + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); + private static PostgresMailboxManager mailboxManager; + private static PostgresMailboxManager parseFailingMailboxManager; + private static PostgresMailboxSessionMapperFactory mapperFactory; + + @BeforeEach + void beforeAll() throws Exception { + BlobId.Factory BLOB_ID_FACTORY = new HashBlobId.Factory(); + DeDuplicationBlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, BLOB_ID_FACTORY); + mapperFactory = new PostgresMailboxSessionMapperFactory(postgresExtension.getExecutorFactory(), Clock.systemUTC(), blobStore, BLOB_ID_FACTORY); + + MailboxACLResolver aclResolver = new UnionMailboxACLResolver(); + MessageParser messageParser = new MessageParser(); + + InVMEventBus eventBus = new InVMEventBus(new InVmEventDelivery(new RecordingMetricFactory()), EventBusTestFixture.RETRY_BACKOFF_CONFIGURATION, new MemoryEventDeadLetters()); + StoreRightManager storeRightManager = new StoreRightManager(mapperFactory, aclResolver, eventBus); + StoreMailboxAnnotationManager annotationManager = new StoreMailboxAnnotationManager(mapperFactory, storeRightManager, 3, 30); + SessionProviderImpl sessionProvider = new SessionProviderImpl(null, null); + QuotaComponents quotaComponents = QuotaComponents.disabled(sessionProvider, mapperFactory); + + MessageIdManager messageIdManager = new StoreMessageIdManager(storeRightManager, mapperFactory + , eventBus, new NoQuotaManager(), mock(QuotaRootResolver.class), PreDeletionHooks.NO_PRE_DELETION_HOOK); + + StoreAttachmentManager storeAttachmentManager = new StoreAttachmentManager(mapperFactory, messageIdManager); + + MessageSearchIndex index = new SimpleMessageSearchIndex(mapperFactory, mapperFactory, new DefaultTextExtractor(), storeAttachmentManager); + + PostgresMessageDAO.Factory postgresMessageDAOFactory = new PostgresMessageDAO.Factory(BLOB_ID_FACTORY, postgresExtension.getExecutorFactory()); + PostgresMailboxMessageDAO.Factory postgresMailboxMessageDAOFactory = new PostgresMailboxMessageDAO.Factory(postgresExtension.getExecutorFactory()); + + eventBus.register(new DeleteMessageListener(blobStore, postgresMailboxMessageDAOFactory, postgresMessageDAOFactory, + ImmutableSet.of())); + + mailboxManager = new PostgresMailboxManager(mapperFactory, sessionProvider, + messageParser, new PostgresMessageId.Factory(), + eventBus, annotationManager, + storeRightManager, quotaComponents, index, new NaiveThreadIdGuessingAlgorithm(), + PreDeletionHooks.NO_PRE_DELETION_HOOK, + new UpdatableTickingClock(Instant.now())); + + MessageParser failingMessageParser = mock(MessageParser.class); + when(failingMessageParser.retrieveAttachments(any(InputStream.class))) + .thenThrow(new RuntimeException("Message parser set to fail")); + + + parseFailingMailboxManager = new PostgresMailboxManager(mapperFactory, sessionProvider, + failingMessageParser, new PostgresMessageId.Factory(), + eventBus, annotationManager, + storeRightManager, quotaComponents, index, new NaiveThreadIdGuessingAlgorithm(), + PreDeletionHooks.NO_PRE_DELETION_HOOK, + new UpdatableTickingClock(Instant.now())); + + super.setUp(); + } + + @Override + protected MailboxManager getMailboxManager() { + return mailboxManager; + } + + @Override + protected MailboxManager getParseFailingMailboxManager() { + return parseFailingMailboxManager; + } + + @Override + protected MailboxSessionMapperFactory getMailboxSessionMapperFactory() { + return mapperFactory; + } + + @Override + protected AttachmentMapperFactory getAttachmentMapperFactory() { + return mapperFactory; + } +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentMapperTest.java new file mode 100644 index 00000000000..68dda51d5ec --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentMapperTest.java @@ -0,0 +1,54 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BlobStore; +import org.apache.james.blob.api.BucketName; +import org.apache.james.blob.api.HashBlobId; +import org.apache.james.blob.memory.MemoryBlobStoreDAO; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresAttachmentDAO; +import org.apache.james.mailbox.store.mail.AttachmentMapper; +import org.apache.james.mailbox.store.mail.model.AttachmentMapperTest; +import org.apache.james.server.blob.deduplication.DeDuplicationBlobStore; +import org.junit.jupiter.api.extension.RegisterExtension; + +class PostgresAttachmentMapperTest extends AttachmentMapperTest { + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresAttachmentModule.MODULE); + + static BlobId.Factory BLOB_ID_FACTORY = new HashBlobId.Factory(); + + @Override + protected AttachmentMapper createAttachmentMapper() { + PostgresAttachmentDAO postgresAttachmentDAO = new PostgresAttachmentDAO(postgresExtension.getPostgresExecutor(), BLOB_ID_FACTORY); + BlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, BLOB_ID_FACTORY); + return new PostgresAttachmentMapper(postgresAttachmentDAO, blobStore); + } + + @Override + protected MessageId generateMessageId() { + return new PostgresMessageId.Factory().generate(); + } +} diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java index c5883b1aac1..9f19c0979e3 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java @@ -30,11 +30,9 @@ import org.apache.james.mailbox.AttachmentManager; import org.apache.james.mailbox.MessageIdManager; import org.apache.james.mailbox.RightManager; -import org.apache.james.mailbox.inmemory.InMemoryMailboxSessionMapperFactory; import org.apache.james.mailbox.store.StoreAttachmentManager; import org.apache.james.mailbox.store.StoreMessageIdManager; import org.apache.james.mailbox.store.StoreRightManager; -import org.apache.james.mailbox.store.mail.AttachmentMapperFactory; import org.apache.james.vacation.api.NotificationRegistry; import org.apache.james.vacation.api.VacationRepository; import org.apache.james.vacation.api.VacationService; @@ -73,7 +71,6 @@ protected void configure() { bind(AttachmentManager.class).to(StoreAttachmentManager.class); bind(StoreMessageIdManager.class).in(Scopes.SINGLETON); bind(StoreAttachmentManager.class).in(Scopes.SINGLETON); - bind(AttachmentMapperFactory.class).to(InMemoryMailboxSessionMapperFactory.class); bind(RightManager.class).to(StoreRightManager.class); bind(StoreRightManager.class).in(Scopes.SINGLETON); diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java index 9886cdbbe97..b248153e625 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java @@ -32,6 +32,7 @@ import org.apache.james.blob.api.BlobReferenceSource; import org.apache.james.events.EventListener; import org.apache.james.mailbox.AttachmentContentLoader; +import org.apache.james.mailbox.AttachmentManager; import org.apache.james.mailbox.Authenticator; import org.apache.james.mailbox.Authorizator; import org.apache.james.mailbox.MailboxManager; @@ -46,7 +47,6 @@ import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.MessageId; import org.apache.james.mailbox.postgres.DeleteMessageListener; -import org.apache.james.mailbox.postgres.PostgresAttachmentContentLoader; import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; import org.apache.james.mailbox.postgres.PostgresMailboxId; import org.apache.james.mailbox.postgres.PostgresMailboxManager; @@ -58,12 +58,14 @@ import org.apache.james.mailbox.store.MailboxSessionMapperFactory; import org.apache.james.mailbox.store.NoMailboxPathLocker; import org.apache.james.mailbox.store.SessionProviderImpl; +import org.apache.james.mailbox.store.StoreAttachmentManager; import org.apache.james.mailbox.store.StoreMailboxManager; import org.apache.james.mailbox.store.StoreMessageIdManager; import org.apache.james.mailbox.store.StoreRightManager; import org.apache.james.mailbox.store.StoreSubscriptionManager; import org.apache.james.mailbox.store.event.MailboxAnnotationListener; import org.apache.james.mailbox.store.event.MailboxSubscriptionListener; +import org.apache.james.mailbox.store.mail.AttachmentMapperFactory; import org.apache.james.mailbox.store.mail.MailboxMapperFactory; import org.apache.james.mailbox.store.mail.MessageMapperFactory; import org.apache.james.mailbox.store.mail.NaiveThreadIdGuessingAlgorithm; @@ -125,6 +127,9 @@ protected void configure() { bind(AttachmentContentLoader.class).to(PostgresAttachmentContentLoader.class); bind(MessageIdManager.class).to(StoreMessageIdManager.class); bind(RightManager.class).to(StoreRightManager.class); + bind(AttachmentManager.class).to(StoreAttachmentManager.class); + bind(AttachmentContentLoader.class).to(AttachmentManager.class); + bind(AttachmentMapperFactory.class).to(PostgresMailboxSessionMapperFactory.class); bind(ReIndexer.class).to(ReIndexerImpl.class); From 1314c650cdb0f41c669ec4055a06758b4dc54c41 Mon Sep 17 00:00:00 2001 From: vttran Date: Thu, 18 Jan 2024 11:06:35 +0700 Subject: [PATCH 197/341] JAMES-2586 Implement Postgres Attachment Blob reference source --- ...PostgresAttachmentBlobReferenceSource.java | 53 +++++++++ ...gresAttachmentBlobReferenceSourceTest.java | 111 ++++++++++++++++++ .../mailbox/PostgresMailboxModule.java | 4 +- .../modules/data/PostgresCommonModule.java | 2 +- 4 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentBlobReferenceSource.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentBlobReferenceSourceTest.java diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentBlobReferenceSource.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentBlobReferenceSource.java new file mode 100644 index 00000000000..79bc7547076 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentBlobReferenceSource.java @@ -0,0 +1,53 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BlobReferenceSource; +import org.apache.james.mailbox.postgres.mail.dao.PostgresAttachmentDAO; + +import reactor.core.publisher.Flux; + +public class PostgresAttachmentBlobReferenceSource implements BlobReferenceSource { + + private final PostgresAttachmentDAO postgresAttachmentDAO; + + @Inject + @Singleton + public PostgresAttachmentBlobReferenceSource(@Named(PostgresExecutor.NON_RLS_INJECT) PostgresExecutor postgresExecutor, + BlobId.Factory bloIdFactory) { + this(new PostgresAttachmentDAO(postgresExecutor, bloIdFactory)); + } + + public PostgresAttachmentBlobReferenceSource(PostgresAttachmentDAO postgresAttachmentDAO) { + this.postgresAttachmentDAO = postgresAttachmentDAO; + } + + @Override + public Flux listReferencedBlobs() { + return postgresAttachmentDAO.listBlobs(); + } + +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentBlobReferenceSourceTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentBlobReferenceSourceTest.java new file mode 100644 index 00000000000..cfe0be56009 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentBlobReferenceSourceTest.java @@ -0,0 +1,111 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.HashBlobId; +import org.apache.james.mailbox.model.AttachmentId; +import org.apache.james.mailbox.model.AttachmentMetadata; +import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresAttachmentDAO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +class PostgresAttachmentBlobReferenceSourceTest { + + private static final AttachmentId ATTACHMENT_ID = AttachmentId.from("id1"); + private static final AttachmentId ATTACHMENT_ID_2 = AttachmentId.from("id2"); + private static final HashBlobId.Factory BLOB_ID_FACTORY = new HashBlobId.Factory(); + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); + + private PostgresAttachmentBlobReferenceSource testee; + + private PostgresAttachmentDAO postgresAttachmentDAO; + + @BeforeEach + void beforeEach() { + HashBlobId.Factory blobIdFactory = new HashBlobId.Factory(); + postgresAttachmentDAO = new PostgresAttachmentDAO(postgresExtension.getPostgresExecutor(), + blobIdFactory); + testee = new PostgresAttachmentBlobReferenceSource(postgresAttachmentDAO); + } + + @Test + void blobReferencesShouldBeEmptyByDefault() { + assertThat(testee.listReferencedBlobs().collectList().block()) + .isEmpty(); + } + + @Test + void blobReferencesShouldReturnAllValues() { + AttachmentMetadata attachment1 = AttachmentMetadata.builder() + .attachmentId(ATTACHMENT_ID) + .messageId(new PostgresMessageId.Factory().generate()) + .type("application/json") + .size(36) + .build(); + BlobId blobId1 = BLOB_ID_FACTORY.from("blobId"); + + postgresAttachmentDAO.storeAttachment(attachment1, blobId1).block(); + + AttachmentMetadata attachment2 = AttachmentMetadata.builder() + .attachmentId(ATTACHMENT_ID_2) + .messageId(new PostgresMessageId.Factory().generate()) + .type("application/json") + .size(36) + .build(); + BlobId blobId2 = BLOB_ID_FACTORY.from("blobId"); + postgresAttachmentDAO.storeAttachment(attachment2, blobId2).block(); + + assertThat(testee.listReferencedBlobs().collectList().block()) + .containsOnly(blobId1, blobId2); + } + + @Test + void blobReferencesShouldReturnDuplicates() { + AttachmentMetadata attachment1 = AttachmentMetadata.builder() + .attachmentId(ATTACHMENT_ID) + .messageId(new PostgresMessageId.Factory().generate()) + .type("application/json") + .size(36) + .build(); + BlobId blobId = BLOB_ID_FACTORY.from("blobId"); + postgresAttachmentDAO.storeAttachment(attachment1, blobId).block(); + + AttachmentMetadata attachment2 = AttachmentMetadata.builder() + .attachmentId(ATTACHMENT_ID_2) + .messageId(new PostgresMessageId.Factory().generate()) + .type("application/json") + .size(36) + .build(); + postgresAttachmentDAO.storeAttachment(attachment2, blobId).block(); + + assertThat(testee.listReferencedBlobs().collectList().block()) + .hasSize(2) + .containsOnly(blobId); + } +} diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java index b248153e625..58767aba194 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java @@ -52,6 +52,8 @@ import org.apache.james.mailbox.postgres.PostgresMailboxManager; import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.mailbox.postgres.mail.PostgresAttachmentBlobReferenceSource; +import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; import org.apache.james.mailbox.postgres.mail.PostgresMessageBlobReferenceSource; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.store.MailboxManagerConfiguration; @@ -124,7 +126,6 @@ protected void configure() { bind(Authorizator.class).to(UserRepositoryAuthorizator.class); bind(MailboxId.Factory.class).to(PostgresMailboxId.Factory.class); bind(MailboxACLResolver.class).to(UnionMailboxACLResolver.class); - bind(AttachmentContentLoader.class).to(PostgresAttachmentContentLoader.class); bind(MessageIdManager.class).to(StoreMessageIdManager.class); bind(RightManager.class).to(StoreRightManager.class); bind(AttachmentManager.class).to(StoreAttachmentManager.class); @@ -162,6 +163,7 @@ protected void configure() { Multibinder blobReferenceSourceMultibinder = Multibinder.newSetBinder(binder(), BlobReferenceSource.class); blobReferenceSourceMultibinder.addBinding().to(PostgresMessageBlobReferenceSource.class); + blobReferenceSourceMultibinder.addBinding().to(PostgresAttachmentBlobReferenceSource.class); } @Singleton diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index 5a2950e484b..3715e59efce 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -90,7 +90,7 @@ JamesPostgresConnectionFactory provideJamesPostgresConnectionFactory(PostgresCon @Singleton JamesPostgresConnectionFactory provideJamesPostgresConnectionFactoryWithRLSBypass(PostgresConfiguration postgresConfiguration, @Named(JamesPostgresConnectionFactory.NON_RLS_INJECT) ConnectionFactory connectionFactory) { - LOGGER.info("Implementation for PostgreSQL connection factory: {}", SinglePostgresConnectionFactory.class.getName()); + LOGGER.info("Implementation for PostgresSQL connection factory: {}", SinglePostgresConnectionFactory.class.getName()); return new SinglePostgresConnectionFactory(Mono.from(connectionFactory.create()).block()); } From 5d7db9ee3df5564f5fda3a9a17afbb05a8efd046 Mon Sep 17 00:00:00 2001 From: vttran Date: Thu, 18 Jan 2024 12:12:27 +0700 Subject: [PATCH 198/341] JAMES-2586 - Delete attachment in DeleteMessageListener --- .../postgres/DeleteMessageListener.java | 27 ++++++- .../PostgresMailboxSessionMapperFactory.java | 3 +- .../mail/PostgresAttachmentModule.java | 6 ++ .../mail/dao/PostgresAttachmentDAO.java | 32 +++++++- .../DeleteMessageListenerContract.java | 81 +++++++++++++++++++ .../postgres/DeleteMessageListenerTest.java | 62 +++++++++++++- .../DeleteMessageListenerWithRLSTest.java | 64 ++++++++++++++- .../PostgresMailboxManagerAttachmentTest.java | 5 +- .../PostgresMailboxManagerProvider.java | 2 +- .../mailbox/PostgresMailboxModule.java | 1 - 10 files changed, 270 insertions(+), 13 deletions(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java index f3c44dc5ff4..367578d1e9c 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java @@ -34,8 +34,10 @@ import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.MessageMetaData; import org.apache.james.mailbox.postgres.mail.MessageRepresentation; +import org.apache.james.mailbox.postgres.mail.dao.PostgresAttachmentDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.apache.james.util.ReactorUtils; import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; @@ -57,17 +59,19 @@ public static class DeleteMessageListenerGroup extends Group { private final PostgresMessageDAO.Factory messageDAOFactory; private final PostgresMailboxMessageDAO.Factory mailboxMessageDAOFactory; - + private final PostgresAttachmentDAO.Factory attachmentDAOFactory; @Inject public DeleteMessageListener(BlobStore blobStore, PostgresMailboxMessageDAO.Factory mailboxMessageDAOFactory, PostgresMessageDAO.Factory messageDAOFactory, + PostgresAttachmentDAO.Factory attachmentDAOFactory, Set deletionCallbackList) { this.messageDAOFactory = messageDAOFactory; this.mailboxMessageDAOFactory = mailboxMessageDAOFactory; this.blobStore = blobStore; this.deletionCallbackList = deletionCallbackList; + this.attachmentDAOFactory = attachmentDAOFactory; } @Override @@ -96,9 +100,10 @@ public Publisher reactiveEvent(Event event) { private Mono handleMailboxDeletion(MailboxDeletion event) { PostgresMessageDAO postgresMessageDAO = messageDAOFactory.create(event.getUsername().getDomainPart()); PostgresMailboxMessageDAO postgresMailboxMessageDAO = mailboxMessageDAOFactory.create(event.getUsername().getDomainPart()); + PostgresAttachmentDAO attachmentDAO = attachmentDAOFactory.create(event.getUsername().getDomainPart()); return postgresMailboxMessageDAO.deleteByMailboxId((PostgresMailboxId) event.getMailboxId()) - .flatMap(msgId -> handleMessageDeletion(postgresMessageDAO, postgresMailboxMessageDAO, msgId, event.getMailboxId(), event.getMailboxPath().getUser()), + .flatMap(msgId -> handleMessageDeletion(postgresMessageDAO, postgresMailboxMessageDAO, attachmentDAO, msgId, event.getMailboxId(), event.getMailboxPath().getUser()), LOW_CONCURRENCY) .then(); } @@ -106,25 +111,28 @@ private Mono handleMailboxDeletion(MailboxDeletion event) { private Mono handleMessageDeletion(Expunged event) { PostgresMessageDAO postgresMessageDAO = messageDAOFactory.create(event.getUsername().getDomainPart()); PostgresMailboxMessageDAO postgresMailboxMessageDAO = mailboxMessageDAOFactory.create(event.getUsername().getDomainPart()); + PostgresAttachmentDAO attachmentDAO = attachmentDAOFactory.create(event.getUsername().getDomainPart()); return Flux.fromIterable(event.getExpunged() .values()) .map(MessageMetaData::getMessageId) .map(PostgresMessageId.class::cast) - .flatMap(msgId -> handleMessageDeletion(postgresMessageDAO, postgresMailboxMessageDAO, msgId, event.getMailboxId(), event.getMailboxPath().getUser()), LOW_CONCURRENCY) + .flatMap(msgId -> handleMessageDeletion(postgresMessageDAO, postgresMailboxMessageDAO, attachmentDAO, msgId, event.getMailboxId(), event.getMailboxPath().getUser()), LOW_CONCURRENCY) .then(); } private Mono handleMessageDeletion(PostgresMessageDAO postgresMessageDAO, PostgresMailboxMessageDAO postgresMailboxMessageDAO, + PostgresAttachmentDAO attachmentDAO, PostgresMessageId messageId, - MailboxId mailboxId, + MailboxId mailboxId, Username owner) { return Mono.just(messageId) .filterWhen(msgId -> isUnreferenced(messageId, postgresMailboxMessageDAO)) .flatMap(msgId -> postgresMessageDAO.retrieveMessage(messageId) .flatMap(executeDeletionCallbacks(mailboxId, owner)) .then(deleteBodyBlob(msgId, postgresMessageDAO)) + .then(deleteAttachment(messageId, attachmentDAO)) .then(postgresMessageDAO.deleteByMessageId(msgId))); } @@ -146,4 +154,15 @@ private Mono isUnreferenced(PostgresMessageId id, PostgresMailboxMessag .map(count -> true) .defaultIfEmpty(false); } + + private Mono deleteAttachment(PostgresMessageId messageId, PostgresAttachmentDAO attachmentDAO) { + return deleteAttachmentBlobs(messageId, attachmentDAO) + .then(attachmentDAO.deleteByMessageId(messageId)); + } + + private Mono deleteAttachmentBlobs(PostgresMessageId messageId, PostgresAttachmentDAO attachmentDAO) { + return attachmentDAO.listBlobsByMessageId(messageId) + .flatMap(blobId -> Mono.from(blobStore.delete(blobStore.getDefaultBucketName(), blobId)), ReactorUtils.DEFAULT_CONCURRENCY) + .then(); + } } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java index 4a904faf201..690b5ef7ba6 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java @@ -130,7 +130,8 @@ public AttachmentMapper getAttachmentMapper(MailboxSession session) { protected DeleteMessageListener deleteMessageListener() { PostgresMessageDAO.Factory postgresMessageDAOFactory = new PostgresMessageDAO.Factory(blobIdFactory, executorFactory); PostgresMailboxMessageDAO.Factory postgresMailboxMessageDAOFactory = new PostgresMailboxMessageDAO.Factory(executorFactory); + PostgresAttachmentDAO.Factory attachmentDAOFactory = new PostgresAttachmentDAO.Factory(executorFactory, blobIdFactory); - return new DeleteMessageListener(blobStore, postgresMailboxMessageDAOFactory, postgresMessageDAOFactory, ImmutableSet.of()); + return new DeleteMessageListener(blobStore, postgresMailboxMessageDAOFactory, postgresMessageDAOFactory, attachmentDAOFactory, ImmutableSet.of()); } } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentModule.java index e085f608517..2bc4e0b16b2 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentModule.java @@ -21,6 +21,7 @@ import java.util.UUID; +import org.apache.james.backends.postgres.PostgresIndex; import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.backends.postgres.PostgresTable; import org.jooq.Field; @@ -50,9 +51,14 @@ interface PostgresAttachmentTable { .constraint(DSL.primaryKey(ID)))) .supportsRowLevelSecurity() .build(); + + PostgresIndex MESSAGE_ID_INDEX = PostgresIndex.name("attachment_message_id_index") + .createIndexStep((dsl, indexName) -> dsl.createIndexIfNotExists(indexName) + .on(TABLE_NAME, MESSAGE_ID)); } PostgresModule MODULE = PostgresModule.builder() .addTable(PostgresAttachmentTable.TABLE) + .addIndex(PostgresAttachmentTable.MESSAGE_ID_INDEX) .build(); } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresAttachmentDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresAttachmentDAO.java index 910afddb4c2..15f7f3ec62a 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresAttachmentDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresAttachmentDAO.java @@ -19,10 +19,15 @@ package org.apache.james.mailbox.postgres.mail.dao; +import java.util.Optional; + +import javax.inject.Inject; +import javax.inject.Singleton; import org.apache.commons.lang3.tuple.Pair; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.blob.api.BlobId; +import org.apache.james.core.Domain; import org.apache.james.mailbox.model.AttachmentId; import org.apache.james.mailbox.model.AttachmentMetadata; import org.apache.james.mailbox.postgres.PostgresMessageId; @@ -33,6 +38,22 @@ public class PostgresAttachmentDAO { + public static class Factory { + private final PostgresExecutor.Factory executorFactory; + private final BlobId.Factory blobIdFactory; + + @Inject + @Singleton + public Factory(PostgresExecutor.Factory executorFactory, BlobId.Factory blobIdFactory) { + this.executorFactory = executorFactory; + this.blobIdFactory = blobIdFactory; + } + + public PostgresAttachmentDAO create(Optional domain) { + return new PostgresAttachmentDAO(executorFactory.create(domain), blobIdFactory); + } + } + private final PostgresExecutor postgresExecutor; private final BlobId.Factory blobIdFactory; @@ -68,9 +89,16 @@ public Mono storeAttachment(AttachmentMetadata attachment, BlobId blobId) .set(PostgresAttachmentTable.SIZE, attachment.getSize()))); } - public Mono delete(AttachmentId attachmentId) { + public Mono deleteByMessageId(PostgresMessageId messageId) { return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(PostgresAttachmentTable.TABLE_NAME) - .where(PostgresAttachmentTable.ID.eq(attachmentId.asUUID())))); + .where(PostgresAttachmentTable.MESSAGE_ID.eq(messageId.asUuid())))); + } + + public Flux listBlobsByMessageId(PostgresMessageId messageId) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(PostgresAttachmentTable.BLOB_ID) + .from(PostgresAttachmentTable.TABLE_NAME) + .where(PostgresAttachmentTable.MESSAGE_ID.eq(messageId.asUuid())))) + .map(row -> blobIdFactory.from(row.get(PostgresAttachmentTable.BLOB_ID))); } public Flux listBlobs() { diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerContract.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerContract.java index 5555c5c26ad..36a10cb33f7 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerContract.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerContract.java @@ -19,24 +19,34 @@ package org.apache.james.mailbox.postgres; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.SoftAssertions.assertSoftly; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import java.util.UUID; +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BlobStore; +import org.apache.james.blob.api.ObjectNotFoundException; import org.apache.james.core.Username; import org.apache.james.mailbox.MailboxSession; import org.apache.james.mailbox.MessageManager; +import org.apache.james.mailbox.model.AttachmentId; import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.MailboxPath; import org.apache.james.mailbox.model.MessageRange; +import org.apache.james.mailbox.postgres.mail.dao.PostgresAttachmentDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.apache.james.server.blob.deduplication.DeDuplicationBlobStore; import org.apache.james.util.ClassLoaderUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import com.google.common.collect.ImmutableList; +import reactor.core.publisher.Mono; + public abstract class DeleteMessageListenerContract { private MailboxSession session; @@ -47,10 +57,19 @@ public abstract class DeleteMessageListenerContract { private PostgresMessageDAO postgresMessageDAO; private PostgresMailboxMessageDAO postgresMailboxMessageDAO; + private PostgresAttachmentDAO attachmentDAO; + private BlobStore blobStore; + abstract PostgresMailboxManager provideMailboxManager(); + abstract PostgresMessageDAO providePostgresMessageDAO(); + abstract PostgresMailboxMessageDAO providePostgresMailboxMessageDAO(); + abstract PostgresAttachmentDAO attachmentDAO(); + + abstract BlobStore blobStore(); + @BeforeEach void setUp() throws Exception { mailboxManager = provideMailboxManager(); @@ -65,6 +84,8 @@ void setUp() throws Exception { postgresMessageDAO = providePostgresMessageDAO(); postgresMailboxMessageDAO = providePostgresMailboxMessageDAO(); + attachmentDAO = attachmentDAO(); + blobStore = blobStore(); } protected Username getUsername() { @@ -76,6 +97,8 @@ void deleteMailboxShouldDeleteUnreferencedMessageMetadata() throws Exception { MessageManager.AppendResult appendResult = inboxManager.appendMessage(MessageManager.AppendCommand.builder() .build(ClassLoaderUtils.getSystemResourceAsByteArray("eml/emailWithOnlyAttachment.eml")), session); + AttachmentId attachmentId = appendResult.getMessageAttachments().get(0).getAttachment().getAttachmentId(); + mailboxManager.deleteMailbox(inbox, session); assertSoftly(softly -> { @@ -87,6 +110,9 @@ void deleteMailboxShouldDeleteUnreferencedMessageMetadata() throws Exception { softly.assertThat(postgresMailboxMessageDAO.countTotalMessagesByMailboxId(mailboxId).block()) .isEqualTo(0); + + softly.assertThat(attachmentDAO.getAttachment(attachmentId).blockOptional()) + .isEmpty(); }); } @@ -95,6 +121,7 @@ void deleteMailboxShouldNotDeleteReferencedMessageMetadata() throws Exception { MessageManager.AppendResult appendResult = inboxManager.appendMessage(MessageManager.AppendCommand.builder() .build(ClassLoaderUtils.getSystemResourceAsByteArray("eml/emailWithOnlyAttachment.eml")), session); mailboxManager.copyMessages(MessageRange.all(), inboxManager.getId(), otherBoxManager.getId(), session); + AttachmentId attachmentId = appendResult.getMessageAttachments().get(0).getAttachment().getAttachmentId(); mailboxManager.deleteMailbox(inbox, session); @@ -107,6 +134,9 @@ void deleteMailboxShouldNotDeleteReferencedMessageMetadata() throws Exception { softly.assertThat(postgresMailboxMessageDAO.countTotalMessagesByMailboxId((PostgresMailboxId) otherBoxManager.getId()) .block()) .isEqualTo(1); + + softly.assertThat(attachmentDAO.getAttachment(attachmentId).blockOptional()) + .isNotEmpty(); }); } @@ -114,6 +144,7 @@ void deleteMailboxShouldNotDeleteReferencedMessageMetadata() throws Exception { void deleteMessageInMailboxShouldDeleteUnreferencedMessageMetadata() throws Exception { MessageManager.AppendResult appendResult = inboxManager.appendMessage(MessageManager.AppendCommand.builder() .build(ClassLoaderUtils.getSystemResourceAsByteArray("eml/emailWithOnlyAttachment.eml")), session); + AttachmentId attachmentId = appendResult.getMessageAttachments().get(0).getAttachment().getAttachmentId(); inboxManager.delete(ImmutableList.of(appendResult.getId().getUid()), session); @@ -122,6 +153,9 @@ void deleteMessageInMailboxShouldDeleteUnreferencedMessageMetadata() throws Exce softly.assertThat(postgresMessageDAO.getBodyBlobId(messageId).blockOptional()) .isEmpty(); + + softly.assertThat(attachmentDAO.getAttachment(attachmentId).blockOptional()) + .isEmpty(); }); } @@ -130,6 +164,7 @@ void deleteMessageInMailboxShouldNotDeleteReferencedMessageMetadata() throws Exc MessageManager.AppendResult appendResult = inboxManager.appendMessage(MessageManager.AppendCommand.builder() .build(ClassLoaderUtils.getSystemResourceAsByteArray("eml/emailWithOnlyAttachment.eml")), session); mailboxManager.copyMessages(MessageRange.all(), inboxManager.getId(), otherBoxManager.getId(), session); + AttachmentId attachmentId = appendResult.getMessageAttachments().get(0).getAttachment().getAttachmentId(); inboxManager.delete(ImmutableList.of(appendResult.getId().getUid()), session); PostgresMessageId messageId = (PostgresMessageId) appendResult.getId().getMessageId(); @@ -141,6 +176,52 @@ void deleteMessageInMailboxShouldNotDeleteReferencedMessageMetadata() throws Exc softly.assertThat(postgresMailboxMessageDAO.countTotalMessagesByMailboxId((PostgresMailboxId) otherBoxManager.getId()) .block()) .isEqualTo(1); + + softly.assertThat(attachmentDAO.getAttachment(attachmentId).blockOptional()) + .isNotEmpty(); + }); + } + + @Test + void deleteMessageListenerShouldDeleteUnreferencedBlob() throws Exception { + assumeTrue(!(blobStore instanceof DeDuplicationBlobStore)); + + MessageManager.AppendResult appendResult = inboxManager.appendMessage(MessageManager.AppendCommand.builder() + .build(ClassLoaderUtils.getSystemResourceAsByteArray("eml/emailWithOnlyAttachment.eml")), session); + AttachmentId attachmentId = appendResult.getMessageAttachments().get(0).getAttachment().getAttachmentId(); + + BlobId attachmentBlobId = attachmentDAO.getAttachment(attachmentId).block().getRight(); + BlobId messageBodyBlobId = postgresMessageDAO.getBodyBlobId((PostgresMessageId) appendResult.getId().getMessageId()).block(); + + inboxManager.delete(ImmutableList.of(appendResult.getId().getUid()), session); + + assertSoftly(softly -> { + softly.assertThatThrownBy(() -> Mono.from(blobStore.readReactive(blobStore.getDefaultBucketName(), attachmentBlobId)).block()) + .isInstanceOf(ObjectNotFoundException.class); + softly.assertThatThrownBy(() -> Mono.from(blobStore.readReactive(blobStore.getDefaultBucketName(), messageBodyBlobId)).block()) + .isInstanceOf(ObjectNotFoundException.class); + }); + } + + @Test + void deleteMessageListenerShouldNotDeleteReferencedBlob() throws Exception { + assumeTrue(!(blobStore instanceof DeDuplicationBlobStore)); + + MessageManager.AppendResult appendResult = inboxManager.appendMessage(MessageManager.AppendCommand.builder() + .build(ClassLoaderUtils.getSystemResourceAsByteArray("eml/emailWithOnlyAttachment.eml")), session); + BlobId messageBodyBlobId = postgresMessageDAO.getBodyBlobId((PostgresMessageId) appendResult.getId().getMessageId()).block(); + mailboxManager.copyMessages(MessageRange.all(), inboxManager.getId(), otherBoxManager.getId(), session); + + AttachmentId attachmentId = appendResult.getMessageAttachments().get(0).getAttachment().getAttachmentId(); + BlobId attachmentBlobId = attachmentDAO.getAttachment(attachmentId).block().getRight(); + + inboxManager.delete(ImmutableList.of(appendResult.getId().getUid()), session); + + assertSoftly(softly -> { + assertThat(Mono.from(blobStore.readReactive(blobStore.getDefaultBucketName(), attachmentBlobId)).blockOptional()) + .isNotEmpty(); + assertThat(Mono.from(blobStore.readReactive(blobStore.getDefaultBucketName(), messageBodyBlobId)).blockOptional()) + .isNotEmpty(); }); } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java index 2e1a14ea16f..47badb5c7b4 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java @@ -21,10 +21,35 @@ import static org.apache.james.mailbox.postgres.PostgresMailboxManagerProvider.BLOB_ID_FACTORY; +import java.time.Clock; +import java.time.Instant; + import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.blob.api.BlobStore; +import org.apache.james.blob.api.BucketName; +import org.apache.james.blob.memory.MemoryBlobStoreDAO; +import org.apache.james.events.EventBusTestFixture; +import org.apache.james.events.InVMEventBus; +import org.apache.james.events.MemoryEventDeadLetters; +import org.apache.james.events.delivery.InVmEventDelivery; +import org.apache.james.mailbox.acl.MailboxACLResolver; +import org.apache.james.mailbox.acl.UnionMailboxACLResolver; +import org.apache.james.mailbox.postgres.mail.dao.PostgresAttachmentDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.store.PreDeletionHooks; +import org.apache.james.mailbox.store.SessionProviderImpl; +import org.apache.james.mailbox.store.StoreMailboxAnnotationManager; +import org.apache.james.mailbox.store.StoreRightManager; +import org.apache.james.mailbox.store.extractor.DefaultTextExtractor; +import org.apache.james.mailbox.store.mail.NaiveThreadIdGuessingAlgorithm; +import org.apache.james.mailbox.store.mail.model.impl.MessageParser; +import org.apache.james.mailbox.store.quota.QuotaComponents; +import org.apache.james.mailbox.store.search.MessageSearchIndex; +import org.apache.james.mailbox.store.search.SimpleMessageSearchIndex; +import org.apache.james.metrics.tests.RecordingMetricFactory; +import org.apache.james.server.blob.deduplication.PassThroughBlobStore; +import org.apache.james.utils.UpdatableTickingClock; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.extension.RegisterExtension; @@ -33,10 +58,35 @@ public class DeleteMessageListenerTest extends DeleteMessageListenerContract { static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); private static PostgresMailboxManager mailboxManager; + private static BlobStore blobStore; @BeforeAll static void beforeAll() { - mailboxManager = PostgresMailboxManagerProvider.provideMailboxManager(postgresExtension, PreDeletionHooks.NO_PRE_DELETION_HOOK); + blobStore = new PassThroughBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, BLOB_ID_FACTORY); + + PostgresMailboxSessionMapperFactory mapperFactory = new PostgresMailboxSessionMapperFactory( + postgresExtension.getExecutorFactory(), + Clock.systemUTC(), + blobStore, + BLOB_ID_FACTORY); + + MailboxACLResolver aclResolver = new UnionMailboxACLResolver(); + MessageParser messageParser = new MessageParser(); + + InVMEventBus eventBus = new InVMEventBus(new InVmEventDelivery(new RecordingMetricFactory()), EventBusTestFixture.RETRY_BACKOFF_CONFIGURATION, new MemoryEventDeadLetters()); + StoreRightManager storeRightManager = new StoreRightManager(mapperFactory, aclResolver, eventBus); + StoreMailboxAnnotationManager annotationManager = new StoreMailboxAnnotationManager(mapperFactory, storeRightManager, 3, 30); + SessionProviderImpl sessionProvider = new SessionProviderImpl(null, null); + QuotaComponents quotaComponents = QuotaComponents.disabled(sessionProvider, mapperFactory); + MessageSearchIndex index = new SimpleMessageSearchIndex(mapperFactory, mapperFactory, new DefaultTextExtractor(), new UnsupportAttachmentContentLoader()); + + eventBus.register(mapperFactory.deleteMessageListener()); + + mailboxManager = new PostgresMailboxManager(mapperFactory, sessionProvider, + messageParser, new PostgresMessageId.Factory(), + eventBus, annotationManager, + storeRightManager, quotaComponents, index, new NaiveThreadIdGuessingAlgorithm(), + PreDeletionHooks.NO_PRE_DELETION_HOOK, new UpdatableTickingClock(Instant.now())); } @Override @@ -53,4 +103,14 @@ PostgresMessageDAO providePostgresMessageDAO() { PostgresMailboxMessageDAO providePostgresMailboxMessageDAO() { return new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); } + + @Override + PostgresAttachmentDAO attachmentDAO() { + return new PostgresAttachmentDAO(postgresExtension.getPostgresExecutor(), BLOB_ID_FACTORY); + } + + @Override + BlobStore blobStore() { + return blobStore; + } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java index 822a454bfa4..d8dabc90b93 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java @@ -21,13 +21,39 @@ import static org.apache.james.mailbox.postgres.PostgresMailboxManagerProvider.BLOB_ID_FACTORY; +import java.time.Clock; +import java.time.Instant; import java.util.UUID; import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BlobStore; +import org.apache.james.blob.api.BucketName; +import org.apache.james.blob.api.HashBlobId; +import org.apache.james.blob.memory.MemoryBlobStoreDAO; import org.apache.james.core.Username; +import org.apache.james.events.EventBusTestFixture; +import org.apache.james.events.InVMEventBus; +import org.apache.james.events.MemoryEventDeadLetters; +import org.apache.james.events.delivery.InVmEventDelivery; +import org.apache.james.mailbox.acl.MailboxACLResolver; +import org.apache.james.mailbox.acl.UnionMailboxACLResolver; +import org.apache.james.mailbox.postgres.mail.dao.PostgresAttachmentDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.store.PreDeletionHooks; +import org.apache.james.mailbox.store.SessionProviderImpl; +import org.apache.james.mailbox.store.StoreMailboxAnnotationManager; +import org.apache.james.mailbox.store.StoreRightManager; +import org.apache.james.mailbox.store.extractor.DefaultTextExtractor; +import org.apache.james.mailbox.store.mail.NaiveThreadIdGuessingAlgorithm; +import org.apache.james.mailbox.store.mail.model.impl.MessageParser; +import org.apache.james.mailbox.store.quota.QuotaComponents; +import org.apache.james.mailbox.store.search.MessageSearchIndex; +import org.apache.james.mailbox.store.search.SimpleMessageSearchIndex; +import org.apache.james.metrics.tests.RecordingMetricFactory; +import org.apache.james.server.blob.deduplication.PassThroughBlobStore; +import org.apache.james.utils.UpdatableTickingClock; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.extension.RegisterExtension; @@ -37,10 +63,36 @@ public class DeleteMessageListenerWithRLSTest extends DeleteMessageListenerContr static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); private static PostgresMailboxManager mailboxManager; + private static BlobStore blobStore; @BeforeAll static void beforeAll() { - mailboxManager = PostgresMailboxManagerProvider.provideMailboxManager(postgresExtension, PreDeletionHooks.NO_PRE_DELETION_HOOK); + blobStore = new PassThroughBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, BLOB_ID_FACTORY); + BlobId.Factory blobIdFactory = new HashBlobId.Factory(); + + PostgresMailboxSessionMapperFactory mapperFactory = new PostgresMailboxSessionMapperFactory( + postgresExtension.getExecutorFactory(), + Clock.systemUTC(), + blobStore, + blobIdFactory); + + MailboxACLResolver aclResolver = new UnionMailboxACLResolver(); + MessageParser messageParser = new MessageParser(); + + InVMEventBus eventBus = new InVMEventBus(new InVmEventDelivery(new RecordingMetricFactory()), EventBusTestFixture.RETRY_BACKOFF_CONFIGURATION, new MemoryEventDeadLetters()); + StoreRightManager storeRightManager = new StoreRightManager(mapperFactory, aclResolver, eventBus); + StoreMailboxAnnotationManager annotationManager = new StoreMailboxAnnotationManager(mapperFactory, storeRightManager, 3, 30); + SessionProviderImpl sessionProvider = new SessionProviderImpl(null, null); + QuotaComponents quotaComponents = QuotaComponents.disabled(sessionProvider, mapperFactory); + MessageSearchIndex index = new SimpleMessageSearchIndex(mapperFactory, mapperFactory, new DefaultTextExtractor(), new UnsupportAttachmentContentLoader()); + + eventBus.register(mapperFactory.deleteMessageListener()); + + mailboxManager = new PostgresMailboxManager(mapperFactory, sessionProvider, + messageParser, new PostgresMessageId.Factory(), + eventBus, annotationManager, + storeRightManager, quotaComponents, index, new NaiveThreadIdGuessingAlgorithm(), + PreDeletionHooks.NO_PRE_DELETION_HOOK, new UpdatableTickingClock(Instant.now())); } @Override @@ -58,6 +110,16 @@ PostgresMailboxMessageDAO providePostgresMailboxMessageDAO() { return new PostgresMailboxMessageDAO(postgresExtension.getExecutorFactory().create(getUsername().getDomainPart())); } + @Override + PostgresAttachmentDAO attachmentDAO() { + return new PostgresAttachmentDAO(postgresExtension.getExecutorFactory().create(getUsername().getDomainPart()), BLOB_ID_FACTORY); + } + + @Override + BlobStore blobStore() { + return blobStore; + } + @Override protected Username getUsername() { return Username.of("userHasDomain" + UUID.randomUUID() + "@domain1.tld"); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerAttachmentTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerAttachmentTest.java index 631ff887701..c52a475dbee 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerAttachmentTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerAttachmentTest.java @@ -40,7 +40,7 @@ import org.apache.james.mailbox.MessageIdManager; import org.apache.james.mailbox.acl.MailboxACLResolver; import org.apache.james.mailbox.acl.UnionMailboxACLResolver; -import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; +import org.apache.james.mailbox.postgres.mail.dao.PostgresAttachmentDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.quota.QuotaRootResolver; @@ -100,9 +100,10 @@ void beforeAll() throws Exception { PostgresMessageDAO.Factory postgresMessageDAOFactory = new PostgresMessageDAO.Factory(BLOB_ID_FACTORY, postgresExtension.getExecutorFactory()); PostgresMailboxMessageDAO.Factory postgresMailboxMessageDAOFactory = new PostgresMailboxMessageDAO.Factory(postgresExtension.getExecutorFactory()); + PostgresAttachmentDAO.Factory attachmentDAOFactory = new PostgresAttachmentDAO.Factory(postgresExtension.getExecutorFactory(), BLOB_ID_FACTORY); eventBus.register(new DeleteMessageListener(blobStore, postgresMailboxMessageDAOFactory, postgresMessageDAOFactory, - ImmutableSet.of())); + attachmentDAOFactory, ImmutableSet.of())); mailboxManager = new PostgresMailboxManager(mapperFactory, sessionProvider, messageParser, new PostgresMessageId.Factory(), diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java index 20507d26498..2736d19ce38 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java @@ -72,7 +72,7 @@ public static PostgresMailboxManager provideMailboxManager(PostgresExtension pos LIMIT_ANNOTATIONS, LIMIT_ANNOTATION_SIZE); SessionProviderImpl sessionProvider = new SessionProviderImpl(noAuthenticator, noAuthorizator); QuotaComponents quotaComponents = QuotaComponents.disabled(sessionProvider, mapperFactory); - MessageSearchIndex index = new SimpleMessageSearchIndex(mapperFactory, mapperFactory, new DefaultTextExtractor(), new PostgresAttachmentContentLoader()); + MessageSearchIndex index = new SimpleMessageSearchIndex(mapperFactory, mapperFactory, new DefaultTextExtractor(), new UnsupportAttachmentContentLoader()); eventBus.register(mapperFactory.deleteMessageListener()); diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java index 58767aba194..555209275c5 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java @@ -53,7 +53,6 @@ import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; import org.apache.james.mailbox.postgres.PostgresMessageId; import org.apache.james.mailbox.postgres.mail.PostgresAttachmentBlobReferenceSource; -import org.apache.james.mailbox.postgres.mail.PostgresMailboxManager; import org.apache.james.mailbox.postgres.mail.PostgresMessageBlobReferenceSource; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.store.MailboxManagerConfiguration; From d42df3f508314776807ef66b8b0b027a3055692f Mon Sep 17 00:00:00 2001 From: hung phan Date: Fri, 19 Jan 2024 17:40:43 +0700 Subject: [PATCH 199/341] JAMES-2586 Webadmin integration tests for postgres --- .../apache/james/PostgresJamesServerMain.java | 12 +- .../PostgresDLPConfigurationStoreModule.java | 35 +++ .../PostgresAuthorizedEndpointsTest.java | 49 +++ ...wProjectionHealthCheckIntegrationTest.java | 48 +++ .../PostgresForwardIntegrationTest.java | 48 +++ .../PostgresJwtFilterIntegrationTest.java | 57 ++++ .../PostgresQuotaSearchIntegrationTest.java | 51 ++++ .../PostgresUnauthorizedEndpointsTest.java | 51 ++++ ...esWebAdminServerBlobGCIntegrationTest.java | 280 ++++++++++++++++++ ...ebAdminServerIntegrationImmutableTest.java | 48 +++ ...PostgresWebAdminServerIntegrationTest.java | 46 +++ .../resources/eml/emailWithOnlyAttachment.eml | 16 + .../src/test/resources/keystore | Bin 0 -> 2245 bytes .../src/test/resources/mailetcontainer.xml | 11 + 14 files changed, 750 insertions(+), 2 deletions(-) create mode 100644 server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDLPConfigurationStoreModule.java create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresAuthorizedEndpointsTest.java create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresFastViewProjectionHealthCheckIntegrationTest.java create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresForwardIntegrationTest.java create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresJwtFilterIntegrationTest.java create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresQuotaSearchIntegrationTest.java create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresUnauthorizedEndpointsTest.java create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresWebAdminServerBlobGCIntegrationTest.java create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresWebAdminServerIntegrationImmutableTest.java create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresWebAdminServerIntegrationTest.java create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/eml/emailWithOnlyAttachment.eml create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/keystore diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index 7c5e85866bd..e3d866bc22a 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -30,6 +30,7 @@ import org.apache.james.modules.RunArgumentsModule; import org.apache.james.modules.blobstore.BlobStoreCacheModulesChooser; import org.apache.james.modules.blobstore.BlobStoreModulesChooser; +import org.apache.james.modules.data.PostgresDLPConfigurationStoreModule; import org.apache.james.modules.data.PostgresDataJmapModule; import org.apache.james.modules.data.PostgresDataModule; import org.apache.james.modules.data.PostgresDelegationStoreModule; @@ -53,6 +54,7 @@ import org.apache.james.modules.protocols.SMTPServerModule; import org.apache.james.modules.queue.activemq.ActiveMQQueueModule; import org.apache.james.modules.queue.rabbitmq.RabbitMQModule; +import org.apache.james.modules.server.DLPRoutesModule; import org.apache.james.modules.server.DataRoutesModules; import org.apache.james.modules.server.InconsistencyQuotasSolvingRoutesModule; import org.apache.james.modules.server.JMXServerModule; @@ -60,9 +62,11 @@ import org.apache.james.modules.server.MailQueueRoutesModule; import org.apache.james.modules.server.MailRepositoriesRoutesModule; import org.apache.james.modules.server.MailboxRoutesModule; +import org.apache.james.modules.server.MailboxesExportRoutesModule; import org.apache.james.modules.server.ReIndexingModule; import org.apache.james.modules.server.SieveRoutesModule; import org.apache.james.modules.server.TaskManagerModule; +import org.apache.james.modules.server.UserIdentityModule; import org.apache.james.modules.server.WebAdminReIndexingTaskSerializationModule; import org.apache.james.modules.server.WebAdminServerModule; import org.apache.james.modules.vault.DeletedMessageVaultRoutesModule; @@ -83,7 +87,10 @@ public class PostgresJamesServerMain implements JamesServerMain { new MailRepositoriesRoutesModule(), new ReIndexingModule(), new SieveRoutesModule(), - new WebAdminReIndexingTaskSerializationModule()); + new WebAdminReIndexingTaskSerializationModule(), + new MailboxesExportRoutesModule(), + new UserIdentityModule(), + new DLPRoutesModule()); private static final Module PROTOCOLS = Modules.combine( new IMAPServerModule(), @@ -105,7 +112,8 @@ public class PostgresJamesServerMain implements JamesServerMain { new SievePostgresRepositoryModules(), new TaskManagerModule(), new MemoryEventStoreModule(), - new TikaMailboxModule()); + new TikaMailboxModule(), + new PostgresDLPConfigurationStoreModule()); public static final Module JMAP = Modules.combine( new PostgresJmapModule(), diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDLPConfigurationStoreModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDLPConfigurationStoreModule.java new file mode 100644 index 00000000000..61c436c57e1 --- /dev/null +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDLPConfigurationStoreModule.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.data; + +import org.apache.james.dlp.api.DLPConfigurationStore; +import org.apache.james.dlp.eventsourcing.EventSourcingDLPConfigurationStore; + +import com.google.inject.AbstractModule; +import com.google.inject.Scopes; + +public class PostgresDLPConfigurationStoreModule extends AbstractModule { + + @Override + protected void configure() { + bind(EventSourcingDLPConfigurationStore.class).in(Scopes.SINGLETON); + bind(DLPConfigurationStore.class).to(EventSourcingDLPConfigurationStore.class); + } +} diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresAuthorizedEndpointsTest.java b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresAuthorizedEndpointsTest.java new file mode 100644 index 00000000000..9e0a2d5ebae --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresAuthorizedEndpointsTest.java @@ -0,0 +1,49 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.webadmin.integration.postgres; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.PostgresJamesConfiguration; +import org.apache.james.PostgresJamesServerMain; +import org.apache.james.SearchConfiguration; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.webadmin.integration.AuthorizedEndpointsTest; +import org.apache.james.webadmin.integration.UnauthorizedModule; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresAuthorizedEndpointsTest extends AuthorizedEndpointsTest { + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.scanning()) + .usersRepository(DEFAULT) + .eventBusImpl(PostgresJamesConfiguration.EventBusImpl.IN_MEMORY) + .build()) + .extension(PostgresExtension.empty()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(new UnauthorizedModule())) + .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) + .build(); +} diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresFastViewProjectionHealthCheckIntegrationTest.java b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresFastViewProjectionHealthCheckIntegrationTest.java new file mode 100644 index 00000000000..6061f0665f4 --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresFastViewProjectionHealthCheckIntegrationTest.java @@ -0,0 +1,48 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.webadmin.integration.postgres; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.PostgresJamesConfiguration; +import org.apache.james.PostgresJamesServerMain; +import org.apache.james.SearchConfiguration; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.modules.TestJMAPServerModule; +import org.apache.james.webadmin.integration.FastViewProjectionHealthCheckIntegrationContract; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresFastViewProjectionHealthCheckIntegrationTest extends FastViewProjectionHealthCheckIntegrationContract { + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.scanning()) + .usersRepository(DEFAULT) + .eventBusImpl(PostgresJamesConfiguration.EventBusImpl.IN_MEMORY) + .build()) + .extension(PostgresExtension.empty()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(new TestJMAPServerModule())) + .build(); +} diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresForwardIntegrationTest.java b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresForwardIntegrationTest.java new file mode 100644 index 00000000000..66f36aee4ab --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresForwardIntegrationTest.java @@ -0,0 +1,48 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.webadmin.integration.postgres; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.PostgresJamesConfiguration; +import org.apache.james.PostgresJamesServerMain; +import org.apache.james.SearchConfiguration; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.modules.TestJMAPServerModule; +import org.apache.james.webadmin.integration.ForwardIntegrationTest; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresForwardIntegrationTest extends ForwardIntegrationTest { + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.scanning()) + .usersRepository(DEFAULT) + .eventBusImpl(PostgresJamesConfiguration.EventBusImpl.IN_MEMORY) + .build()) + .extension(PostgresExtension.empty()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(new TestJMAPServerModule())) + .build(); +} diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresJwtFilterIntegrationTest.java b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresJwtFilterIntegrationTest.java new file mode 100644 index 00000000000..49aa9ed55d4 --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresJwtFilterIntegrationTest.java @@ -0,0 +1,57 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.webadmin.integration.postgres; + +import static org.apache.james.JamesServerExtension.Lifecycle.PER_CLASS; +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.PostgresJamesConfiguration; +import org.apache.james.PostgresJamesServerMain; +import org.apache.james.SearchConfiguration; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.jwt.JwtTokenVerifier; +import org.apache.james.webadmin.authentication.AuthenticationFilter; +import org.apache.james.webadmin.authentication.JwtFilter; +import org.apache.james.webadmin.integration.JwtFilterIntegrationTest; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.google.inject.name.Names; + +public class PostgresJwtFilterIntegrationTest extends JwtFilterIntegrationTest { + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.scanning()) + .usersRepository(DEFAULT) + .eventBusImpl(PostgresJamesConfiguration.EventBusImpl.IN_MEMORY) + .build()) + .extension(PostgresExtension.empty()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(binder -> binder.bind(AuthenticationFilter.class).to(JwtFilter.class)) + .overrideWith(binder -> binder.bind(JwtTokenVerifier.Factory.class) + .annotatedWith(Names.named("webadmin")) + .toInstance(() -> JwtTokenVerifier.create(jwtConfiguration())))) + .lifeCycle(PER_CLASS) + .build(); +} diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresQuotaSearchIntegrationTest.java b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresQuotaSearchIntegrationTest.java new file mode 100644 index 00000000000..d1b027a2a5b --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresQuotaSearchIntegrationTest.java @@ -0,0 +1,51 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.webadmin.integration.postgres; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.PostgresJamesConfiguration; +import org.apache.james.PostgresJamesServerMain; +import org.apache.james.SearchConfiguration; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.modules.TestJMAPServerModule; +import org.apache.james.webadmin.integration.QuotaSearchIntegrationTest; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresQuotaSearchIntegrationTest extends QuotaSearchIntegrationTest { + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.scanning()) + .usersRepository(DEFAULT) + .eventBusImpl(PostgresJamesConfiguration.EventBusImpl.IN_MEMORY) + .build()) + .extension(PostgresExtension.empty()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(new TestJMAPServerModule())) + .build(); + + @Override + protected void awaitSearchUpToDate() { + } +} diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresUnauthorizedEndpointsTest.java b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresUnauthorizedEndpointsTest.java new file mode 100644 index 00000000000..526418f22d2 --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresUnauthorizedEndpointsTest.java @@ -0,0 +1,51 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.webadmin.integration.postgres; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.PostgresJamesConfiguration; +import org.apache.james.PostgresJamesServerMain; +import org.apache.james.SearchConfiguration; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.vault.VaultConfiguration; +import org.apache.james.webadmin.integration.UnauthorizedEndpointsTest; +import org.apache.james.webadmin.integration.UnauthorizedModule; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresUnauthorizedEndpointsTest extends UnauthorizedEndpointsTest { + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.scanning()) + .usersRepository(DEFAULT) + .eventBusImpl(PostgresJamesConfiguration.EventBusImpl.IN_MEMORY) + .deletedMessageVaultConfiguration(VaultConfiguration.ENABLED_DEFAULT) + .build()) + .extension(PostgresExtension.empty()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(new UnauthorizedModule())) + .lifeCycle(JamesServerExtension.Lifecycle.PER_CLASS) + .build(); +} diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresWebAdminServerBlobGCIntegrationTest.java b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresWebAdminServerBlobGCIntegrationTest.java new file mode 100644 index 00000000000..7346805bbe5 --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresWebAdminServerBlobGCIntegrationTest.java @@ -0,0 +1,280 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.webadmin.integration.postgres; + +import static io.restassured.RestAssured.given; +import static io.restassured.RestAssured.with; +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; +import static org.hamcrest.Matchers.is; + +import java.time.Clock; +import java.time.ZonedDateTime; +import java.util.Date; + +import javax.mail.Flags; +import javax.mail.util.SharedByteArrayInputStream; + +import org.apache.james.GuiceJamesServer; +import org.apache.james.GuiceModuleTestExtension; +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.PostgresJamesConfiguration; +import org.apache.james.PostgresJamesServerMain; +import org.apache.james.SearchConfiguration; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.core.Username; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.model.MailboxConstants; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mailbox.probe.MailboxProbe; +import org.apache.james.modules.MailboxProbeImpl; +import org.apache.james.modules.TestJMAPServerModule; +import org.apache.james.modules.blobstore.BlobStoreConfiguration; +import org.apache.james.probe.DataProbe; +import org.apache.james.task.TaskManager; +import org.apache.james.util.ClassLoaderUtils; +import org.apache.james.utils.DataProbeImpl; +import org.apache.james.utils.UpdatableTickingClock; +import org.apache.james.utils.WebAdminGuiceProbe; +import org.apache.james.webadmin.WebAdminUtils; +import org.apache.james.webadmin.routes.TasksRoutes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.google.inject.Module; + +import io.restassured.RestAssured; + +public class PostgresWebAdminServerBlobGCIntegrationTest { + private static final ZonedDateTime TIMESTAMP = ZonedDateTime.parse("2015-10-30T16:12:00Z"); + + public static class ClockExtension implements GuiceModuleTestExtension { + private UpdatableTickingClock clock; + + @Override + public void beforeEach(ExtensionContext extensionContext) { + clock = new UpdatableTickingClock(TIMESTAMP.toInstant()); + } + + @Override + public Module getModule() { + return binder -> binder.bind(Clock.class).toInstance(clock); + } + + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + return parameterContext.getParameter().getType() == UpdatableTickingClock.class; + } + + @Override + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + return clock; + } + } + + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.scanning()) + .usersRepository(DEFAULT) + .eventBusImpl(PostgresJamesConfiguration.EventBusImpl.IN_MEMORY) + .blobStore(BlobStoreConfiguration.builder() + .postgres() + .disableCache() + .deduplication() + .noCryptoConfig()) + .build()) + .extension(PostgresExtension.empty()) + .extension(new ClockExtension()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(new TestJMAPServerModule())) + .build(); + + private static final String DOMAIN = "domain"; + private static final String USERNAME = "username@" + DOMAIN; + + private DataProbe dataProbe; + private MailboxProbe mailboxProbe; + + @BeforeEach + void setUp(GuiceJamesServer guiceJamesServer, UpdatableTickingClock clock) throws Exception { + clock.setInstant(TIMESTAMP.toInstant()); + + WebAdminGuiceProbe webAdminGuiceProbe = guiceJamesServer.getProbe(WebAdminGuiceProbe.class); + dataProbe = guiceJamesServer.getProbe(DataProbeImpl.class); + mailboxProbe = guiceJamesServer.getProbe(MailboxProbeImpl.class); + + dataProbe.addDomain(DOMAIN); + dataProbe.addUser(USERNAME, "secret"); + mailboxProbe.createMailbox(MailboxConstants.USER_NAMESPACE, USERNAME, MailboxConstants.INBOX); + + RestAssured.requestSpecification = WebAdminUtils.buildRequestSpecification(webAdminGuiceProbe.getWebAdminPort()) + .build(); + RestAssured.enableLoggingOfRequestAndResponseIfValidationFails(); + } + + @Test + void blobGCShouldRemoveUnreferencedAndInactiveBlobId(UpdatableTickingClock clock) throws MailboxException { + SharedByteArrayInputStream mailInputStream = ClassLoaderUtils.getSystemResourceAsSharedStream("eml/emailWithOnlyAttachment.eml"); + mailboxProbe.appendMessage( + USERNAME, + MailboxPath.inbox(Username.of(USERNAME)), + mailInputStream.newStream(0, -1), + new Date(), + false, + new Flags()); + + mailboxProbe.deleteMailbox(MailboxConstants.USER_NAMESPACE, USERNAME, MailboxConstants.INBOX); + clock.setInstant(TIMESTAMP.plusMonths(2).toInstant()); + + String taskId = given() + .queryParam("scope", "unreferenced") + .delete("blobs") + .jsonPath() + .getString("taskId"); + + with() + .basePath(TasksRoutes.BASE) + .when() + .get(taskId + "/await") + .then() + .body("status", is(TaskManager.Status.COMPLETED.getValue())) + .body("taskId", is(taskId)) + .body("type", is("BlobGCTask")) + .body("additionalInformation.referenceSourceCount", is(0)) + .body("additionalInformation.blobCount", is(2)) + .body("additionalInformation.gcedBlobCount", is(2)) + .body("additionalInformation.errorCount", is(0)); + } + + @Test + void blobGCShouldNotRemoveActiveBlobId() throws MailboxException { + SharedByteArrayInputStream mailInputStream = ClassLoaderUtils.getSystemResourceAsSharedStream("eml/emailWithOnlyAttachment.eml"); + mailboxProbe.appendMessage( + USERNAME, + MailboxPath.inbox(Username.of(USERNAME)), + mailInputStream.newStream(0, -1), + new Date(), + false, + new Flags()); + + mailboxProbe.deleteMailbox(MailboxConstants.USER_NAMESPACE, USERNAME, MailboxConstants.INBOX); + + String taskId = given() + .queryParam("scope", "unreferenced") + .delete("blobs") + .jsonPath() + .getString("taskId"); + + with() + .basePath(TasksRoutes.BASE) + .when() + .get(taskId + "/await") + .then() + .body("status", is(TaskManager.Status.COMPLETED.getValue())) + .body("taskId", is(taskId)) + .body("type", is("BlobGCTask")) + .body("additionalInformation.referenceSourceCount", is(0)) + .body("additionalInformation.blobCount", is(2)) + .body("additionalInformation.gcedBlobCount", is(0)) + .body("additionalInformation.errorCount", is(0)); + } + + @Test + void blobGCShouldNotRemoveReferencedBlobId(UpdatableTickingClock clock) throws MailboxException { + SharedByteArrayInputStream mailInputStream = ClassLoaderUtils.getSystemResourceAsSharedStream("eml/emailWithOnlyAttachment.eml"); + mailboxProbe.appendMessage( + USERNAME, + MailboxPath.inbox(Username.of(USERNAME)), + mailInputStream.newStream(0, -1), + new Date(), + false, + new Flags()); + clock.setInstant(TIMESTAMP.plusMonths(2).toInstant()); + + String taskId = given() + .queryParam("scope", "unreferenced") + .delete("blobs") + .jsonPath() + .getString("taskId"); + + with() + .basePath(TasksRoutes.BASE) + .when() + .get(taskId + "/await") + .then() + .body("status", is(TaskManager.Status.COMPLETED.getValue())) + .body("taskId", is(taskId)) + .body("type", is("BlobGCTask")) + .body("additionalInformation.referenceSourceCount", is(2)) + .body("additionalInformation.blobCount", is(2)) + .body("additionalInformation.gcedBlobCount", is(0)) + .body("additionalInformation.errorCount", is(0)); + } + + @Test + void blobGCShouldNotRemoveReferencedBlobIdToAnotherMailbox(UpdatableTickingClock clock) throws Exception { + SharedByteArrayInputStream mailInputStream = ClassLoaderUtils.getSystemResourceAsSharedStream("eml/emailWithOnlyAttachment.eml"); + mailboxProbe.appendMessage( + USERNAME, + MailboxPath.inbox(Username.of(USERNAME)), + mailInputStream.newStream(0, -1), + new Date(), + false, + new Flags()); + + mailboxProbe.createMailbox(MailboxConstants.USER_NAMESPACE, USERNAME, "CustomBox"); + mailboxProbe.appendMessage( + USERNAME, + MailboxPath.forUser(Username.of(USERNAME), "CustomBox"), + mailInputStream.newStream(0, -1), + new Date(), + false, + new Flags()); + + mailboxProbe.deleteMailbox(MailboxConstants.USER_NAMESPACE, USERNAME, MailboxConstants.INBOX); + clock.setInstant(TIMESTAMP.plusMonths(2).toInstant()); + + String taskId = given() + .queryParam("scope", "unreferenced") + .delete("blobs") + .jsonPath() + .getString("taskId"); + + with() + .basePath(TasksRoutes.BASE) + .when() + .get(taskId + "/await") + .then() + .body("status", is(TaskManager.Status.COMPLETED.getValue())) + .body("taskId", is(taskId)) + .body("type", is("BlobGCTask")) + .body("additionalInformation.referenceSourceCount", is(2)) + .body("additionalInformation.blobCount", is(2)) + .body("additionalInformation.gcedBlobCount", is(0)) + .body("additionalInformation.errorCount", is(0)); + } +} diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresWebAdminServerIntegrationImmutableTest.java b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresWebAdminServerIntegrationImmutableTest.java new file mode 100644 index 00000000000..a8afc6b52ae --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresWebAdminServerIntegrationImmutableTest.java @@ -0,0 +1,48 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.webadmin.integration.postgres; + +import static org.apache.james.JamesServerExtension.Lifecycle.PER_CLASS; +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.PostgresJamesConfiguration; +import org.apache.james.PostgresJamesServerMain; +import org.apache.james.SearchConfiguration; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.webadmin.integration.WebAdminServerIntegrationImmutableTest; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresWebAdminServerIntegrationImmutableTest extends WebAdminServerIntegrationImmutableTest { + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.scanning()) + .usersRepository(DEFAULT) + .eventBusImpl(PostgresJamesConfiguration.EventBusImpl.IN_MEMORY) + .build()) + .extension(PostgresExtension.empty()) + .server(PostgresJamesServerMain::createServer) + .lifeCycle(PER_CLASS) + .build(); +} diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresWebAdminServerIntegrationTest.java b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresWebAdminServerIntegrationTest.java new file mode 100644 index 00000000000..7ce3a16d374 --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresWebAdminServerIntegrationTest.java @@ -0,0 +1,46 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.webadmin.integration.postgres; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.PostgresJamesConfiguration; +import org.apache.james.PostgresJamesServerMain; +import org.apache.james.SearchConfiguration; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.webadmin.integration.WebAdminServerIntegrationTest; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresWebAdminServerIntegrationTest extends WebAdminServerIntegrationTest { + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.scanning()) + .usersRepository(DEFAULT) + .eventBusImpl(PostgresJamesConfiguration.EventBusImpl.IN_MEMORY) + .build()) + .extension(PostgresExtension.empty()) + .server(PostgresJamesServerMain::createServer) + .build(); +} diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/eml/emailWithOnlyAttachment.eml b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/eml/emailWithOnlyAttachment.eml new file mode 100644 index 00000000000..452d4cc26d4 --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/eml/emailWithOnlyAttachment.eml @@ -0,0 +1,16 @@ +Return-Path: +Subject: 29989 btellier +From: +Content-Disposition: attachment +MIME-Version: 1.0 +Date: Sun, 02 Apr 2017 22:09:04 -0000 +Content-Type: application/zip; name="9559333830.zip" +To: +Message-ID: <149117094410.10639.6001033367375624@any.com> +Content-Transfer-Encoding: base64 + +UEsDBBQAAgAIAEQeg0oN2YT/EAsAAMsWAAAIABwAMjIwODUuanNVVAkAAxBy4VgQcuFYdXgLAAEE +AAAAAAQAAAAApZhbi1zHFYWfY/B/MP3i7kwj1/2CokAwBPIQ+sGPkgJ1tURkdeiMbYzQf8+3q8+M +ZmQllgn2aHrqnNq1L2uvtavnj2/b7evz26/Op5M6q/P+8OUX77784g8/lQtLisXTU/68vfzCv/Lg +D9vqs/3b8fNXf92273ey4XTCykk9w9LpfD7tX+zGzU83b8pPg39uBr/Kmxe7w9PLuP3xwpFKTJ32 +AAEEAAAAAAQAAAAAUEsFBgAAAAABAAEATgAAAFILAAAAAA== diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/keystore b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/resources/keystore new file mode 100644 index 0000000000000000000000000000000000000000..536a6c792b0740ef4273327bf4a61ffc2d6491d8 GIT binary patch literal 2245 zcmchY={pn*7sh8LBQ%y#WJwLmHX~!-n&e4IWZ$x7nXzWyM!ymF9%GH0|)`01HpknC;&o)EW|hTnC0KzVn%TKNE#dU1v||+1tZxX zS_9GsgkCLFCv|_)LvA!S*k!K2h)$={;+p9hHH7Nb0p>KwaVg~IFb3Sc1wDRw9$A){s zjWgyn8QQ_DwD67^UN~?lj{Brp?9aL{)#!V+F@3yd+SXoy#ls2T};RV4e2y4MYI1_L5*8Y+3@jZ}Jq=k;pjN{&W6V&8CnMam*;{LK8_ zVM=cij+9`Yn?R}TQ&+mUIg*K2CR|gqXqw>>3OJI|3T0Q6?~|~GQ+Cq*Ub{W= z#tEY5JH3B7<^Ay^isK!NQlyqlK>%jK4bn-JJ1I_tg1E53mrrAfv?W-!v5v*W1PD^o zxAg%m|LiTHI$`?t4_QyHAX{D{qH>>39tRp>KI;&`pMqjM%_S@a>jO>` z6pB-cdX{xVxy#YMXTrC-^vxG;KHTzHJl8ZO(ySb{-z~l#bcPwmZz!xT*qai`@=~g7 zm%`Wwk)!3E8#0=esd0RL9=xO}l_gdqO`CGH7ked&sARd)5kT$wm= z(V}s9O156MBTz(2khxa8_$Q`dZatu&qt;^pD<4J1$qXsr6Vb23Hu=&yB~!VNc_Jq7 z>VHqD5r3dce|yB1wtClTIY>%O@DHRB{=}X}6o%-w9had83mD84mrS?s_A(A^%{Ybf zRT$$U8`bB!I?xkRBP`95KfExp?{qx}b$oLcb-j z058_v&mR{oY2ohUgL4l=i3{_fF(`FqRg~I!WempdH=@zXD*wg*_c%nL)ISY5{1;#% zkPm<&0%0H`5C}-{<*=1KBbO?SE#xkKMXvqKHKh)AwKZ^R?x7Gq zEJ*}Q`i!-;D;`bn<_(PMs?Z!Azhb;wGdEjk+VigAO}tt$&0gSSAkd^Qu!YeAVl>_P zq$(ep;B$ZZRcA%4lYiy6#UI5)x3Z~7q5Zti`7%_(oi!vm`e!I-%8fY0(DZ6xzl)3s zC8vu)lBpgh%sJWw?xJ&^Lf|}E;FK>dP{OL^>8>odoE0JSm(A1w7;@mTwWsWTaS38liiOoY7+EQJp|1|ONst!#A z0&q=oUM&(2S+u)9)NE3)LgN5Iy~&PWa%6*-3MUjfcyByu7b)f3tpKXQeTd-2|17(3qjJ zuCdt!7~*+Jj-k$)2}|B;vFe5_aZzP>x+f-|h}*dnJi&WkeY1Xb&&jLmqkgpE0spgY zybxo}kn!S$8P;k(zWJ(t|K7IXP**)mv%t;DM3PJALygR(3trmZ)bjb(P7m4wUZX6{ zTa^)O + + + postgres://var/mail/rrt-error/ + + @@ -62,9 +67,15 @@ bcc + + ignore + ignore + + ignore + local-address-error From c2f5e26ae56f1da138c083b3ea3e5a0bfcf7e8f9 Mon Sep 17 00:00:00 2001 From: hung phan Date: Wed, 17 Jan 2024 17:00:38 +0700 Subject: [PATCH 200/341] JAMES-2586 Implement PostgresEmailChangeRepository --- server/apps/postgres-app/pom.xml | 5 + .../org/apache/james/PostgresJmapModule.java | 5 +- server/data/data-jmap-postgres/pom.xml | 9 ++ .../change/PostgresEmailChangeDAO.java | 118 ++++++++++++++++++ .../change/PostgresEmailChangeModule.java | 71 +++++++++++ .../change/PostgresEmailChangeRepository.java | 115 +++++++++++++++++ .../postgres/change/PostgresStateFactory.java | 31 +++++ .../PostgresEmailChangeRepositoryTest.java | 58 +++++++++ 8 files changed, 410 insertions(+), 2 deletions(-) create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeDAO.java create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeModule.java create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeRepository.java create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresStateFactory.java create mode 100644 server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeRepositoryTest.java diff --git a/server/apps/postgres-app/pom.xml b/server/apps/postgres-app/pom.xml index e60d5e1d38a..0f0478a6a91 100644 --- a/server/apps/postgres-app/pom.xml +++ b/server/apps/postgres-app/pom.xml @@ -101,6 +101,11 @@ james-server-cli runtime + + ${james.groupId} + james-server-data-jmap-postgres + ${project.version} + ${james.groupId} james-server-data-ldap diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java index 9f19c0979e3..6e9a8c101ba 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java @@ -27,6 +27,7 @@ import org.apache.james.jmap.memory.change.MemoryEmailChangeRepository; import org.apache.james.jmap.memory.change.MemoryMailboxChangeRepository; import org.apache.james.jmap.memory.upload.InMemoryUploadUsageRepository; +import org.apache.james.jmap.postgres.change.PostgresEmailChangeRepository; import org.apache.james.mailbox.AttachmentManager; import org.apache.james.mailbox.MessageIdManager; import org.apache.james.mailbox.RightManager; @@ -47,8 +48,8 @@ public class PostgresJmapModule extends AbstractModule { @Override protected void configure() { - bind(EmailChangeRepository.class).to(MemoryEmailChangeRepository.class); - bind(MemoryEmailChangeRepository.class).in(Scopes.SINGLETON); + bind(EmailChangeRepository.class).to(PostgresEmailChangeRepository.class); + bind(PostgresEmailChangeRepository.class).in(Scopes.SINGLETON); bind(MailboxChangeRepository.class).to(MemoryMailboxChangeRepository.class); bind(MemoryMailboxChangeRepository.class).in(Scopes.SINGLETON); diff --git a/server/data/data-jmap-postgres/pom.xml b/server/data/data-jmap-postgres/pom.xml index c69be1f1074..23abe4e8df6 100644 --- a/server/data/data-jmap-postgres/pom.xml +++ b/server/data/data-jmap-postgres/pom.xml @@ -33,6 +33,10 @@ Apache James :: Server :: Data :: JMAP :: PostgreSQL persistence + + 5.3.7 + + ${james.groupId} @@ -101,6 +105,11 @@ testing-base test + + com.github.f4b6a3 + uuid-creator + ${uuid-creator.version} + com.google.guava guava diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeDAO.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeDAO.java new file mode 100644 index 00000000000..7e651562f99 --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeDAO.java @@ -0,0 +1,118 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.change; + +import static org.apache.james.jmap.postgres.change.PostgresEmailChangeModule.PostgresEmailChangeTable.ACCOUNT_ID; +import static org.apache.james.jmap.postgres.change.PostgresEmailChangeModule.PostgresEmailChangeTable.CREATED; +import static org.apache.james.jmap.postgres.change.PostgresEmailChangeModule.PostgresEmailChangeTable.DATE; +import static org.apache.james.jmap.postgres.change.PostgresEmailChangeModule.PostgresEmailChangeTable.DESTROYED; +import static org.apache.james.jmap.postgres.change.PostgresEmailChangeModule.PostgresEmailChangeTable.IS_SHARED; +import static org.apache.james.jmap.postgres.change.PostgresEmailChangeModule.PostgresEmailChangeTable.STATE; +import static org.apache.james.jmap.postgres.change.PostgresEmailChangeModule.PostgresEmailChangeTable.TABLE_NAME; +import static org.apache.james.jmap.postgres.change.PostgresEmailChangeModule.PostgresEmailChangeTable.UPDATED; + +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.jmap.api.change.EmailChange; +import org.apache.james.jmap.api.change.State; +import org.apache.james.jmap.api.model.AccountId; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.jooq.Record; + +import com.google.common.collect.ImmutableList; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresEmailChangeDAO { + private final PostgresExecutor postgresExecutor; + + public PostgresEmailChangeDAO(PostgresExecutor postgresExecutor) { + this.postgresExecutor = postgresExecutor; + } + + public Mono insert(EmailChange change) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME) + .set(ACCOUNT_ID, change.getAccountId().getIdentifier()) + .set(STATE, change.getState().getValue()) + .set(IS_SHARED, change.isShared()) + .set(CREATED, convertToUUIDArray(change.getCreated())) + .set(UPDATED, convertToUUIDArray(change.getUpdated())) + .set(DESTROYED, convertToUUIDArray(change.getDestroyed())) + .set(DATE, change.getDate().toOffsetDateTime()))); + } + + public Flux getAllChanges(AccountId accountId) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.selectFrom(TABLE_NAME) + .where(ACCOUNT_ID.eq(accountId.getIdentifier())))) + .map(record -> readRecord(record, accountId)); + } + + public Flux getChangesSince(AccountId accountId, State state) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.selectFrom(TABLE_NAME) + .where(ACCOUNT_ID.eq(accountId.getIdentifier())) + .and(STATE.greaterOrEqual(state.getValue())) + .orderBy(STATE))) + .map(record -> readRecord(record, accountId)); + } + + public Mono latestState(AccountId accountId) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select(STATE) + .from(TABLE_NAME) + .where(ACCOUNT_ID.eq(accountId.getIdentifier())) + .orderBy(STATE.desc()) + .limit(1))) + .map(record -> State.of(record.get(STATE))); + } + + public Mono latestStateNotDelegated(AccountId accountId) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select(STATE) + .from(TABLE_NAME) + .where(ACCOUNT_ID.eq(accountId.getIdentifier())) + .and(IS_SHARED.eq(false)) + .orderBy(STATE.desc()) + .limit(1))) + .map(record -> State.of(record.get(STATE))); + } + + private UUID[] convertToUUIDArray(List messageIds) { + return messageIds.stream().map(PostgresMessageId.class::cast).map(PostgresMessageId::asUuid).toArray(UUID[]::new); + } + + private EmailChange readRecord(Record record, AccountId accountId) { + return EmailChange.builder() + .accountId(accountId) + .state(State.of(record.get(STATE))) + .date(record.get(DATE).toZonedDateTime()) + .isShared(record.get(IS_SHARED)) + .created(convertToMessageIdList(record.get(CREATED))) + .updated(convertToMessageIdList(record.get(UPDATED))) + .destroyed(convertToMessageIdList(record.get(DESTROYED))) + .build(); + } + + private List convertToMessageIdList(UUID[] uuids) { + return Arrays.stream(uuids).map(PostgresMessageId.Factory::of).collect(ImmutableList.toImmutableList()); + } +} diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeModule.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeModule.java new file mode 100644 index 00000000000..fbd0ac877b4 --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeModule.java @@ -0,0 +1,71 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.change; + +import static org.apache.james.jmap.postgres.change.PostgresEmailChangeModule.PostgresEmailChangeTable.INDEX; +import static org.apache.james.jmap.postgres.change.PostgresEmailChangeModule.PostgresEmailChangeTable.TABLE; + +import java.time.OffsetDateTime; +import java.util.UUID; + +import org.apache.james.backends.postgres.PostgresIndex; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresEmailChangeModule { + interface PostgresEmailChangeTable { + Table TABLE_NAME = DSL.table("email_change"); + + Field ACCOUNT_ID = DSL.field("account_id", SQLDataType.VARCHAR.notNull()); + Field STATE = DSL.field("state", SQLDataType.UUID.notNull()); + Field DATE = DSL.field("date", SQLDataType.TIMESTAMPWITHTIMEZONE.notNull()); + Field IS_SHARED = DSL.field("is_shared", SQLDataType.BOOLEAN.notNull()); + Field CREATED = DSL.field("created", SQLDataType.UUID.getArrayDataType().notNull()); + Field UPDATED = DSL.field("updated", SQLDataType.UUID.getArrayDataType().notNull()); + Field DESTROYED = DSL.field("destroyed", SQLDataType.UUID.getArrayDataType().notNull()); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(ACCOUNT_ID) + .column(STATE) + .column(DATE) + .column(IS_SHARED) + .column(CREATED) + .column(UPDATED) + .column(DESTROYED) + .constraint(DSL.primaryKey(ACCOUNT_ID, STATE)))) + .supportsRowLevelSecurity() + .build(); + + PostgresIndex INDEX = PostgresIndex.name("idx_email_change_date") + .createIndexStep((dslContext, indexName) -> dslContext.createIndex(indexName) + .on(TABLE_NAME, DATE)); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(TABLE) + .addIndex(INDEX) + .build(); +} diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeRepository.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeRepository.java new file mode 100644 index 00000000000..0689b270510 --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeRepository.java @@ -0,0 +1,115 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.change; + +import java.util.Optional; + +import javax.inject.Inject; +import javax.inject.Named; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.core.Username; +import org.apache.james.jmap.api.change.EmailChange; +import org.apache.james.jmap.api.change.EmailChangeRepository; +import org.apache.james.jmap.api.change.EmailChanges; +import org.apache.james.jmap.api.change.Limit; +import org.apache.james.jmap.api.change.State; +import org.apache.james.jmap.api.exception.ChangeNotFoundException; +import org.apache.james.jmap.api.model.AccountId; + +import com.google.common.base.Preconditions; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresEmailChangeRepository implements EmailChangeRepository { + public static final String LIMIT_NAME = "emailChangeDefaultLimit"; + + private final PostgresExecutor.Factory executorFactory; + private final Limit defaultLimit; + + @Inject + public PostgresEmailChangeRepository(PostgresExecutor.Factory executorFactory, @Named(LIMIT_NAME) Limit defaultLimit) { + this.executorFactory = executorFactory; + this.defaultLimit = defaultLimit; + } + + @Override + public Mono save(EmailChange change) { + PostgresEmailChangeDAO emailChangeDAO = createPostgresEmailChangeDAO(change.getAccountId()); + return emailChangeDAO.insert(change); + } + + @Override + public Mono getSinceState(AccountId accountId, State state, Optional maxChanges) { + Preconditions.checkNotNull(accountId); + Preconditions.checkNotNull(state); + maxChanges.ifPresent(limit -> Preconditions.checkArgument(limit.getValue() > 0, "maxChanges must be a positive integer")); + + PostgresEmailChangeDAO emailChangeDAO = createPostgresEmailChangeDAO(accountId); + if (state.equals(State.INITIAL)) { + return emailChangeDAO.getAllChanges(accountId) + .filter(change -> !change.isShared()) + .collect(new EmailChanges.Builder.EmailChangeCollector(state, maxChanges.orElse(defaultLimit))); + } + + return emailChangeDAO.getChangesSince(accountId, state) + .switchIfEmpty(Flux.error(() -> new ChangeNotFoundException(state, String.format("State '%s' could not be found", state.getValue())))) + .filter(change -> !change.isShared()) + .filter(change -> !change.getState().equals(state)) + .collect(new EmailChanges.Builder.EmailChangeCollector(state, maxChanges.orElse(defaultLimit))); + } + + @Override + public Mono getSinceStateWithDelegation(AccountId accountId, State state, Optional maxChanges) { + Preconditions.checkNotNull(accountId); + Preconditions.checkNotNull(state); + maxChanges.ifPresent(limit -> Preconditions.checkArgument(limit.getValue() > 0, "maxChanges must be a positive integer")); + + PostgresEmailChangeDAO emailChangeDAO = createPostgresEmailChangeDAO(accountId); + if (state.equals(State.INITIAL)) { + return emailChangeDAO.getAllChanges(accountId) + .collect(new EmailChanges.Builder.EmailChangeCollector(state, maxChanges.orElse(defaultLimit))); + } + + return emailChangeDAO.getChangesSince(accountId, state) + .switchIfEmpty(Flux.error(() -> new ChangeNotFoundException(state, String.format("State '%s' could not be found", state.getValue())))) + .filter(change -> !change.getState().equals(state)) + .collect(new EmailChanges.Builder.EmailChangeCollector(state, maxChanges.orElse(defaultLimit))); + } + + @Override + public Mono getLatestState(AccountId accountId) { + PostgresEmailChangeDAO emailChangeDAO = createPostgresEmailChangeDAO(accountId); + return emailChangeDAO.latestStateNotDelegated(accountId) + .switchIfEmpty(Mono.just(State.INITIAL)); + } + + @Override + public Mono getLatestStateWithDelegation(AccountId accountId) { + PostgresEmailChangeDAO emailChangeDAO = createPostgresEmailChangeDAO(accountId); + return emailChangeDAO.latestState(accountId) + .switchIfEmpty(Mono.just(State.INITIAL)); + } + + private PostgresEmailChangeDAO createPostgresEmailChangeDAO(AccountId accountId) { + return new PostgresEmailChangeDAO(executorFactory.create(Username.of(accountId.getIdentifier()).getDomainPart())); + } +} diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresStateFactory.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresStateFactory.java new file mode 100644 index 00000000000..e4ab2129b4b --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresStateFactory.java @@ -0,0 +1,31 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.change; + +import org.apache.james.jmap.api.change.State; + +import com.github.f4b6a3.uuid.UuidCreator; + +public class PostgresStateFactory implements State.Factory { + @Override + public State generate() { + return State.of(UuidCreator.getTimeOrderedEpoch()); + } +} diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeRepositoryTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeRepositoryTest.java new file mode 100644 index 00000000000..7b2865102b5 --- /dev/null +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeRepositoryTest.java @@ -0,0 +1,58 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.change; + +import java.util.UUID; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.jmap.api.change.EmailChangeRepository; +import org.apache.james.jmap.api.change.EmailChangeRepositoryContract; +import org.apache.james.jmap.api.change.State; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresEmailChangeRepositoryTest implements EmailChangeRepositoryContract { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresEmailChangeModule.MODULE); + + PostgresEmailChangeRepository postgresEmailChangeRepository; + + @BeforeEach + public void setUp() { + postgresEmailChangeRepository = new PostgresEmailChangeRepository(postgresExtension.getExecutorFactory(), DEFAULT_NUMBER_OF_CHANGES); + } + + @Override + public EmailChangeRepository emailChangeRepository() { + return postgresEmailChangeRepository; + } + + @Override + public MessageId generateNewMessageId() { + return PostgresMessageId.Factory.of(UUID.randomUUID()); + } + + @Override + public State generateNewState() { + return new PostgresStateFactory().generate(); + } +} From c39158e78cfcdb0efcc01a36543b51342777cb20 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 22 Jan 2024 16:59:55 +0700 Subject: [PATCH 201/341] JAMES-2586 - Delete Message Listener - add test case when delete mailbox has a lot of messages --- .../DeleteMessageListenerContract.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerContract.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerContract.java index 36a10cb33f7..af85869d527 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerContract.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerContract.java @@ -23,6 +23,7 @@ import static org.assertj.core.api.SoftAssertions.assertSoftly; import static org.junit.jupiter.api.Assumptions.assumeTrue; +import java.util.List; import java.util.UUID; import org.apache.james.blob.api.BlobId; @@ -43,8 +44,10 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import com.github.fge.lambdas.Throwing; import com.google.common.collect.ImmutableList; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; public abstract class DeleteMessageListenerContract { @@ -224,4 +227,20 @@ void deleteMessageListenerShouldNotDeleteReferencedBlob() throws Exception { .isNotEmpty(); }); } + + @Test + void deleteMessageListenerShouldSucceedWhenDeleteMailboxHasALotOfMessages() throws Exception { + List messageIdList = Flux.range(0, 50) + .map(i -> Throwing.supplier(() -> inboxManager.appendMessage(MessageManager.AppendCommand.builder() + .build(ClassLoaderUtils.getSystemResourceAsByteArray("eml/emailWithOnlyAttachment.eml")), session)).get()) + .map(appendResult -> (PostgresMessageId) appendResult.getId().getMessageId()) + .collectList() + .block(); + + mailboxManager.deleteMailbox(inbox, session); + + assertThat(Flux.fromIterable(messageIdList) + .flatMap(msgId -> postgresMessageDAO.getBodyBlobId(msgId)) + .collectList().block()).isEmpty(); + } } From e6c9cd7cdac8e8bc6e700e6665496e4b804cbcbe Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 22 Jan 2024 17:01:07 +0700 Subject: [PATCH 202/341] JAMES-2586 - Fixbug - Delete Message Listener - Fix hanging issue --- .../backends/postgres/utils/PostgresExecutor.java | 10 ++++++++++ .../postgres/mail/dao/PostgresMailboxMessageDAO.java | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index 268e14a08a2..88ccd47746a 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -28,6 +28,7 @@ import org.apache.james.core.Domain; import org.jooq.DSLContext; +import org.jooq.DeleteResultStep; import org.jooq.Record; import org.jooq.Record1; import org.jooq.SQLDialect; @@ -98,6 +99,15 @@ public Flux executeRows(Function> queryFunction .filter(preparedStatementConflictException())); } + public Flux executeDeleteAndReturnList(Function> queryFunction) { + return dslContext() + .flatMapMany(queryFunction) + .collectList() + .flatMapIterable(list -> list) // The convert Flux -> Mono -> Flux to avoid a hanging issue. See: https://github.com/jOOQ/jOOQ/issues/16055 + .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) + .filter(preparedStatementConflictException())); + } + public Mono executeRow(Function> queryFunction) { return dslContext() .flatMap(queryFunction.andThen(Mono::from)) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java index 61e48ea6372..26e4507bef1 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java @@ -232,9 +232,9 @@ public Flux deleteByMailboxIdAndMessageUids(PostgresMailboxId m } public Flux deleteByMailboxId(PostgresMailboxId mailboxId) { - return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.deleteFrom(TABLE_NAME) + return postgresExecutor.executeDeleteAndReturnList(dslContext -> dslContext.deleteFrom(TABLE_NAME) .where(MAILBOX_ID.eq(mailboxId.asUuid())) - .returning(MESSAGE_ID))) + .returning(MESSAGE_ID)) .map(record -> PostgresMessageId.Factory.of(record.get(MESSAGE_ID))); } From bbce8d1db5454a8dd3c9652c0b7b2665f6bc4f7b Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 22 Jan 2024 17:06:45 +0700 Subject: [PATCH 203/341] JAMES-2586 - Fixbug hanging issue when Jooq execute delete and return list --- .../mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java index 26e4507bef1..fd51fcc2fb3 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java @@ -217,10 +217,10 @@ public Mono deleteByMailboxIdAndMessageUid(PostgresMailboxId ma } public Flux deleteByMailboxIdAndMessageUids(PostgresMailboxId mailboxId, List uids) { - Function, Flux> deletePublisherFunction = uidsToDelete -> postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.deleteFrom(TABLE_NAME) + Function, Flux> deletePublisherFunction = uidsToDelete -> postgresExecutor.executeDeleteAndReturnList(dslContext -> dslContext.deleteFrom(TABLE_NAME) .where(MAILBOX_ID.eq(mailboxId.asUuid())) .and(MESSAGE_UID.in(uidsToDelete.stream().map(MessageUid::asLong).toArray(Long[]::new))) - .returning(MESSAGE_METADATA_FIELDS_REQUIRE))) + .returning(MESSAGE_METADATA_FIELDS_REQUIRE)) .map(RECORD_TO_MESSAGE_METADATA_FUNCTION); if (uids.size() <= IN_CLAUSE_MAX_SIZE) { From 15f153f734590e58fd916be711d520a389c108b1 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Tue, 23 Jan 2024 09:28:29 +0700 Subject: [PATCH 204/341] JAMES-2586 Implement PostgresEmailChangeRepository - Fixup Guice binding --- .../src/main/java/org/apache/james/PostgresJmapModule.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java index 6e9a8c101ba..5d2ce02a008 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java @@ -19,6 +19,7 @@ package org.apache.james; +import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.jmap.api.change.EmailChangeRepository; import org.apache.james.jmap.api.change.Limit; import org.apache.james.jmap.api.change.MailboxChangeRepository; @@ -27,6 +28,7 @@ import org.apache.james.jmap.memory.change.MemoryEmailChangeRepository; import org.apache.james.jmap.memory.change.MemoryMailboxChangeRepository; import org.apache.james.jmap.memory.upload.InMemoryUploadUsageRepository; +import org.apache.james.jmap.postgres.change.PostgresEmailChangeModule; import org.apache.james.jmap.postgres.change.PostgresEmailChangeRepository; import org.apache.james.mailbox.AttachmentManager; import org.apache.james.mailbox.MessageIdManager; @@ -42,12 +44,15 @@ import com.google.inject.AbstractModule; import com.google.inject.Scopes; +import com.google.inject.multibindings.Multibinder; import com.google.inject.name.Names; public class PostgresJmapModule extends AbstractModule { @Override protected void configure() { + Multibinder.newSetBinder(binder(), PostgresModule.class).addBinding().toInstance(PostgresEmailChangeModule.MODULE); + bind(EmailChangeRepository.class).to(PostgresEmailChangeRepository.class); bind(PostgresEmailChangeRepository.class).in(Scopes.SINGLETON); From 7c8c6f6a1d7060d26762d2a96690630dc1ab95bd Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 24 Jan 2024 15:41:55 +0700 Subject: [PATCH 205/341] JAMES-2586 Implement Postgres upload repository --- .../backends/postgres/PostgresCommons.java | 10 ++ .../backends/postgres/PostgresExtension.java | 27 ++++- .../postgres/upload/PostgresUploadDAO.java | 110 ++++++++++++++++++ .../postgres/upload/PostgresUploadModule.java | 75 ++++++++++++ .../upload/PostgresUploadRepository.java | 96 +++++++++++++++ .../upload/PostgresUploadRepositoryTest.java | 64 ++++++++++ .../upload/PostgresUploadServiceTest.java | 76 ++++++++++++ 7 files changed, 453 insertions(+), 5 deletions(-) create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadDAO.java create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadModule.java create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java create mode 100644 server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepositoryTest.java create mode 100644 server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadServiceTest.java diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresCommons.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresCommons.java index 5ffb1905258..88d36936c83 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresCommons.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresCommons.java @@ -19,6 +19,7 @@ package org.apache.james.backends.postgres; +import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneOffset; import java.util.Date; @@ -57,11 +58,20 @@ public static Field tableField(Table table, Field field) { public static final Function DATE_TO_LOCAL_DATE_TIME = date -> Optional.ofNullable(date) .map(value -> LocalDateTime.ofInstant(value.toInstant(), ZoneOffset.UTC)) .orElse(null); + + public static final Function INSTANT_TO_LOCAL_DATE_TIME = instant -> Optional.ofNullable(instant) + .map(value -> LocalDateTime.ofInstant(instant, ZoneOffset.UTC)) + .orElse(null); + public static final Function LOCAL_DATE_TIME_DATE_FUNCTION = localDateTime -> Optional.ofNullable(localDateTime) .map(value -> value.toInstant(ZoneOffset.UTC)) .map(Date::from) .orElse(null); + public static final Function LOCAL_DATE_TIME_INSTANT_FUNCTION = localDateTime -> Optional.ofNullable(localDateTime) + .map(value -> value.toInstant(ZoneOffset.UTC)) + .orElse(null); + public static final Function, Field> UNNEST_FIELD = field -> DSL.function("unnest", field.getType().getComponentType(), field); public static final int IN_CLAUSE_MAX_SIZE = 32; diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index 2a2c6b9a33f..bde60c3d4b1 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -66,6 +66,7 @@ public static PostgresExtension empty() { private final PostgresFixture.Database selectedDatabase; private PostgresConfiguration postgresConfiguration; private PostgresExecutor postgresExecutor; + private PostgresExecutor nonRLSPostgresExecutor; private PostgresqlConnectionFactory connectionFactory; private PostgresExecutor.Factory executorFactory; @@ -127,16 +128,17 @@ private void initPostgresSession() { .rowLevelSecurityEnabled(rlsEnabled) .build(); - connectionFactory = new PostgresqlConnectionFactory(PostgresqlConnectionConfiguration.builder() + PostgresqlConnectionConfiguration.Builder connectionBaseBuilder = PostgresqlConnectionConfiguration.builder() .host(postgresConfiguration.getHost()) .port(postgresConfiguration.getPort()) + .database(postgresConfiguration.getDatabaseName()) + .schema(postgresConfiguration.getDatabaseSchema()); + + connectionFactory = new PostgresqlConnectionFactory(connectionBaseBuilder .username(postgresConfiguration.getCredential().getUsername()) .password(postgresConfiguration.getCredential().getPassword()) - .database(postgresConfiguration.getDatabaseName()) - .schema(postgresConfiguration.getDatabaseSchema()) .build()); - if (rlsEnabled) { executorFactory = new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(connectionFactory)); } else { @@ -146,6 +148,17 @@ private void initPostgresSession() { } postgresExecutor = executorFactory.create(); + if (rlsEnabled) { + nonRLSPostgresExecutor = Mono.just(connectionBaseBuilder + .username(postgresConfiguration.getNonRLSCredential().getUsername()) + .password(postgresConfiguration.getNonRLSCredential().getPassword()) + .build()) + .flatMap(configuration -> new PostgresqlConnectionFactory(configuration).create().cache()) + .map(connection -> new PostgresExecutor.Factory(new SinglePostgresConnectionFactory(connection)).create()) + .block(); + } else { + nonRLSPostgresExecutor = postgresExecutor; + } } @Override @@ -167,7 +180,7 @@ public void afterEach(ExtensionContext extensionContext) { resetSchema(); } - public void restartContainer() throws URISyntaxException { + public void restartContainer() { PG_CONTAINER.stop(); PG_CONTAINER.start(); initPostgresSession(); @@ -195,6 +208,10 @@ public PostgresExecutor getPostgresExecutor() { return postgresExecutor; } + public PostgresExecutor getNonRLSPostgresExecutor() { + return nonRLSPostgresExecutor; + } + public ConnectionFactory getConnectionFactory() { return connectionFactory; } diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadDAO.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadDAO.java new file mode 100644 index 00000000000..512096abfc0 --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadDAO.java @@ -0,0 +1,110 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.upload; + +import static org.apache.james.backends.postgres.PostgresCommons.INSTANT_TO_LOCAL_DATE_TIME; +import static org.apache.james.jmap.postgres.upload.PostgresUploadModule.PostgresUploadTable; + +import java.time.LocalDateTime; +import java.util.Optional; + +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; + +import org.apache.james.backends.postgres.PostgresCommons; +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.blob.api.BlobId; +import org.apache.james.core.Domain; +import org.apache.james.core.Username; +import org.apache.james.jmap.api.model.UploadId; +import org.apache.james.jmap.api.model.UploadMetaData; +import org.apache.james.mailbox.model.ContentType; +import org.jooq.Record; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresUploadDAO { + public static class Factory { + private final BlobId.Factory blobIdFactory; + private final PostgresExecutor.Factory executorFactory; + + @Inject + @Singleton + public Factory(BlobId.Factory blobIdFactory, PostgresExecutor.Factory executorFactory) { + this.blobIdFactory = blobIdFactory; + this.executorFactory = executorFactory; + } + + public PostgresUploadDAO create(Optional domain) { + return new PostgresUploadDAO(executorFactory.create(domain), blobIdFactory); + } + } + + private final PostgresExecutor postgresExecutor; + + private final BlobId.Factory blobIdFactory; + + @Singleton + @Inject + public PostgresUploadDAO(@Named(PostgresExecutor.NON_RLS_INJECT) PostgresExecutor postgresExecutor, BlobId.Factory blobIdFactory) { + this.postgresExecutor = postgresExecutor; + this.blobIdFactory = blobIdFactory; + } + + public Mono insert(UploadMetaData upload, Username user) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(PostgresUploadTable.TABLE_NAME) + .set(PostgresUploadTable.ID, upload.uploadId().getId()) + .set(PostgresUploadTable.CONTENT_TYPE, upload.contentType().asString()) + .set(PostgresUploadTable.SIZE, upload.sizeAsLong()) + .set(PostgresUploadTable.BLOB_ID, upload.blobId().asString()) + .set(PostgresUploadTable.USER_NAME, user.asString()) + .set(PostgresUploadTable.UPLOAD_DATE, INSTANT_TO_LOCAL_DATE_TIME.apply(upload.uploadDate())))); + } + + public Flux list(Username user) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.selectFrom(PostgresUploadTable.TABLE_NAME) + .where(PostgresUploadTable.USER_NAME.eq(user.asString())))) + .map(this::uploadMetaDataFromRow); + } + + public Mono get(UploadId uploadId, Username user) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.selectFrom(PostgresUploadTable.TABLE_NAME) + .where(PostgresUploadTable.ID.eq(uploadId.getId())) + .and(PostgresUploadTable.USER_NAME.eq(user.asString())))) + .map(this::uploadMetaDataFromRow); + } + + public Mono delete(UploadId uploadId, Username user) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(PostgresUploadTable.TABLE_NAME) + .where(PostgresUploadTable.ID.eq(uploadId.getId())) + .and(PostgresUploadTable.USER_NAME.eq(user.asString())))); + } + + private UploadMetaData uploadMetaDataFromRow(Record record) { + return UploadMetaData.from( + UploadId.from(record.get(PostgresUploadTable.ID)), + Optional.ofNullable(record.get(PostgresUploadTable.CONTENT_TYPE)).map(ContentType::of).orElse(null), + record.get(PostgresUploadTable.SIZE), + blobIdFactory.from(record.get(PostgresUploadTable.BLOB_ID)), + PostgresCommons.LOCAL_DATE_TIME_INSTANT_FUNCTION.apply(record.get(PostgresUploadTable.UPLOAD_DATE, LocalDateTime.class))); + } +} diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadModule.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadModule.java new file mode 100644 index 00000000000..d3623c283a9 --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadModule.java @@ -0,0 +1,75 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.upload; + + +import static org.apache.james.jmap.postgres.upload.PostgresUploadModule.PostgresUploadTable.TABLE; + +import java.time.LocalDateTime; +import java.util.UUID; + +import org.apache.james.backends.postgres.PostgresCommons; +import org.apache.james.backends.postgres.PostgresIndex; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresUploadModule { + interface PostgresUploadTable { + + Table TABLE_NAME = DSL.table("uploads"); + + Field ID = DSL.field("id", SQLDataType.UUID.notNull()); + Field CONTENT_TYPE = DSL.field("content_type", SQLDataType.VARCHAR); + Field SIZE = DSL.field("size", SQLDataType.BIGINT.notNull()); + Field BLOB_ID = DSL.field("blob_id", SQLDataType.VARCHAR.notNull()); + Field USER_NAME = DSL.field("user_name", SQLDataType.VARCHAR.notNull()); + Field UPLOAD_DATE = DSL.field("upload_date", PostgresCommons.DataTypes.TIMESTAMP.notNull()); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(ID) + .column(CONTENT_TYPE) + .column(SIZE) + .column(BLOB_ID) + .column(USER_NAME) + .column(UPLOAD_DATE) + .primaryKey(ID))) + .supportsRowLevelSecurity() + .build(); + + PostgresIndex USER_NAME_INDEX = PostgresIndex.name("uploads_user_name_index") + .createIndexStep((dslContext, indexName) -> dslContext.createIndex(indexName) + .on(TABLE_NAME, USER_NAME)); + PostgresIndex ID_USERNAME_INDEX = PostgresIndex.name("uploads_id_user_name_index") + .createIndexStep((dslContext, indexName) -> dslContext.createIndex(indexName) + .on(TABLE_NAME, ID, USER_NAME)); + + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(TABLE) + .addIndex(PostgresUploadTable.USER_NAME_INDEX, PostgresUploadTable.ID_USERNAME_INDEX) + .build(); +} diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java new file mode 100644 index 00000000000..a5a0e23d82e --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java @@ -0,0 +1,96 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.upload; + +import static org.apache.james.blob.api.BlobStore.StoragePolicy.LOW_COST; + +import java.io.InputStream; +import java.time.Clock; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import org.apache.james.blob.api.BlobStore; +import org.apache.james.blob.api.BucketName; +import org.apache.james.core.Username; +import org.apache.james.jmap.api.model.Upload; +import org.apache.james.jmap.api.model.UploadId; +import org.apache.james.jmap.api.model.UploadMetaData; +import org.apache.james.jmap.api.model.UploadNotFoundException; +import org.apache.james.jmap.api.upload.UploadRepository; +import org.apache.james.mailbox.model.ContentType; + +import com.github.f4b6a3.uuid.UuidCreator; +import com.google.common.io.CountingInputStream; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresUploadRepository implements UploadRepository { + public static final BucketName UPLOAD_BUCKET = BucketName.of("jmap-uploads"); + private final BlobStore blobStore; + private final Clock clock; + private final PostgresUploadDAO.Factory uploadDAOFactory; + private final PostgresUploadDAO nonRLSUploadDAO; + + @Inject + @Singleton + public PostgresUploadRepository(BlobStore blobStore, Clock clock, + PostgresUploadDAO.Factory uploadDAOFactory, + PostgresUploadDAO nonRLSUploadDAO) { + this.blobStore = blobStore; + this.clock = clock; + this.uploadDAOFactory = uploadDAOFactory; + this.nonRLSUploadDAO = nonRLSUploadDAO; + } + + @Override + public Mono upload(InputStream data, ContentType contentType, Username user) { + UploadId uploadId = generateId(); + PostgresUploadDAO uploadDAO = uploadDAOFactory.create(user.getDomainPart()); + return Mono.fromCallable(() -> new CountingInputStream(data)) + .flatMap(countingInputStream -> Mono.from(blobStore.save(UPLOAD_BUCKET, countingInputStream, LOW_COST)) + .map(blobId -> UploadMetaData.from(uploadId, contentType, countingInputStream.getCount(), blobId, clock.instant())) + .flatMap(uploadMetaData -> uploadDAO.insert(uploadMetaData, user) + .thenReturn(uploadMetaData))); + } + + @Override + public Mono retrieve(UploadId id, Username user) { + return uploadDAOFactory.create(user.getDomainPart()).get(id, user) + .flatMap(upload -> Mono.from(blobStore.readReactive(UPLOAD_BUCKET, upload.blobId(), LOW_COST)) + .map(inputStream -> Upload.from(upload, () -> inputStream))) + .switchIfEmpty(Mono.error(() -> new UploadNotFoundException(id))); + } + + @Override + public Mono delete(UploadId id, Username user) { + return uploadDAOFactory.create(user.getDomainPart()).delete(id, user); + } + + @Override + public Flux listUploads(Username user) { + return uploadDAOFactory.create(user.getDomainPart()).list(user); + } + + private UploadId generateId() { + return UploadId.from(UuidCreator.getTimeOrderedEpoch()); + } +} diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepositoryTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepositoryTest.java new file mode 100644 index 00000000000..e8738bcb8ce --- /dev/null +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepositoryTest.java @@ -0,0 +1,64 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.upload; + +import java.time.Clock; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.blob.api.BlobStore; +import org.apache.james.blob.api.BucketName; +import org.apache.james.blob.api.HashBlobId; +import org.apache.james.blob.memory.MemoryBlobStoreDAO; +import org.apache.james.jmap.api.upload.UploadRepository; +import org.apache.james.jmap.api.upload.UploadRepositoryContract; +import org.apache.james.server.blob.deduplication.DeDuplicationBlobStore; +import org.apache.james.utils.UpdatableTickingClock; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; + +class PostgresUploadRepositoryTest implements UploadRepositoryContract { + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity( + PostgresModule.aggregateModules(PostgresUploadModule.MODULE)); + private UploadRepository testee; + private UpdatableTickingClock clock; + + @BeforeEach + void setUp() { + clock = new UpdatableTickingClock(Clock.systemUTC().instant()); + HashBlobId.Factory blobIdFactory = new HashBlobId.Factory(); + BlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory); + PostgresUploadDAO uploadDAO = new PostgresUploadDAO(postgresExtension.getNonRLSPostgresExecutor(), blobIdFactory); + PostgresUploadDAO.Factory uploadFactory = new PostgresUploadDAO.Factory(blobIdFactory, postgresExtension.getExecutorFactory()); + testee = new PostgresUploadRepository(blobStore, clock, uploadFactory, uploadDAO); + } + + @Override + public UploadRepository testee() { + return testee; + } + + @Override + public UpdatableTickingClock clock() { + return clock; + } +} diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadServiceTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadServiceTest.java new file mode 100644 index 00000000000..f5cb92a3384 --- /dev/null +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadServiceTest.java @@ -0,0 +1,76 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.upload; + +import java.time.Clock; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.quota.PostgresQuotaCurrentValueDAO; +import org.apache.james.backends.postgres.quota.PostgresQuotaModule; +import org.apache.james.blob.api.BlobStore; +import org.apache.james.blob.api.BucketName; +import org.apache.james.blob.api.HashBlobId; +import org.apache.james.blob.memory.MemoryBlobStoreDAO; +import org.apache.james.jmap.api.upload.UploadRepository; +import org.apache.james.jmap.api.upload.UploadService; +import org.apache.james.jmap.api.upload.UploadServiceContract; +import org.apache.james.jmap.api.upload.UploadServiceDefaultImpl; +import org.apache.james.jmap.api.upload.UploadUsageRepository; +import org.apache.james.server.blob.deduplication.DeDuplicationBlobStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresUploadServiceTest implements UploadServiceContract { + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity( + PostgresModule.aggregateModules(PostgresUploadModule.MODULE, PostgresQuotaModule.MODULE)); + + private PostgresUploadRepository uploadRepository; + private PostgresUploadUsageRepository uploadUsageRepository; + private UploadService testee; + + @BeforeEach + void setUp() { + HashBlobId.Factory blobIdFactory = new HashBlobId.Factory(); + BlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory); + PostgresUploadDAO uploadDAO = new PostgresUploadDAO(postgresExtension.getNonRLSPostgresExecutor(), blobIdFactory); + PostgresUploadDAO.Factory uploadFactory = new PostgresUploadDAO.Factory(blobIdFactory, postgresExtension.getExecutorFactory()); + uploadRepository = new PostgresUploadRepository( blobStore, Clock.systemUTC(),uploadFactory, uploadDAO); + uploadUsageRepository = new PostgresUploadUsageRepository(new PostgresQuotaCurrentValueDAO(postgresExtension.getPostgresExecutor())); + testee = new UploadServiceDefaultImpl(uploadRepository, uploadUsageRepository, UploadServiceContract.TEST_CONFIGURATION()); + } + + @Override + public UploadRepository uploadRepository() { + return uploadRepository; + } + + @Override + public UploadUsageRepository uploadUsageRepository() { + return uploadUsageRepository; + } + + @Override + public UploadService testee() { + return testee; + } +} From 69f0c085aa511aa7a7cf2e2e4646845ca6bf9e4e Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 24 Jan 2024 15:45:12 +0700 Subject: [PATCH 206/341] JAMES-2586 Implement Postgres upload usage repository --- .../upload/PostgresUploadUsageRepository.java | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadUsageRepository.java diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadUsageRepository.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadUsageRepository.java new file mode 100644 index 00000000000..a3d02cb5070 --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadUsageRepository.java @@ -0,0 +1,70 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.upload; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import org.apache.james.backends.postgres.quota.PostgresQuotaCurrentValueDAO; +import org.apache.james.core.Username; +import org.apache.james.core.quota.QuotaComponent; +import org.apache.james.core.quota.QuotaCurrentValue; +import org.apache.james.core.quota.QuotaSizeUsage; +import org.apache.james.core.quota.QuotaType; +import org.apache.james.jmap.api.upload.UploadUsageRepository; + +import reactor.core.publisher.Mono; + +public class PostgresUploadUsageRepository implements UploadUsageRepository { + private static final QuotaSizeUsage DEFAULT_QUOTA_SIZE_USAGE = QuotaSizeUsage.size(0); + + private final PostgresQuotaCurrentValueDAO quotaCurrentValueDAO; + + @Inject + @Singleton + public PostgresUploadUsageRepository(PostgresQuotaCurrentValueDAO quotaCurrentValueDAO) { + this.quotaCurrentValueDAO = quotaCurrentValueDAO; + } + + @Override + public Mono increaseSpace(Username username, QuotaSizeUsage usage) { + return quotaCurrentValueDAO.increase(QuotaCurrentValue.Key.of(QuotaComponent.JMAP_UPLOADS, username.asString(), QuotaType.SIZE), + usage.asLong()); + } + + @Override + public Mono decreaseSpace(Username username, QuotaSizeUsage usage) { + return quotaCurrentValueDAO.decrease(QuotaCurrentValue.Key.of(QuotaComponent.JMAP_UPLOADS, username.asString(), QuotaType.SIZE), + usage.asLong()); + } + + @Override + public Mono getSpaceUsage(Username username) { + return quotaCurrentValueDAO.getQuotaCurrentValue(QuotaCurrentValue.Key.of(QuotaComponent.JMAP_UPLOADS, username.asString(), QuotaType.SIZE)) + .map(quotaCurrentValue -> QuotaSizeUsage.size(quotaCurrentValue.getCurrentValue())).defaultIfEmpty(DEFAULT_QUOTA_SIZE_USAGE); + } + + @Override + public Mono resetSpace(Username username, QuotaSizeUsage usage) { + return getSpaceUsage(username) + .switchIfEmpty(Mono.just(QuotaSizeUsage.ZERO)) + .flatMap(quotaSizeUsage -> decreaseSpace(username, QuotaSizeUsage.size(quotaSizeUsage.asLong() - usage.asLong()))); + } +} From eefb176be6a0bd5d38315237518bf17c9ba1acf6 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 24 Jan 2024 15:45:25 +0700 Subject: [PATCH 207/341] JAMES-2586 Guice binding for Postgres upload --- .../org/apache/james/PostgresJmapModule.java | 7 ++-- .../container/guice/postgres-common/pom.xml | 5 +++ .../modules/data/PostgresDataJmapModule.java | 5 ++- .../PostgresDataJMapAggregateModule.java | 33 +++++++++++++++++++ 4 files changed, 45 insertions(+), 5 deletions(-) create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/PostgresDataJMapAggregateModule.java diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java index 5d2ce02a008..1dca0ac9be6 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java @@ -27,9 +27,10 @@ import org.apache.james.jmap.api.upload.UploadUsageRepository; import org.apache.james.jmap.memory.change.MemoryEmailChangeRepository; import org.apache.james.jmap.memory.change.MemoryMailboxChangeRepository; -import org.apache.james.jmap.memory.upload.InMemoryUploadUsageRepository; +import org.apache.james.jmap.postgres.PostgresDataJMapAggregateModule; import org.apache.james.jmap.postgres.change.PostgresEmailChangeModule; import org.apache.james.jmap.postgres.change.PostgresEmailChangeRepository; +import org.apache.james.jmap.postgres.upload.PostgresUploadUsageRepository; import org.apache.james.mailbox.AttachmentManager; import org.apache.james.mailbox.MessageIdManager; import org.apache.james.mailbox.RightManager; @@ -51,6 +52,8 @@ public class PostgresJmapModule extends AbstractModule { @Override protected void configure() { + Multibinder.newSetBinder(binder(), PostgresModule.class).addBinding().toInstance(PostgresDataJMapAggregateModule.MODULE); + Multibinder.newSetBinder(binder(), PostgresModule.class).addBinding().toInstance(PostgresEmailChangeModule.MODULE); bind(EmailChangeRepository.class).to(PostgresEmailChangeRepository.class); @@ -62,7 +65,7 @@ protected void configure() { bind(Limit.class).annotatedWith(Names.named(MemoryEmailChangeRepository.LIMIT_NAME)).toInstance(Limit.of(256)); bind(Limit.class).annotatedWith(Names.named(MemoryMailboxChangeRepository.LIMIT_NAME)).toInstance(Limit.of(256)); - bind(UploadUsageRepository.class).to(InMemoryUploadUsageRepository.class); + bind(UploadUsageRepository.class).to(PostgresUploadUsageRepository.class); bind(DefaultVacationService.class).in(Scopes.SINGLETON); bind(VacationService.class).to(DefaultVacationService.class); diff --git a/server/container/guice/postgres-common/pom.xml b/server/container/guice/postgres-common/pom.xml index f6d77993a5c..9bf67159596 100644 --- a/server/container/guice/postgres-common/pom.xml +++ b/server/container/guice/postgres-common/pom.xml @@ -52,6 +52,11 @@ ${james.groupId} james-server-data-file + + ${james.groupId} + james-server-data-jmap-postgres + ${project.version} + ${james.groupId} james-server-data-postgres diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java index e156b153ddd..cab393a93d8 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java @@ -36,7 +36,7 @@ import org.apache.james.jmap.memory.identity.MemoryCustomIdentityDAO; import org.apache.james.jmap.memory.projections.MemoryEmailQueryView; import org.apache.james.jmap.memory.projections.MemoryMessageFastViewProjection; -import org.apache.james.jmap.memory.upload.InMemoryUploadRepository; +import org.apache.james.jmap.postgres.upload.PostgresUploadRepository; import org.apache.james.mailbox.store.extractor.DefaultTextExtractor; import org.apache.james.user.api.DeleteUserDataTaskStep; import org.apache.james.user.api.UsernameChangeTaskStep; @@ -52,8 +52,7 @@ protected void configure() { bind(MemoryAccessTokenRepository.class).in(Scopes.SINGLETON); bind(AccessTokenRepository.class).to(MemoryAccessTokenRepository.class); - bind(InMemoryUploadRepository.class).in(Scopes.SINGLETON); - bind(UploadRepository.class).to(InMemoryUploadRepository.class); + bind(UploadRepository.class).to(PostgresUploadRepository.class); bind(MemoryCustomIdentityDAO.class).in(Scopes.SINGLETON); bind(CustomIdentityDAO.class).to(MemoryCustomIdentityDAO.class); diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/PostgresDataJMapAggregateModule.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/PostgresDataJMapAggregateModule.java new file mode 100644 index 00000000000..28459dbf974 --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/PostgresDataJMapAggregateModule.java @@ -0,0 +1,33 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres; + +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.jmap.postgres.change.PostgresEmailChangeModule; +import org.apache.james.jmap.postgres.projections.PostgresMessageFastViewProjectionModule; +import org.apache.james.jmap.postgres.upload.PostgresUploadModule; + +public interface PostgresDataJMapAggregateModule { + + PostgresModule MODULE = PostgresModule.aggregateModules( + PostgresUploadModule.MODULE, + PostgresMessageFastViewProjectionModule.MODULE, + PostgresEmailChangeModule.MODULE); +} From e4ac56cde512197636e8d9fb0959e381ba5553f8 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 24 Jan 2024 17:37:08 +0700 Subject: [PATCH 208/341] JAMES-2586: The UploadRepositoryCleanupTask should rely on the UploadRepository interface - It will more abstract for another implement (here is Postgres) --- .../server/JmapUploadCleanupModule.java | 6 +++--- .../upload/CassandraUploadRepository.java | 9 ++++----- .../upload/CassandraUploadRepositoryTest.java | 10 ++++++++-- .../postgres/upload/PostgresUploadDAO.java | 7 +++++++ .../postgres/upload/PostgresUploadModule.java | 9 ++++++--- .../upload/PostgresUploadRepository.java | 18 ++++++++++++++++++ .../jmap/api/upload/UploadRepository.java | 3 +++ .../upload/InMemoryUploadRepository.java | 11 +++++++++++ .../api/upload/UploadRepositoryContract.scala | 17 +++++++++++++++++ .../upload/InMemoryUploadRepositoryTest.java | 10 +++++++++- .../webadmin/data/jmap/JmapUploadRoutes.java | 6 +++--- .../data/jmap/UploadCleanupTaskDTO.java | 4 ++-- .../data/jmap/UploadRepositoryCleanupTask.java | 10 ++++++---- 13 files changed, 97 insertions(+), 23 deletions(-) diff --git a/server/container/guice/protocols/webadmin-jmap/src/main/java/org/apache/james/modules/server/JmapUploadCleanupModule.java b/server/container/guice/protocols/webadmin-jmap/src/main/java/org/apache/james/modules/server/JmapUploadCleanupModule.java index d746556705d..632e7af2734 100644 --- a/server/container/guice/protocols/webadmin-jmap/src/main/java/org/apache/james/modules/server/JmapUploadCleanupModule.java +++ b/server/container/guice/protocols/webadmin-jmap/src/main/java/org/apache/james/modules/server/JmapUploadCleanupModule.java @@ -19,7 +19,7 @@ package org.apache.james.modules.server; -import org.apache.james.jmap.cassandra.upload.CassandraUploadRepository; +import org.apache.james.jmap.api.upload.UploadRepository; import org.apache.james.server.task.json.dto.AdditionalInformationDTO; import org.apache.james.server.task.json.dto.AdditionalInformationDTOModule; import org.apache.james.server.task.json.dto.TaskDTO; @@ -46,8 +46,8 @@ protected void configure() { } @ProvidesIntoSet - public TaskDTOModule uploadRepositoryCleanupTask(CassandraUploadRepository cassandraUploadRepository) { - return UploadCleanupTaskDTO.module(cassandraUploadRepository); + public TaskDTOModule uploadRepositoryCleanupTask(UploadRepository uploadRepository) { + return UploadCleanupTaskDTO.module(uploadRepository); } @ProvidesIntoSet diff --git a/server/data/data-jmap-cassandra/src/main/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepository.java b/server/data/data-jmap-cassandra/src/main/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepository.java index b66ed6ca15a..9de6f27c023 100644 --- a/server/data/data-jmap-cassandra/src/main/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepository.java +++ b/server/data/data-jmap-cassandra/src/main/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepository.java @@ -46,9 +46,7 @@ import reactor.core.publisher.Mono; public class CassandraUploadRepository implements UploadRepository { - public static final BucketName UPLOAD_BUCKET = BucketName.of("jmap-uploads"); - public static final Duration EXPIRE_DURATION = Duration.ofDays(7); private final UploadDAO uploadDAO; private final BlobStore blobStore; private final Clock clock; @@ -91,10 +89,11 @@ public Flux listUploads(Username user) { .map(UploadDAO.UploadRepresentation::toUploadMetaData); } - public Mono purge() { - Instant sevenDaysAgo = clock.instant().minus(EXPIRE_DURATION); + @Override + public Mono deleteByUploadDateBefore(Duration expireDuration) { + Instant expirationTime = clock.instant().minus(expireDuration); return Flux.from(uploadDAO.all()) - .filter(upload -> upload.getUploadDate().isBefore(sevenDaysAgo)) + .filter(upload -> upload.getUploadDate().isBefore(expirationTime)) .flatMap(upload -> Mono.from(blobStore.delete(UPLOAD_BUCKET, upload.getBlobId())) .then(uploadDAO.delete(upload.getUser(), upload.getId())), DEFAULT_CONCURRENCY) .then(); diff --git a/server/data/data-jmap-cassandra/src/test/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepositoryTest.java b/server/data/data-jmap-cassandra/src/test/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepositoryTest.java index 22c47139711..285ad9a0908 100644 --- a/server/data/data-jmap-cassandra/src/test/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepositoryTest.java +++ b/server/data/data-jmap-cassandra/src/test/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepositoryTest.java @@ -29,6 +29,7 @@ import org.apache.james.jmap.api.upload.UploadRepository; import org.apache.james.jmap.api.upload.UploadRepositoryContract; import org.apache.james.server.blob.deduplication.DeDuplicationBlobStore; +import org.apache.james.utils.UpdatableTickingClock; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.extension.RegisterExtension; @@ -39,10 +40,10 @@ class CassandraUploadRepositoryTest implements UploadRepositoryContract { @RegisterExtension static CassandraClusterExtension cassandra = new CassandraClusterExtension(UploadModule.MODULE); private CassandraUploadRepository testee; - + private UpdatableTickingClock clock; @BeforeEach void setUp() { - Clock clock = Clock.systemUTC(); + clock = new UpdatableTickingClock(Clock.systemUTC().instant()); testee = new CassandraUploadRepository(new UploadDAO(cassandra.getCassandraCluster().getConf(), new PlainBlobId.Factory()), new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.of("default"), new PlainBlobId.Factory()), clock); @@ -70,4 +71,9 @@ public void deleteShouldReturnTrueWhenRowExists() { public void deleteShouldReturnFalseWhenRowDoesNotExist() { UploadRepositoryContract.super.deleteShouldReturnFalseWhenRowDoesNotExist(); } + + @Override + public UpdatableTickingClock clock() { + return clock; + } } \ No newline at end of file diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadDAO.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadDAO.java index 512096abfc0..b6f43f5c303 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadDAO.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadDAO.java @@ -29,6 +29,7 @@ import javax.inject.Named; import javax.inject.Singleton; +import org.apache.commons.lang3.tuple.Pair; import org.apache.james.backends.postgres.PostgresCommons; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.blob.api.BlobId; @@ -99,6 +100,12 @@ public Mono delete(UploadId uploadId, Username user) { .and(PostgresUploadTable.USER_NAME.eq(user.asString())))); } + public Flux> listByUploadDateBefore(LocalDateTime before) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.selectFrom(PostgresUploadTable.TABLE_NAME) + .where(PostgresUploadTable.UPLOAD_DATE.lessThan(before)))) + .map(record -> Pair.of(uploadMetaDataFromRow(record), Username.of(record.get(PostgresUploadTable.USER_NAME)))); + } + private UploadMetaData uploadMetaDataFromRow(Record record) { return UploadMetaData.from( UploadId.from(record.get(PostgresUploadTable.ID)), diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadModule.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadModule.java index d3623c283a9..cfc9d097a5e 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadModule.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadModule.java @@ -60,16 +60,19 @@ interface PostgresUploadTable { .build(); PostgresIndex USER_NAME_INDEX = PostgresIndex.name("uploads_user_name_index") - .createIndexStep((dslContext, indexName) -> dslContext.createIndex(indexName) + .createIndexStep((dslContext, indexName) -> dslContext.createIndexIfNotExists(indexName) .on(TABLE_NAME, USER_NAME)); PostgresIndex ID_USERNAME_INDEX = PostgresIndex.name("uploads_id_user_name_index") - .createIndexStep((dslContext, indexName) -> dslContext.createIndex(indexName) + .createIndexStep((dslContext, indexName) -> dslContext.createIndexIfNotExists(indexName) .on(TABLE_NAME, ID, USER_NAME)); + PostgresIndex UPLOAD_DATE_INDEX = PostgresIndex.name("uploads_upload_date_index") + .createIndexStep((dslContext, indexName) -> dslContext.createIndexIfNotExists(indexName) + .on(TABLE_NAME, UPLOAD_DATE)); } PostgresModule MODULE = PostgresModule.builder() .addTable(TABLE) - .addIndex(PostgresUploadTable.USER_NAME_INDEX, PostgresUploadTable.ID_USERNAME_INDEX) + .addIndex(PostgresUploadTable.USER_NAME_INDEX, PostgresUploadTable.ID_USERNAME_INDEX, PostgresUploadTable.UPLOAD_DATE_INDEX) .build(); } diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java index a5a0e23d82e..233fda50281 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java @@ -19,10 +19,14 @@ package org.apache.james.jmap.postgres.upload; +import static org.apache.james.backends.postgres.PostgresCommons.INSTANT_TO_LOCAL_DATE_TIME; import static org.apache.james.blob.api.BlobStore.StoragePolicy.LOW_COST; +import static org.apache.james.util.ReactorUtils.DEFAULT_CONCURRENCY; import java.io.InputStream; import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; import javax.inject.Inject; import javax.inject.Singleton; @@ -90,6 +94,20 @@ public Flux listUploads(Username user) { return uploadDAOFactory.create(user.getDomainPart()).list(user); } + @Override + public Mono deleteByUploadDateBefore(Duration expireDuration) { + LocalDateTime expirationTime = INSTANT_TO_LOCAL_DATE_TIME.apply(clock.instant().minus(expireDuration)); + + return Flux.from(nonRLSUploadDAO.listByUploadDateBefore(expirationTime)) + .flatMap(uploadPair -> { + Username username = uploadPair.getRight(); + UploadMetaData upload = uploadPair.getLeft(); + return Mono.from(blobStore.delete(UPLOAD_BUCKET, upload.blobId())) + .then(nonRLSUploadDAO.delete(upload.uploadId(), username)); + }, DEFAULT_CONCURRENCY) + .then(); + } + private UploadId generateId() { return UploadId.from(UuidCreator.getTimeOrderedEpoch()); } diff --git a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadRepository.java b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadRepository.java index 2d130b087bd..60c7d207acb 100644 --- a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadRepository.java +++ b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/upload/UploadRepository.java @@ -20,6 +20,7 @@ package org.apache.james.jmap.api.upload; import java.io.InputStream; +import java.time.Duration; import org.apache.james.core.Username; import org.apache.james.jmap.api.model.Upload; @@ -36,5 +37,7 @@ public interface UploadRepository { Publisher delete(UploadId id, Username user); Publisher listUploads(Username user); + + Publisher deleteByUploadDateBefore(Duration expireDuration); } diff --git a/server/data/data-jmap/src/main/java/org/apache/james/jmap/memory/upload/InMemoryUploadRepository.java b/server/data/data-jmap/src/main/java/org/apache/james/jmap/memory/upload/InMemoryUploadRepository.java index ae4bce2908e..c3b98a95a3d 100644 --- a/server/data/data-jmap/src/main/java/org/apache/james/jmap/memory/upload/InMemoryUploadRepository.java +++ b/server/data/data-jmap/src/main/java/org/apache/james/jmap/memory/upload/InMemoryUploadRepository.java @@ -22,6 +22,7 @@ import java.io.ByteArrayInputStream; import java.io.InputStream; import java.time.Clock; +import java.time.Duration; import java.time.Instant; import java.util.HashMap; import java.util.Map; @@ -109,6 +110,16 @@ public Publisher listUploads(Username user) { .map(pair -> pair.right); } + @Override + public Publisher deleteByUploadDateBefore(Duration expireDuration) { + Instant expirationTime = clock.instant().minus(expireDuration); + return Flux.fromIterable(uploadStore.values()) + .filter(pair -> pair.right.uploadDate().isBefore(expirationTime)) + .flatMap(pair -> Mono.from(blobStore.delete(bucketName, pair.right.blobId())) + .then(Mono.fromRunnable(() -> uploadStore.remove(pair.right.uploadId())))) + .then(); + } + private Mono retrieveUpload(UploadMetaData uploadMetaData) { return Mono.from(blobStore.readBytes(bucketName, uploadMetaData.blobId())) .map(content -> Upload.from(uploadMetaData, () -> new ByteArrayInputStream(content))); diff --git a/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/upload/UploadRepositoryContract.scala b/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/upload/UploadRepositoryContract.scala index c3993fa4421..68554a1664a 100644 --- a/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/upload/UploadRepositoryContract.scala +++ b/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/upload/UploadRepositoryContract.scala @@ -21,6 +21,7 @@ import java.io.InputStream import java.nio.charset.StandardCharsets + import java.time.{Clock, Duration} import java.util.UUID import org.apache.commons.io.IOUtils @@ -29,6 +30,7 @@ import org.apache.james.jmap.api.model.{Upload, UploadId, UploadMetaData, UploadNotFoundException} import org.apache.james.jmap.api.upload.UploadRepositoryContract.{CONTENT_TYPE, DATA_STRING, USER} import org.apache.james.mailbox.model.ContentType + import org.apache.james.utils.UpdatableTickingClock import org.assertj.core.api.Assertions.{assertThat, assertThatCode, assertThatThrownBy} import org.assertj.core.groups.Tuple.tuple import org.junit.jupiter.api.Test @@ -49,6 +51,8 @@ def testee: UploadRepository + def clock: UpdatableTickingClock + def data(): InputStream = IOUtils.toInputStream(DATA_STRING, StandardCharsets.UTF_8) @Test @@ -201,4 +205,17 @@ assertThat(SMono.fromPublisher(testee.delete(uploadIdOfAlice, Username.of("Bob"))).block()).isFalse } + def deleteByUploadDateBeforeShouldRemoveExpiredUploads(): Unit = { + val uploadId1: UploadId = SMono.fromPublisher(testee.upload(data(), CONTENT_TYPE, USER)).block().uploadId + clock.setInstant(clock.instant().plus(8, java.time.temporal.ChronoUnit.DAYS)) + val uploadId2: UploadId = SMono.fromPublisher(testee.upload(data(), CONTENT_TYPE, USER)).block().uploadId + + SMono(testee.deleteByUploadDateBefore(Duration.ofDays(7))).block(); + + assertThatThrownBy(() => SMono.fromPublisher(testee.retrieve(uploadId1, USER)).block()) + .isInstanceOf(classOf[UploadNotFoundException]) + assertThat(SMono.fromPublisher(testee.retrieve(uploadId2, USER)).block()) + .isNotNull + } + } diff --git a/server/data/data-jmap/src/test/java/org/apache/james/jmap/memory/upload/InMemoryUploadRepositoryTest.java b/server/data/data-jmap/src/test/java/org/apache/james/jmap/memory/upload/InMemoryUploadRepositoryTest.java index e949525ac2a..c40d119895e 100644 --- a/server/data/data-jmap/src/test/java/org/apache/james/jmap/memory/upload/InMemoryUploadRepositoryTest.java +++ b/server/data/data-jmap/src/test/java/org/apache/james/jmap/memory/upload/InMemoryUploadRepositoryTest.java @@ -28,20 +28,28 @@ import org.apache.james.jmap.api.upload.UploadRepository; import org.apache.james.jmap.api.upload.UploadRepositoryContract; import org.apache.james.server.blob.deduplication.DeDuplicationBlobStore; +import org.apache.james.utils.UpdatableTickingClock; import org.junit.jupiter.api.BeforeEach; public class InMemoryUploadRepositoryTest implements UploadRepositoryContract { private UploadRepository testee; + private UpdatableTickingClock clock; @BeforeEach void setUp() { + clock = new UpdatableTickingClock(Clock.systemUTC().instant()); BlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, new PlainBlobId.Factory()); - testee = new InMemoryUploadRepository(blobStore, Clock.systemUTC()); + testee = new InMemoryUploadRepository(blobStore, clock); } @Override public UploadRepository testee() { return testee; } + + @Override + public UpdatableTickingClock clock() { + return clock; + } } diff --git a/server/protocols/webadmin/webadmin-jmap/src/main/java/org/apache/james/webadmin/data/jmap/JmapUploadRoutes.java b/server/protocols/webadmin/webadmin-jmap/src/main/java/org/apache/james/webadmin/data/jmap/JmapUploadRoutes.java index 950c850c35c..49730d138f4 100644 --- a/server/protocols/webadmin/webadmin-jmap/src/main/java/org/apache/james/webadmin/data/jmap/JmapUploadRoutes.java +++ b/server/protocols/webadmin/webadmin-jmap/src/main/java/org/apache/james/webadmin/data/jmap/JmapUploadRoutes.java @@ -23,7 +23,7 @@ import jakarta.inject.Inject; -import org.apache.james.jmap.cassandra.upload.CassandraUploadRepository; +import org.apache.james.jmap.api.upload.UploadRepository; import org.apache.james.task.Task; import org.apache.james.task.TaskManager; import org.apache.james.webadmin.Routes; @@ -39,12 +39,12 @@ public class JmapUploadRoutes implements Routes { public static final String BASE_PATH = "/jmap/uploads"; - private final CassandraUploadRepository uploadRepository; + private final UploadRepository uploadRepository; private final TaskManager taskManager; private final JsonTransformer jsonTransformer; @Inject - public JmapUploadRoutes(CassandraUploadRepository uploadRepository, TaskManager taskManager, JsonTransformer jsonTransformer) { + public JmapUploadRoutes(UploadRepository uploadRepository, TaskManager taskManager, JsonTransformer jsonTransformer) { this.uploadRepository = uploadRepository; this.taskManager = taskManager; this.jsonTransformer = jsonTransformer; diff --git a/server/protocols/webadmin/webadmin-jmap/src/main/java/org/apache/james/webadmin/data/jmap/UploadCleanupTaskDTO.java b/server/protocols/webadmin/webadmin-jmap/src/main/java/org/apache/james/webadmin/data/jmap/UploadCleanupTaskDTO.java index 8a3aa2b8720..6ffaffee7f7 100644 --- a/server/protocols/webadmin/webadmin-jmap/src/main/java/org/apache/james/webadmin/data/jmap/UploadCleanupTaskDTO.java +++ b/server/protocols/webadmin/webadmin-jmap/src/main/java/org/apache/james/webadmin/data/jmap/UploadCleanupTaskDTO.java @@ -21,7 +21,7 @@ import java.util.Locale; -import org.apache.james.jmap.cassandra.upload.CassandraUploadRepository; +import org.apache.james.jmap.api.upload.UploadRepository; import org.apache.james.json.DTOModule; import org.apache.james.server.task.json.dto.TaskDTO; import org.apache.james.server.task.json.dto.TaskDTOModule; @@ -48,7 +48,7 @@ public String getScope() { return scope; } - public static TaskDTOModule module(CassandraUploadRepository uploadRepository) { + public static TaskDTOModule module(UploadRepository uploadRepository) { return DTOModule .forDomainObject(UploadRepositoryCleanupTask.class) .convertToDTO(UploadCleanupTaskDTO.class) diff --git a/server/protocols/webadmin/webadmin-jmap/src/main/java/org/apache/james/webadmin/data/jmap/UploadRepositoryCleanupTask.java b/server/protocols/webadmin/webadmin-jmap/src/main/java/org/apache/james/webadmin/data/jmap/UploadRepositoryCleanupTask.java index aeaee5ee1df..419c9cf707d 100644 --- a/server/protocols/webadmin/webadmin-jmap/src/main/java/org/apache/james/webadmin/data/jmap/UploadRepositoryCleanupTask.java +++ b/server/protocols/webadmin/webadmin-jmap/src/main/java/org/apache/james/webadmin/data/jmap/UploadRepositoryCleanupTask.java @@ -22,11 +22,12 @@ import static org.apache.james.webadmin.data.jmap.UploadRepositoryCleanupTask.CleanupScope.EXPIRED; import java.time.Clock; +import java.time.Duration; import java.time.Instant; import java.util.Arrays; import java.util.Optional; -import org.apache.james.jmap.cassandra.upload.CassandraUploadRepository; +import org.apache.james.jmap.api.upload.UploadRepository; import org.apache.james.task.Task; import org.apache.james.task.TaskExecutionDetails; import org.apache.james.task.TaskType; @@ -40,6 +41,7 @@ public class UploadRepositoryCleanupTask implements Task { private static final Logger LOGGER = LoggerFactory.getLogger(UploadRepositoryCleanupTask.class); public static final TaskType TASK_TYPE = TaskType.of("UploadRepositoryCleanupTask"); + public static final Duration EXPIRE_DURATION = Duration.ofDays(7); enum CleanupScope { EXPIRED; @@ -79,10 +81,10 @@ public CleanupScope getScope() { } } - private final CassandraUploadRepository uploadRepository; + private final UploadRepository uploadRepository; private final CleanupScope scope; - public UploadRepositoryCleanupTask(CassandraUploadRepository uploadRepository, CleanupScope scope) { + public UploadRepositoryCleanupTask(UploadRepository uploadRepository, CleanupScope scope) { this.uploadRepository = uploadRepository; this.scope = scope; } @@ -90,7 +92,7 @@ public UploadRepositoryCleanupTask(CassandraUploadRepository uploadRepository, C @Override public Result run() { if (EXPIRED.equals(scope)) { - return uploadRepository.purge() + return Mono.from(uploadRepository.deleteByUploadDateBefore(EXPIRE_DURATION)) .thenReturn(Result.COMPLETED) .onErrorResume(error -> { LOGGER.error("Error when cleaning upload repository", error); From 92ffe798ed64231ee57efd33b114b6048ba61a86 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 24 Jan 2024 17:48:21 +0700 Subject: [PATCH 209/341] JAMES-2586: Guice binding JmapUploadCleanupModule for Postgres webadmin --- .../apache/james/PostgresJamesServerMain.java | 4 ++- ...PostgresWebAdminServerIntegrationTest.java | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index e3d866bc22a..0a4a2c85906 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -59,6 +59,7 @@ import org.apache.james.modules.server.InconsistencyQuotasSolvingRoutesModule; import org.apache.james.modules.server.JMXServerModule; import org.apache.james.modules.server.JmapTasksModule; +import org.apache.james.modules.server.JmapUploadCleanupModule; import org.apache.james.modules.server.MailQueueRoutesModule; import org.apache.james.modules.server.MailRepositoriesRoutesModule; import org.apache.james.modules.server.MailboxRoutesModule; @@ -90,7 +91,8 @@ public class PostgresJamesServerMain implements JamesServerMain { new WebAdminReIndexingTaskSerializationModule(), new MailboxesExportRoutesModule(), new UserIdentityModule(), - new DLPRoutesModule()); + new DLPRoutesModule(), + new JmapUploadCleanupModule()); private static final Module PROTOCOLS = Modules.combine( new IMAPServerModule(), diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresWebAdminServerIntegrationTest.java b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresWebAdminServerIntegrationTest.java index 7ce3a16d374..40ccdfe683b 100644 --- a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresWebAdminServerIntegrationTest.java +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresWebAdminServerIntegrationTest.java @@ -19,7 +19,10 @@ package org.apache.james.webadmin.integration.postgres; +import static io.restassured.RestAssured.given; +import static io.restassured.RestAssured.with; import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; +import static org.hamcrest.Matchers.is; import org.apache.james.JamesServerBuilder; import org.apache.james.JamesServerExtension; @@ -27,7 +30,10 @@ import org.apache.james.PostgresJamesServerMain; import org.apache.james.SearchConfiguration; import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.task.TaskManager; import org.apache.james.webadmin.integration.WebAdminServerIntegrationTest; +import org.apache.james.webadmin.routes.TasksRoutes; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; public class PostgresWebAdminServerIntegrationTest extends WebAdminServerIntegrationTest { @@ -43,4 +49,23 @@ public class PostgresWebAdminServerIntegrationTest extends WebAdminServerIntegra .extension(PostgresExtension.empty()) .server(PostgresJamesServerMain::createServer) .build(); + + @Test + void cleanUploadRepositoryShouldComplete() { + String taskId = given() + .queryParam("scope", "expired") + .delete("jmap/uploads") + .jsonPath() + .getString("taskId"); + + with() + .basePath(TasksRoutes.BASE) + .when() + .get(taskId + "/await") + .then() + .body("status", is(TaskManager.Status.COMPLETED.getValue())) + .body("taskId", is(taskId)) + .body("type", is("UploadRepositoryCleanupTask")) + .body("additionalInformation.scope", is("expired")); + } } From 4484b7a20d7bb43c7915576f7a1097959d8d08ce Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 29 Jan 2024 13:59:20 +0700 Subject: [PATCH 210/341] JAMES-2586 Disable row-level security by default in postgres.properties - Fix the startup docker-compose not work --- .../sample-configuration/postgres.properties | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/apps/postgres-app/sample-configuration/postgres.properties b/server/apps/postgres-app/sample-configuration/postgres.properties index b93071532e7..c0bcf88cf06 100644 --- a/server/apps/postgres-app/sample-configuration/postgres.properties +++ b/server/apps/postgres-app/sample-configuration/postgres.properties @@ -16,11 +16,11 @@ database.username=james # String. Required. Database password of the user. database.password=secret1 +# Boolean. Optional, default to false. Whether to enable row level security. +row.level.security.enabled=false + # String. It is required when row.level.security.enabled is true. Database username with the permission of bypassing RLS. -database.non-rls.username=nonrlsjames +#database.non-rls.username=nonrlsjames # String. It is required when row.level.security.enabled is true. Database password of non-rls user. -database.non-rls.password=secret1 - -# Boolean. Optional, default to false. Whether to enable row level security. -row.level.security.enabled=true +#database.non-rls.password=secret1 From 19250841da1f8ffe8aac8564126dfb83c7d580cc Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Fri, 26 Jan 2024 13:44:54 +0700 Subject: [PATCH 211/341] JAMES-2586 Implement Postgres Push subscription --- .../backends/postgres/PostgresCommons.java | 8 +- .../apache/james/PostgresJamesServerMain.java | 2 - .../org/apache/james/PostgresJmapModule.java | 4 + .../PostgresDataJMapAggregateModule.java | 4 +- .../PostgresPushSubscriptionDAO.java | 171 ++++++++++++++++++ .../PostgresPushSubscriptionModule.java | 80 ++++++++ .../PostgresPushSubscriptionRepository.java | 140 ++++++++++++++ ...ostgresPushSubscriptionRepositoryTest.java | 62 +++++++ 8 files changed, 467 insertions(+), 4 deletions(-) create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionDAO.java create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionModule.java create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionRepository.java create mode 100644 server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionRepositoryTest.java diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresCommons.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresCommons.java index 88d36936c83..d465740e40e 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresCommons.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresCommons.java @@ -21,7 +21,9 @@ import java.time.Instant; import java.time.LocalDateTime; +import java.time.ZoneId; import java.time.ZoneOffset; +import java.time.ZonedDateTime; import java.util.Date; import java.util.Optional; import java.util.function.Function; @@ -47,7 +49,7 @@ public interface DataTypes { DataType TIMESTAMP = SQLDataType.LOCALDATETIME(6); // text[] - DataType STRING_ARRAY = SQLDataType.CLOB.getArrayDataType(); + DataType STRING_ARRAY = SQLDataType.VARCHAR.getArrayDataType(); } @@ -68,6 +70,10 @@ public static Field tableField(Table table, Field field) { .map(Date::from) .orElse(null); + public static final Function LOCAL_DATE_TIME_ZONED_DATE_TIME_FUNCTION = localDateTime -> Optional.ofNullable(localDateTime) + .map(value -> value.atZone(ZoneId.of("UTC"))) + .orElse(null); + public static final Function LOCAL_DATE_TIME_INSTANT_FUNCTION = localDateTime -> Optional.ofNullable(localDateTime) .map(value -> value.toInstant(ZoneOffset.UTC)) .orElse(null); diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index 0a4a2c85906..367148dcbe6 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -23,7 +23,6 @@ import org.apache.james.data.UsersRepositoryModuleChooser; import org.apache.james.jmap.draft.JMAPListenerModule; -import org.apache.james.jmap.memory.pushsubscription.MemoryPushSubscriptionModule; import org.apache.james.modules.BlobExportMechanismModule; import org.apache.james.modules.MailboxModule; import org.apache.james.modules.MailetProcessingModule; @@ -120,7 +119,6 @@ public class PostgresJamesServerMain implements JamesServerMain { public static final Module JMAP = Modules.combine( new PostgresJmapModule(), new PostgresDataJmapModule(), - new MemoryPushSubscriptionModule(), new JmapEventBusModule(), new JMAPServerModule(), new JmapTasksModule()); diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java index 1dca0ac9be6..018982f312b 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java @@ -24,12 +24,14 @@ import org.apache.james.jmap.api.change.Limit; import org.apache.james.jmap.api.change.MailboxChangeRepository; import org.apache.james.jmap.api.change.State; +import org.apache.james.jmap.api.pushsubscription.PushSubscriptionRepository; import org.apache.james.jmap.api.upload.UploadUsageRepository; import org.apache.james.jmap.memory.change.MemoryEmailChangeRepository; import org.apache.james.jmap.memory.change.MemoryMailboxChangeRepository; import org.apache.james.jmap.postgres.PostgresDataJMapAggregateModule; import org.apache.james.jmap.postgres.change.PostgresEmailChangeModule; import org.apache.james.jmap.postgres.change.PostgresEmailChangeRepository; +import org.apache.james.jmap.postgres.pushsubscription.PostgresPushSubscriptionRepository; import org.apache.james.jmap.postgres.upload.PostgresUploadUsageRepository; import org.apache.james.mailbox.AttachmentManager; import org.apache.james.mailbox.MessageIdManager; @@ -84,5 +86,7 @@ protected void configure() { bind(StoreRightManager.class).in(Scopes.SINGLETON); bind(State.Factory.class).toInstance(State.Factory.DEFAULT); + + bind(PushSubscriptionRepository.class).to(PostgresPushSubscriptionRepository.class); } } diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/PostgresDataJMapAggregateModule.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/PostgresDataJMapAggregateModule.java index 28459dbf974..592f8fb2b2d 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/PostgresDataJMapAggregateModule.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/PostgresDataJMapAggregateModule.java @@ -22,6 +22,7 @@ import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.jmap.postgres.change.PostgresEmailChangeModule; import org.apache.james.jmap.postgres.projections.PostgresMessageFastViewProjectionModule; +import org.apache.james.jmap.postgres.pushsubscription.PostgresPushSubscriptionModule; import org.apache.james.jmap.postgres.upload.PostgresUploadModule; public interface PostgresDataJMapAggregateModule { @@ -29,5 +30,6 @@ public interface PostgresDataJMapAggregateModule { PostgresModule MODULE = PostgresModule.aggregateModules( PostgresUploadModule.MODULE, PostgresMessageFastViewProjectionModule.MODULE, - PostgresEmailChangeModule.MODULE); + PostgresEmailChangeModule.MODULE, + PostgresPushSubscriptionModule.MODULE); } diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionDAO.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionDAO.java new file mode 100644 index 00000000000..69f0abf41c5 --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionDAO.java @@ -0,0 +1,171 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.pushsubscription; + +import static org.apache.james.backends.postgres.PostgresCommons.IN_CLAUSE_MAX_SIZE; +import static org.apache.james.backends.postgres.PostgresCommons.LOCAL_DATE_TIME_ZONED_DATE_TIME_FUNCTION; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.Collection; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.core.Username; +import org.apache.james.jmap.api.change.TypeStateFactory; +import org.apache.james.jmap.api.model.PushSubscription; +import org.apache.james.jmap.api.model.PushSubscriptionExpiredTime; +import org.apache.james.jmap.api.model.PushSubscriptionId; +import org.apache.james.jmap.api.model.PushSubscriptionKeys; +import org.apache.james.jmap.api.model.PushSubscriptionServerURL; +import org.apache.james.jmap.api.model.TypeName; +import org.apache.james.jmap.postgres.pushsubscription.PostgresPushSubscriptionModule.PushSubscriptionTable; +import org.jooq.Record; + +import com.google.common.base.Preconditions; +import com.google.common.collect.Iterables; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import scala.jdk.javaapi.CollectionConverters; +import scala.jdk.javaapi.OptionConverters; + +public class PostgresPushSubscriptionDAO { + private final PostgresExecutor postgresExecutor; + private final TypeStateFactory typeStateFactory; + + public PostgresPushSubscriptionDAO(PostgresExecutor postgresExecutor, TypeStateFactory typeStateFactory) { + this.postgresExecutor = postgresExecutor; + this.typeStateFactory = typeStateFactory; + } + + public Mono save(Username username, PushSubscription pushSubscription) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(PushSubscriptionTable.TABLE_NAME) + .set(PushSubscriptionTable.USER, username.asString()) + .set(PushSubscriptionTable.DEVICE_CLIENT_ID, pushSubscription.deviceClientId()) + .set(PushSubscriptionTable.ID, pushSubscription.id().value()) + .set(PushSubscriptionTable.EXPIRES, pushSubscription.expires().value().toLocalDateTime()) + .set(PushSubscriptionTable.TYPES, CollectionConverters.asJava(pushSubscription.types()) + .stream().map(TypeName::asString).toArray(String[]::new)) + .set(PushSubscriptionTable.URL, pushSubscription.url().value().toString()) + .set(PushSubscriptionTable.VERIFICATION_CODE, pushSubscription.verificationCode()) + .set(PushSubscriptionTable.VALIDATED, pushSubscription.validated()) + .set(PushSubscriptionTable.ENCRYPT_PUBLIC_KEY, OptionConverters.toJava(pushSubscription.keys().map(PushSubscriptionKeys::p256dh)).orElse(null)) + .set(PushSubscriptionTable.ENCRYPT_AUTH_SECRET, OptionConverters.toJava(pushSubscription.keys().map(PushSubscriptionKeys::auth)).orElse(null)))); + } + + public Flux listByUsername(Username username) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.selectFrom(PushSubscriptionTable.TABLE_NAME) + .where(PushSubscriptionTable.USER.eq(username.asString())))) + .map(this::recordAsPushSubscription); + } + + public Flux getByUsernameAndIds(Username username, Collection ids) { + Function, Flux> queryPublisherFunction = idsMatching -> postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.selectFrom(PushSubscriptionTable.TABLE_NAME) + .where(PushSubscriptionTable.USER.eq(username.asString())) + .and(PushSubscriptionTable.ID.in(idsMatching.stream().map(PushSubscriptionId::value).collect(Collectors.toList()))))) + .map(this::recordAsPushSubscription); + + if (ids.size() <= IN_CLAUSE_MAX_SIZE) { + return queryPublisherFunction.apply(ids); + } else { + return Flux.fromIterable(Iterables.partition(ids, IN_CLAUSE_MAX_SIZE)) + .flatMap(queryPublisherFunction); + } + } + + public Mono deleteByUsername(Username username) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(PushSubscriptionTable.TABLE_NAME) + .where(PushSubscriptionTable.USER.eq(username.asString())))); + } + + public Mono deleteByUsernameAndId(Username username, PushSubscriptionId id) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(PushSubscriptionTable.TABLE_NAME) + .where(PushSubscriptionTable.USER.eq(username.asString())) + .and(PushSubscriptionTable.ID.eq(id.value())))); + } + + public Mono> updateType(Username username, PushSubscriptionId id, Set newTypes) { + Preconditions.checkNotNull(newTypes, "newTypes should not be null"); + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.update(PushSubscriptionTable.TABLE_NAME) + .set(PushSubscriptionTable.TYPES, newTypes.stream().map(TypeName::asString).toArray(String[]::new)) + .where(PushSubscriptionTable.USER.eq(username.asString())) + .and(PushSubscriptionTable.ID.eq(id.value())) + .returning(PushSubscriptionTable.TYPES))) + .map(this::extractTypes); + } + + public Mono updateValidated(Username username, PushSubscriptionId id, boolean validated) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.update(PushSubscriptionTable.TABLE_NAME) + .set(PushSubscriptionTable.VALIDATED, validated) + .where(PushSubscriptionTable.USER.eq(username.asString())) + .and(PushSubscriptionTable.ID.eq(id.value())) + .returning(PushSubscriptionTable.VALIDATED))) + .map(record -> record.get(PushSubscriptionTable.VALIDATED)); + } + + public Mono updateExpireTime(Username username, PushSubscriptionId id, ZonedDateTime newExpire) { + Preconditions.checkNotNull(newExpire, "newExpire should not be null"); + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.update(PushSubscriptionTable.TABLE_NAME) + .set(PushSubscriptionTable.EXPIRES, newExpire.toLocalDateTime()) + .where(PushSubscriptionTable.USER.eq(username.asString())) + .and(PushSubscriptionTable.ID.eq(id.value())) + .returning(PushSubscriptionTable.EXPIRES))) + .map(record -> LOCAL_DATE_TIME_ZONED_DATE_TIME_FUNCTION.apply(record.get(PushSubscriptionTable.EXPIRES))); + } + + public Mono existDeviceClientId(Username username, String deviceClientId) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select(PushSubscriptionTable.DEVICE_CLIENT_ID) + .from(PushSubscriptionTable.TABLE_NAME) + .where(PushSubscriptionTable.USER.eq(username.asString())) + .and(PushSubscriptionTable.DEVICE_CLIENT_ID.eq(deviceClientId)) + .limit(1))) + .hasElement(); + } + + private PushSubscription recordAsPushSubscription(Record record) { + try { + return new PushSubscription(new PushSubscriptionId(record.get(PushSubscriptionTable.ID)), + record.get(PushSubscriptionTable.DEVICE_CLIENT_ID), + PushSubscriptionServerURL.from(record.get(PushSubscriptionTable.URL)).get(), + scala.jdk.javaapi.OptionConverters.toScala(Optional.ofNullable(record.get(PushSubscriptionTable.ENCRYPT_PUBLIC_KEY)) + .flatMap(key -> Optional.ofNullable(record.get(PushSubscriptionTable.ENCRYPT_AUTH_SECRET)) + .map(secret -> new PushSubscriptionKeys(key, secret)))), + record.get(PushSubscriptionTable.VERIFICATION_CODE), + record.get(PushSubscriptionTable.VALIDATED), + Optional.ofNullable(record.get(PushSubscriptionTable.EXPIRES, LocalDateTime.class)) + .map(LOCAL_DATE_TIME_ZONED_DATE_TIME_FUNCTION) + .map(PushSubscriptionExpiredTime::new).get(), + CollectionConverters.asScala(extractTypes(record)).toSeq()); + } catch (Exception e) { + throw new RuntimeException("Error while parsing PushSubscription from database", e); + } + } + + private Set extractTypes(Record record) { + return Arrays.stream(record.get(PushSubscriptionTable.TYPES)) + .map(string -> typeStateFactory.parse(string).right().get()) + .collect(Collectors.toSet()); + } +} \ No newline at end of file diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionModule.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionModule.java new file mode 100644 index 00000000000..eceda1c2b10 --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionModule.java @@ -0,0 +1,80 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.pushsubscription; + +import java.time.LocalDateTime; +import java.util.UUID; + +import org.apache.james.backends.postgres.PostgresCommons; +import org.apache.james.backends.postgres.PostgresIndex; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresPushSubscriptionModule { + + interface PushSubscriptionTable { + Table TABLE_NAME = DSL.table("push_subscription"); + Field USER = DSL.field("username", SQLDataType.VARCHAR.notNull()); + Field DEVICE_CLIENT_ID = DSL.field("device_client_id", SQLDataType.VARCHAR.notNull()); + + Field ID = DSL.field("id", SQLDataType.UUID.notNull()); + Field EXPIRES = DSL.field("expires", PostgresCommons.DataTypes.TIMESTAMP); + Field TYPES = DSL.field("types", PostgresCommons.DataTypes.STRING_ARRAY.notNull()); + + Field URL = DSL.field("url", SQLDataType.VARCHAR.notNull()); + Field VERIFICATION_CODE = DSL.field("verification_code", SQLDataType.VARCHAR); + Field ENCRYPT_PUBLIC_KEY = DSL.field("encrypt_public_key", SQLDataType.VARCHAR); + Field ENCRYPT_AUTH_SECRET = DSL.field("encrypt_auth_secret", SQLDataType.VARCHAR); + Field VALIDATED = DSL.field("validated", SQLDataType.BOOLEAN.notNull()); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(USER) + .column(DEVICE_CLIENT_ID) + .column(ID) + .column(EXPIRES) + .column(TYPES) + .column(URL) + .column(VERIFICATION_CODE) + .column(ENCRYPT_PUBLIC_KEY) + .column(ENCRYPT_AUTH_SECRET) + .column(VALIDATED) + .primaryKey(USER, DEVICE_CLIENT_ID))) + .supportsRowLevelSecurity() + .build(); + + PostgresIndex USERNAME_INDEX = PostgresIndex.name("push_subscription_username_index") + .createIndexStep((dslContext, indexName) -> dslContext.createIndexIfNotExists(indexName) + .on(TABLE_NAME, USER)); + PostgresIndex USERNAME_ID_INDEX = PostgresIndex.name("push_subscription_username_id_index") + .createIndexStep((dslContext, indexName) -> dslContext.createIndexIfNotExists(indexName) + .on(TABLE_NAME, USER, ID)); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(PushSubscriptionTable.TABLE) + .addIndex(PushSubscriptionTable.USERNAME_INDEX, PushSubscriptionTable.USERNAME_ID_INDEX) + .build(); +} diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionRepository.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionRepository.java new file mode 100644 index 00000000000..c2370e43ad9 --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionRepository.java @@ -0,0 +1,140 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.pushsubscription; + +import static org.apache.james.jmap.api.pushsubscription.PushSubscriptionHelpers.evaluateExpiresTime; +import static org.apache.james.jmap.api.pushsubscription.PushSubscriptionHelpers.isInThePast; +import static org.apache.james.jmap.api.pushsubscription.PushSubscriptionHelpers.isInvalidPushSubscriptionKey; + +import java.time.Clock; +import java.time.ZonedDateTime; +import java.util.Optional; +import java.util.Set; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.core.Username; +import org.apache.james.jmap.api.change.TypeStateFactory; +import org.apache.james.jmap.api.model.DeviceClientIdInvalidException; +import org.apache.james.jmap.api.model.ExpireTimeInvalidException; +import org.apache.james.jmap.api.model.InvalidPushSubscriptionKeys; +import org.apache.james.jmap.api.model.PushSubscription; +import org.apache.james.jmap.api.model.PushSubscriptionCreationRequest; +import org.apache.james.jmap.api.model.PushSubscriptionExpiredTime; +import org.apache.james.jmap.api.model.PushSubscriptionId; +import org.apache.james.jmap.api.model.PushSubscriptionNotFoundException; +import org.apache.james.jmap.api.model.TypeName; +import org.apache.james.jmap.api.pushsubscription.PushSubscriptionRepository; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import scala.jdk.javaapi.OptionConverters; + +public class PostgresPushSubscriptionRepository implements PushSubscriptionRepository { + private final Clock clock; + private final TypeStateFactory typeStateFactory; + private final PostgresExecutor.Factory executorFactory; + + @Inject + @Singleton + public PostgresPushSubscriptionRepository(Clock clock, TypeStateFactory typeStateFactory, PostgresExecutor.Factory executorFactory) { + this.clock = clock; + this.typeStateFactory = typeStateFactory; + this.executorFactory = executorFactory; + } + + @Override + public Mono save(Username username, PushSubscriptionCreationRequest request) { + PushSubscription pushSubscription = PushSubscription.from(request, + evaluateExpiresTime(OptionConverters.toJava(request.expires().map(PushSubscriptionExpiredTime::value)), clock)); + + PostgresPushSubscriptionDAO pushSubscriptionDAO = getDAO(username); + return pushSubscriptionDAO.existDeviceClientId(username, request.deviceClientId()) + .handle((isDuplicated, sink) -> { + if (isInThePast(request.expires(), clock)) { + sink.error(new ExpireTimeInvalidException(request.expires().get().value(), "expires must be greater than now")); + return; + } + if (isDuplicated) { + sink.error(new DeviceClientIdInvalidException(request.deviceClientId(), "deviceClientId must be unique")); + return; + } + if (isInvalidPushSubscriptionKey(request.keys())) { + sink.error(new InvalidPushSubscriptionKeys(request.keys().get())); + } + }) + .then(Mono.defer(() -> pushSubscriptionDAO.save(username, pushSubscription)) + .thenReturn(pushSubscription)); + } + + @Override + public Mono updateExpireTime(Username username, PushSubscriptionId id, ZonedDateTime newExpire) { + return Mono.just(newExpire) + .handle((inputTime, sink) -> { + if (newExpire.isBefore(ZonedDateTime.now(clock))) { + sink.error(new ExpireTimeInvalidException(inputTime, "expires must be greater than now")); + } + }) + .then(getDAO(username).updateExpireTime(username, id, evaluateExpiresTime(Optional.of(newExpire), clock).value()) + .map(PushSubscriptionExpiredTime::new) + .switchIfEmpty(Mono.error(() -> new PushSubscriptionNotFoundException(id)))); + } + + @Override + public Mono updateTypes(Username username, PushSubscriptionId id, Set types) { + return getDAO(username).updateType(username, id, types) + .switchIfEmpty(Mono.error(() -> new PushSubscriptionNotFoundException(id))) + .then(); + } + + @Override + public Mono validateVerificationCode(Username username, PushSubscriptionId id) { + return getDAO(username) + .updateValidated(username, id, true) + .switchIfEmpty(Mono.error(() -> new PushSubscriptionNotFoundException(id))) + .then(); + } + + @Override + public Mono revoke(Username username, PushSubscriptionId id) { + return getDAO(username).deleteByUsernameAndId(username, id); + } + + @Override + public Mono delete(Username username) { + return getDAO(username).deleteByUsername(username); + } + + @Override + public Flux get(Username username, Set ids) { + return getDAO(username).getByUsernameAndIds(username, ids); + } + + @Override + public Flux list(Username username) { + return getDAO(username).listByUsername(username); + } + + private PostgresPushSubscriptionDAO getDAO(Username username) { + return new PostgresPushSubscriptionDAO(executorFactory.create(username.getDomainPart()), typeStateFactory); + } +} diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionRepositoryTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionRepositoryTest.java new file mode 100644 index 00000000000..7a471569dfb --- /dev/null +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionRepositoryTest.java @@ -0,0 +1,62 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.pushsubscription; + +import java.util.Set; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.jmap.api.change.TypeStateFactory; +import org.apache.james.jmap.api.model.TypeName; +import org.apache.james.jmap.api.pushsubscription.PushSubscriptionRepository; +import org.apache.james.jmap.api.pushsubscription.PushSubscriptionRepositoryContract; +import org.apache.james.utils.UpdatableTickingClock; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; + +import scala.jdk.javaapi.CollectionConverters; + +class PostgresPushSubscriptionRepositoryTest implements PushSubscriptionRepositoryContract { + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity( + PostgresModule.aggregateModules(PostgresPushSubscriptionModule.MODULE)); + + UpdatableTickingClock clock; + PushSubscriptionRepository pushSubscriptionRepository; + + @BeforeEach + void setup() { + clock = new UpdatableTickingClock(PushSubscriptionRepositoryContract.NOW()); + pushSubscriptionRepository = new PostgresPushSubscriptionRepository(clock, + new TypeStateFactory((Set) CollectionConverters.asJava(PushSubscriptionRepositoryContract.TYPE_NAME_SET())), + postgresExtension.getExecutorFactory()); + } + + @Override + public UpdatableTickingClock clock() { + return clock; + } + + @Override + public PushSubscriptionRepository testee() { + return pushSubscriptionRepository; + } +} From eb06ce1f12a78dff0d9659cb71ee08591b8aca46 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 29 Jan 2024 14:42:40 +0700 Subject: [PATCH 212/341] JAMES-2586 Introduce sql script to clean up PGSL data --- server/apps/postgres-app/README.adoc | 8 ++++++++ server/apps/postgres-app/clean_up.sql | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 server/apps/postgres-app/clean_up.sql diff --git a/server/apps/postgres-app/README.adoc b/server/apps/postgres-app/README.adoc index 3ab88b00ffd..219d276b29d 100644 --- a/server/apps/postgres-app/README.adoc +++ b/server/apps/postgres-app/README.adoc @@ -124,3 +124,11 @@ To run it, simply type: .... docker compose -f docker-compose-distributed.yml up -d .... + +== Administration Operations +=== Clean up data + +To clean up some specific data, that will never be used again after a long time, you can execute the SQL queries `clean_up.sql`. +The never used data are: +- mailbox_change +- email_change \ No newline at end of file diff --git a/server/apps/postgres-app/clean_up.sql b/server/apps/postgres-app/clean_up.sql new file mode 100644 index 00000000000..cee84dce803 --- /dev/null +++ b/server/apps/postgres-app/clean_up.sql @@ -0,0 +1,21 @@ +-- This is a script to delete old rows from some tables. One of the attempts to clean up the never-used data after a long time. + +DO +$$ + DECLARE + days_to_keep INTEGER; + BEGIN + -- Set the number of days dynamically + days_to_keep := 60; + + -- Delete rows older than the specified number of days in email_change + DELETE + FROM email_change + WHERE date < current_timestamp - interval '1 day' * days_to_keep; + + -- Delete rows older than the specified number of days in mailbox_change + DELETE + FROM email_change + WHERE date < current_timestamp - interval '1 day' * days_to_keep; + END +$$; \ No newline at end of file From 94064cce12052ba1d7842be75c082b53b13b53be Mon Sep 17 00:00:00 2001 From: hungphan227 <45198168+hungphan227@users.noreply.github.com> Date: Wed, 31 Jan 2024 09:19:34 +0700 Subject: [PATCH 213/341] JAMES-2586 Implement PostgresThreadIdGuessingAlgorithm (#1941) --- ...assandraThreadIdGuessingAlgorithmTest.java | 6 +- mailbox/postgres/pom.xml | 9 +- .../postgres/DeleteMessageListener.java | 16 +- .../PostgresMailboxAggregateModule.java | 4 +- .../PostgresMailboxSessionMapperFactory.java | 5 +- .../mailbox/postgres/PostgresMessageId.java | 3 +- .../PostgresThreadIdGuessingAlgorithm.java | 91 ++++++++++ .../postgres/mail/dao/PostgresThreadDAO.java | 120 +++++++++++++ .../mail/dao/PostgresThreadModule.java | 72 ++++++++ .../DeleteMessageListenerContract.java | 75 ++++++++ .../postgres/DeleteMessageListenerTest.java | 8 +- .../DeleteMessageListenerWithRLSTest.java | 8 +- .../PostgresMailboxManagerAttachmentTest.java | 4 +- ...PostgresThreadIdGuessingAlgorithmTest.java | 166 ++++++++++++++++++ .../SearchThreadIdGuessingAlgorithmTest.java | 3 +- .../ThreadIdGuessingAlgorithmContract.java | 14 +- .../mailbox/PostgresMailboxModule.java | 6 +- 17 files changed, 584 insertions(+), 26 deletions(-) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresThreadIdGuessingAlgorithm.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresThreadDAO.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresThreadModule.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresThreadIdGuessingAlgorithmTest.java diff --git a/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/CassandraThreadIdGuessingAlgorithmTest.java b/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/CassandraThreadIdGuessingAlgorithmTest.java index 546657676f9..0edc7dbe794 100644 --- a/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/CassandraThreadIdGuessingAlgorithmTest.java +++ b/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/CassandraThreadIdGuessingAlgorithmTest.java @@ -94,8 +94,10 @@ protected MessageId initOtherBasedMessageId() { } @Override - protected Flux saveThreadData(Username username, Set mimeMessageIds, MessageId messageId, ThreadId threadId, Optional baseSubject) { - return threadDAO.insertSome(username, hashMimeMessagesIds(mimeMessageIds), messageId, threadId, hashSubject(baseSubject)); + protected void saveThreadData(Username username, Set mimeMessageIds, MessageId messageId, ThreadId threadId, Optional baseSubject) { + threadDAO.insertSome(username, hashMimeMessagesIds(mimeMessageIds), messageId, threadId, hashSubject(baseSubject)) + .then() + .block(); } @Test diff --git a/mailbox/postgres/pom.xml b/mailbox/postgres/pom.xml index 33edb9ed015..925acd4052e 100644 --- a/mailbox/postgres/pom.xml +++ b/mailbox/postgres/pom.xml @@ -29,7 +29,9 @@ apache-james-mailbox-postgres Apache James :: Mailbox :: Postgres - + + 5.3.7 + @@ -142,6 +144,11 @@ com.fasterxml.jackson.datatype jackson-datatype-jdk8 + + com.github.f4b6a3 + uuid-creator + ${uuid-creator.version} + com.sun.mail javax.mail diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java index 367578d1e9c..79f739b0e0c 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java @@ -37,6 +37,7 @@ import org.apache.james.mailbox.postgres.mail.dao.PostgresAttachmentDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresThreadDAO; import org.apache.james.util.ReactorUtils; import org.reactivestreams.Publisher; @@ -60,18 +61,21 @@ public static class DeleteMessageListenerGroup extends Group { private final PostgresMessageDAO.Factory messageDAOFactory; private final PostgresMailboxMessageDAO.Factory mailboxMessageDAOFactory; private final PostgresAttachmentDAO.Factory attachmentDAOFactory; + private final PostgresThreadDAO.Factory threadDAOFactory; @Inject public DeleteMessageListener(BlobStore blobStore, PostgresMailboxMessageDAO.Factory mailboxMessageDAOFactory, PostgresMessageDAO.Factory messageDAOFactory, PostgresAttachmentDAO.Factory attachmentDAOFactory, + PostgresThreadDAO.Factory threadDAOFactory, Set deletionCallbackList) { this.messageDAOFactory = messageDAOFactory; this.mailboxMessageDAOFactory = mailboxMessageDAOFactory; this.blobStore = blobStore; this.deletionCallbackList = deletionCallbackList; this.attachmentDAOFactory = attachmentDAOFactory; + this.threadDAOFactory = threadDAOFactory; } @Override @@ -101,9 +105,10 @@ private Mono handleMailboxDeletion(MailboxDeletion event) { PostgresMessageDAO postgresMessageDAO = messageDAOFactory.create(event.getUsername().getDomainPart()); PostgresMailboxMessageDAO postgresMailboxMessageDAO = mailboxMessageDAOFactory.create(event.getUsername().getDomainPart()); PostgresAttachmentDAO attachmentDAO = attachmentDAOFactory.create(event.getUsername().getDomainPart()); + PostgresThreadDAO threadDAO = threadDAOFactory.create(event.getUsername().getDomainPart()); return postgresMailboxMessageDAO.deleteByMailboxId((PostgresMailboxId) event.getMailboxId()) - .flatMap(msgId -> handleMessageDeletion(postgresMessageDAO, postgresMailboxMessageDAO, attachmentDAO, msgId, event.getMailboxId(), event.getMailboxPath().getUser()), + .flatMap(msgId -> handleMessageDeletion(postgresMessageDAO, postgresMailboxMessageDAO, attachmentDAO, threadDAO, msgId, event.getMailboxId(), event.getMailboxPath().getUser()), LOW_CONCURRENCY) .then(); } @@ -112,27 +117,30 @@ private Mono handleMessageDeletion(Expunged event) { PostgresMessageDAO postgresMessageDAO = messageDAOFactory.create(event.getUsername().getDomainPart()); PostgresMailboxMessageDAO postgresMailboxMessageDAO = mailboxMessageDAOFactory.create(event.getUsername().getDomainPart()); PostgresAttachmentDAO attachmentDAO = attachmentDAOFactory.create(event.getUsername().getDomainPart()); + PostgresThreadDAO threadDAO = threadDAOFactory.create(event.getUsername().getDomainPart()); return Flux.fromIterable(event.getExpunged() .values()) .map(MessageMetaData::getMessageId) .map(PostgresMessageId.class::cast) - .flatMap(msgId -> handleMessageDeletion(postgresMessageDAO, postgresMailboxMessageDAO, attachmentDAO, msgId, event.getMailboxId(), event.getMailboxPath().getUser()), LOW_CONCURRENCY) + .flatMap(msgId -> handleMessageDeletion(postgresMessageDAO, postgresMailboxMessageDAO, attachmentDAO, threadDAO, msgId, event.getMailboxId(), event.getMailboxPath().getUser()), LOW_CONCURRENCY) .then(); } private Mono handleMessageDeletion(PostgresMessageDAO postgresMessageDAO, PostgresMailboxMessageDAO postgresMailboxMessageDAO, PostgresAttachmentDAO attachmentDAO, + PostgresThreadDAO threadDAO, PostgresMessageId messageId, MailboxId mailboxId, Username owner) { return Mono.just(messageId) - .filterWhen(msgId -> isUnreferenced(messageId, postgresMailboxMessageDAO)) + .filterWhen(msgId -> isUnreferenced(msgId, postgresMailboxMessageDAO)) .flatMap(msgId -> postgresMessageDAO.retrieveMessage(messageId) .flatMap(executeDeletionCallbacks(mailboxId, owner)) .then(deleteBodyBlob(msgId, postgresMessageDAO)) - .then(deleteAttachment(messageId, attachmentDAO)) + .then(deleteAttachment(msgId, attachmentDAO)) + .then(threadDAO.deleteSome(owner, msgId)) .then(postgresMessageDAO.deleteByMessageId(msgId))); } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAggregateModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAggregateModule.java index 2635555b6d6..90a52df7c4b 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAggregateModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxAggregateModule.java @@ -23,6 +23,7 @@ import org.apache.james.mailbox.postgres.mail.PostgresAttachmentModule; import org.apache.james.mailbox.postgres.mail.PostgresMailboxModule; import org.apache.james.mailbox.postgres.mail.PostgresMessageModule; +import org.apache.james.mailbox.postgres.mail.dao.PostgresThreadModule; import org.apache.james.mailbox.postgres.user.PostgresSubscriptionModule; public interface PostgresMailboxAggregateModule { @@ -32,5 +33,6 @@ public interface PostgresMailboxAggregateModule { PostgresSubscriptionModule.MODULE, PostgresMessageModule.MODULE, PostgresMailboxAnnotationModule.MODULE, - PostgresAttachmentModule.MODULE); + PostgresAttachmentModule.MODULE, + PostgresThreadModule.MODULE); } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java index 690b5ef7ba6..8b9c50118cf 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java @@ -38,6 +38,7 @@ import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresThreadDAO; import org.apache.james.mailbox.postgres.user.PostgresSubscriptionDAO; import org.apache.james.mailbox.postgres.user.PostgresSubscriptionMapper; import org.apache.james.mailbox.store.MailboxSessionMapperFactory; @@ -131,7 +132,9 @@ protected DeleteMessageListener deleteMessageListener() { PostgresMessageDAO.Factory postgresMessageDAOFactory = new PostgresMessageDAO.Factory(blobIdFactory, executorFactory); PostgresMailboxMessageDAO.Factory postgresMailboxMessageDAOFactory = new PostgresMailboxMessageDAO.Factory(executorFactory); PostgresAttachmentDAO.Factory attachmentDAOFactory = new PostgresAttachmentDAO.Factory(executorFactory, blobIdFactory); + PostgresThreadDAO.Factory threadDAOFactory = new PostgresThreadDAO.Factory(executorFactory); - return new DeleteMessageListener(blobStore, postgresMailboxMessageDAOFactory, postgresMessageDAOFactory, attachmentDAOFactory, ImmutableSet.of()); + return new DeleteMessageListener(blobStore, postgresMailboxMessageDAOFactory, postgresMessageDAOFactory, + attachmentDAOFactory, threadDAOFactory, ImmutableSet.of()); } } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMessageId.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMessageId.java index c4012f19993..57594b45987 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMessageId.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMessageId.java @@ -24,6 +24,7 @@ import org.apache.james.mailbox.model.MessageId; +import com.github.f4b6a3.uuid.UuidCreator; import com.google.common.base.MoreObjects; public class PostgresMessageId implements MessageId { @@ -32,7 +33,7 @@ public static class Factory implements MessageId.Factory { @Override public PostgresMessageId generate() { - return of(UUID.randomUUID()); + return of(UuidCreator.getTimeOrderedEpoch()); } public static PostgresMessageId of(UUID uuid) { diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresThreadIdGuessingAlgorithm.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresThreadIdGuessingAlgorithm.java new file mode 100644 index 00000000000..5b61ab73f5e --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresThreadIdGuessingAlgorithm.java @@ -0,0 +1,91 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres; + +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import javax.inject.Inject; + +import org.apache.commons.lang3.tuple.Pair; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.exception.ThreadNotFoundException; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.model.ThreadId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresThreadDAO; +import org.apache.james.mailbox.store.mail.ThreadIdGuessingAlgorithm; +import org.apache.james.mailbox.store.mail.model.MimeMessageId; +import org.apache.james.mailbox.store.mail.model.Subject; +import org.apache.james.mailbox.store.search.SearchUtil; + +import com.google.common.hash.Hashing; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresThreadIdGuessingAlgorithm implements ThreadIdGuessingAlgorithm { + private final PostgresThreadDAO.Factory threadDAOFactory; + + @Inject + public PostgresThreadIdGuessingAlgorithm(PostgresThreadDAO.Factory threadDAOFactory) { + this.threadDAOFactory = threadDAOFactory; + } + + @Override + public Mono guessThreadIdReactive(MessageId messageId, Optional mimeMessageId, Optional inReplyTo, + Optional> references, Optional subject, MailboxSession session) { + PostgresThreadDAO threadDAO = threadDAOFactory.create(session.getUser().getDomainPart()); + + Set hashMimeMessageIds = buildMimeMessageIdSet(mimeMessageId, inReplyTo, references) + .stream() + .map(mimeMessageId1 -> Hashing.murmur3_32_fixed().hashBytes(mimeMessageId1.getValue().getBytes()).asInt()) + .collect(Collectors.toSet()); + + Optional hashBaseSubject = subject.map(value -> new Subject(SearchUtil.getBaseSubject(value.getValue()))) + .map(subject1 -> Hashing.murmur3_32_fixed().hashBytes(subject1.getValue().getBytes()).asInt()); + + return threadDAO.findThreads(session.getUser(), hashMimeMessageIds) + .filter(pair -> pair.getLeft().equals(hashBaseSubject)) + .next() + .map(Pair::getRight) + .switchIfEmpty(Mono.just(ThreadId.fromBaseMessageId(messageId))) + .flatMap(threadId -> threadDAO + .insertSome(session.getUser(), hashMimeMessageIds, PostgresMessageId.class.cast(messageId), threadId, hashBaseSubject) + .then(Mono.just(threadId))); + } + + @Override + public Flux getMessageIdsInThread(ThreadId threadId, MailboxSession session) { + PostgresThreadDAO threadDAO = threadDAOFactory.create(session.getUser().getDomainPart()); + return threadDAO.findMessageIds(threadId, session.getUser()) + .switchIfEmpty(Flux.error(new ThreadNotFoundException(threadId))); + } + + private Set buildMimeMessageIdSet(Optional mimeMessageId, Optional inReplyTo, Optional> references) { + Set mimeMessageIds = new HashSet<>(); + mimeMessageId.ifPresent(mimeMessageIds::add); + inReplyTo.ifPresent(mimeMessageIds::add); + references.ifPresent(mimeMessageIds::addAll); + return mimeMessageIds; + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresThreadDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresThreadDAO.java new file mode 100644 index 00000000000..4557086528b --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresThreadDAO.java @@ -0,0 +1,120 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail.dao; + +import static org.apache.james.backends.postgres.PostgresCommons.IN_CLAUSE_MAX_SIZE; +import static org.apache.james.mailbox.postgres.mail.dao.PostgresThreadModule.PostgresThreadTable.HASH_BASE_SUBJECT; +import static org.apache.james.mailbox.postgres.mail.dao.PostgresThreadModule.PostgresThreadTable.HASH_MIME_MESSAGE_ID; +import static org.apache.james.mailbox.postgres.mail.dao.PostgresThreadModule.PostgresThreadTable.MESSAGE_ID; +import static org.apache.james.mailbox.postgres.mail.dao.PostgresThreadModule.PostgresThreadTable.TABLE_NAME; +import static org.apache.james.mailbox.postgres.mail.dao.PostgresThreadModule.PostgresThreadTable.THREAD_ID; +import static org.apache.james.mailbox.postgres.mail.dao.PostgresThreadModule.PostgresThreadTable.USERNAME; + +import java.util.Collection; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import org.apache.commons.lang3.tuple.Pair; +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.core.Domain; +import org.apache.james.core.Username; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.model.ThreadId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.jooq.Record; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresThreadDAO { + public static class Factory { + private final PostgresExecutor.Factory executorFactory; + + @Inject + @Singleton + public Factory(PostgresExecutor.Factory executorFactory) { + this.executorFactory = executorFactory; + } + + public PostgresThreadDAO create(Optional domain) { + return new PostgresThreadDAO(executorFactory.create(domain)); + } + } + + private final PostgresExecutor postgresExecutor; + + public PostgresThreadDAO(PostgresExecutor postgresExecutor) { + this.postgresExecutor = postgresExecutor; + } + + public Mono insertSome(Username username, Set hashMimeMessageIds, PostgresMessageId messageId, ThreadId threadId, Optional hashBaseSubject) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.batch( + hashMimeMessageIds.stream().map(hashMimeMessageId -> dslContext.insertInto(TABLE_NAME) + .set(USERNAME, username.asString()) + .set(HASH_MIME_MESSAGE_ID, hashMimeMessageId) + .set(MESSAGE_ID, messageId.asUuid()) + .set(THREAD_ID, ((PostgresMessageId) threadId.getBaseMessageId()).asUuid()) + .set(HASH_BASE_SUBJECT, hashBaseSubject.orElse(null))) + .collect(ImmutableList.toImmutableList())))); + } + + public Flux, ThreadId>> findThreads(Username username, Set hashMimeMessageIds) { + Function, Flux, ThreadId>>> function = hashMimeMessageIdSubSet -> + postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(THREAD_ID, HASH_BASE_SUBJECT) + .from(TABLE_NAME) + .where(USERNAME.eq(username.asString())) + .and(HASH_MIME_MESSAGE_ID.in(hashMimeMessageIdSubSet)))) + .map(this::readRecord); + + if (hashMimeMessageIds.size() <= IN_CLAUSE_MAX_SIZE) { + return function.apply(hashMimeMessageIds); + } else { + return Flux.fromIterable(Iterables.partition(hashMimeMessageIds, IN_CLAUSE_MAX_SIZE)) + .flatMap(function); + } + } + + public Flux findMessageIds(ThreadId threadId, Username username) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(MESSAGE_ID) + .from(TABLE_NAME) + .where(USERNAME.eq(username.asString())) + .and(THREAD_ID.eq(PostgresMessageId.class.cast(threadId.getBaseMessageId()).asUuid())) + .orderBy(MESSAGE_ID))) + .map(record -> PostgresMessageId.Factory.of(record.get(MESSAGE_ID))); + } + + public Pair, ThreadId> readRecord(Record record) { + return Pair.of(Optional.ofNullable(record.get(HASH_BASE_SUBJECT)), + ThreadId.fromBaseMessageId(PostgresMessageId.Factory.of(record.get(THREAD_ID)))); + } + + public Mono deleteSome(Username username, PostgresMessageId messageId) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(TABLE_NAME) + .where(USERNAME.eq(username.asString())) + .and(MESSAGE_ID.eq(messageId.asUuid())))); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresThreadModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresThreadModule.java new file mode 100644 index 00000000000..046db43c82e --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresThreadModule.java @@ -0,0 +1,72 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail.dao; + +import static org.apache.james.mailbox.postgres.mail.dao.PostgresThreadModule.PostgresThreadTable.MESSAGE_ID_INDEX; +import static org.apache.james.mailbox.postgres.mail.dao.PostgresThreadModule.PostgresThreadTable.TABLE; +import static org.apache.james.mailbox.postgres.mail.dao.PostgresThreadModule.PostgresThreadTable.THREAD_ID_INDEX; + +import java.util.UUID; + +import org.apache.james.backends.postgres.PostgresIndex; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresThreadModule { + interface PostgresThreadTable { + Table TABLE_NAME = DSL.table("thread"); + + Field USERNAME = DSL.field("username", SQLDataType.VARCHAR(255).notNull()); + Field HASH_MIME_MESSAGE_ID = DSL.field("hash_mime_message_id", SQLDataType.INTEGER.notNull()); + Field MESSAGE_ID = DSL.field("message_id", SQLDataType.UUID.notNull()); + Field THREAD_ID = DSL.field("thread_id", SQLDataType.UUID.notNull()); + Field HASH_BASE_SUBJECT = DSL.field("hash_base_subject", SQLDataType.INTEGER); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(USERNAME) + .column(HASH_MIME_MESSAGE_ID) + .column(MESSAGE_ID) + .column(THREAD_ID) + .column(HASH_BASE_SUBJECT) + .constraint(DSL.primaryKey(USERNAME, HASH_MIME_MESSAGE_ID, MESSAGE_ID)))) + .supportsRowLevelSecurity() + .build(); + + PostgresIndex MESSAGE_ID_INDEX = PostgresIndex.name("thread_message_id_index") + .createIndexStep((dsl, indexName) -> dsl.createIndexIfNotExists(indexName) + .on(TABLE_NAME, USERNAME, MESSAGE_ID)); + + PostgresIndex THREAD_ID_INDEX = PostgresIndex.name("thread_thread_id_index") + .createIndexStep((dsl, indexName) -> dsl.createIndexIfNotExists(indexName) + .on(TABLE_NAME, USERNAME, THREAD_ID)); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(TABLE) + .addIndex(MESSAGE_ID_INDEX) + .addIndex(THREAD_ID_INDEX) + .build(); +} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerContract.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerContract.java index af85869d527..719f47a732d 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerContract.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerContract.java @@ -23,8 +23,13 @@ import static org.assertj.core.api.SoftAssertions.assertSoftly; import static org.junit.jupiter.api.Assumptions.assumeTrue; +import java.nio.charset.StandardCharsets; +import java.util.HashSet; import java.util.List; +import java.util.Optional; +import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; import org.apache.james.blob.api.BlobId; import org.apache.james.blob.api.BlobStore; @@ -39,13 +44,19 @@ import org.apache.james.mailbox.postgres.mail.dao.PostgresAttachmentDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresThreadDAO; +import org.apache.james.mailbox.store.mail.model.MimeMessageId; +import org.apache.james.mime4j.dom.Message; +import org.apache.james.mime4j.stream.RawField; import org.apache.james.server.blob.deduplication.DeDuplicationBlobStore; import org.apache.james.util.ClassLoaderUtils; +import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import com.github.fge.lambdas.Throwing; import com.google.common.collect.ImmutableList; +import com.google.common.hash.Hashing; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -59,6 +70,7 @@ public abstract class DeleteMessageListenerContract { private PostgresMailboxManager mailboxManager; private PostgresMessageDAO postgresMessageDAO; private PostgresMailboxMessageDAO postgresMailboxMessageDAO; + private PostgresThreadDAO postgresThreadDAO; private PostgresAttachmentDAO attachmentDAO; private BlobStore blobStore; @@ -69,6 +81,8 @@ public abstract class DeleteMessageListenerContract { abstract PostgresMailboxMessageDAO providePostgresMailboxMessageDAO(); + abstract PostgresThreadDAO threadDAO(); + abstract PostgresAttachmentDAO attachmentDAO(); abstract BlobStore blobStore(); @@ -87,6 +101,7 @@ void setUp() throws Exception { postgresMessageDAO = providePostgresMessageDAO(); postgresMailboxMessageDAO = providePostgresMailboxMessageDAO(); + postgresThreadDAO = threadDAO(); attachmentDAO = attachmentDAO(); blobStore = blobStore(); } @@ -243,4 +258,64 @@ void deleteMessageListenerShouldSucceedWhenDeleteMailboxHasALotOfMessages() thro .flatMap(msgId -> postgresMessageDAO.getBodyBlobId(msgId)) .collectList().block()).isEmpty(); } + + @Test + void deleteMailboxShouldCleanUpThreadData() throws Exception { + // append a message + MessageManager.AppendResult message = inboxManager.appendMessage(MessageManager.AppendCommand.from(Message.Builder.of() + .setSubject("Test") + .setMessageId("Message-ID") + .setField(new RawField("In-Reply-To", "someInReplyTo")) + .addField(new RawField("References", "references1")) + .addField(new RawField("References", "references2")) + .setBody("testmail", StandardCharsets.UTF_8)), session); + + Set hashMimeMessageIds = buildMimeMessageIdSet(Optional.of(new MimeMessageId("Message-ID")), + Optional.of(new MimeMessageId("someInReplyTo")), + Optional.of(List.of(new MimeMessageId("references1"), new MimeMessageId("references2")))) + .stream() + .map(mimeMessageId1 -> Hashing.murmur3_32_fixed().hashBytes(mimeMessageId1.getValue().getBytes()).asInt()) + .collect(Collectors.toSet()); + + mailboxManager.deleteMailbox(inbox, session); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(threadDAO().findThreads(session.getUser(), hashMimeMessageIds).collectList().block()) + .isEmpty(); + }); + } + + @Test + void deleteMessageShouldCleanUpThreadData() throws Exception { + // append a message + MessageManager.AppendResult message = inboxManager.appendMessage(MessageManager.AppendCommand.from(Message.Builder.of() + .setSubject("Test") + .setMessageId("Message-ID") + .setField(new RawField("In-Reply-To", "someInReplyTo")) + .addField(new RawField("References", "references1")) + .addField(new RawField("References", "references2")) + .setBody("testmail", StandardCharsets.UTF_8)), session); + + Set hashMimeMessageIds = buildMimeMessageIdSet(Optional.of(new MimeMessageId("Message-ID")), + Optional.of(new MimeMessageId("someInReplyTo")), + Optional.of(List.of(new MimeMessageId("references1"), new MimeMessageId("references2")))) + .stream() + .map(mimeMessageId1 -> Hashing.murmur3_32_fixed().hashBytes(mimeMessageId1.getValue().getBytes()).asInt()) + .collect(Collectors.toSet()); + + inboxManager.delete(ImmutableList.of(message.getId().getUid()), session); + + SoftAssertions.assertSoftly(softly -> { + softly.assertThat(threadDAO().findThreads(session.getUser(), hashMimeMessageIds).collectList().block()) + .isEmpty(); + }); + } + + private Set buildMimeMessageIdSet(Optional mimeMessageId, Optional inReplyTo, Optional> references) { + Set mimeMessageIds = new HashSet<>(); + mimeMessageId.ifPresent(mimeMessageIds::add); + inReplyTo.ifPresent(mimeMessageIds::add); + references.ifPresent(mimeMessageIds::addAll); + return mimeMessageIds; + } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java index 47badb5c7b4..7a2c846aaad 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java @@ -37,6 +37,7 @@ import org.apache.james.mailbox.postgres.mail.dao.PostgresAttachmentDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresThreadDAO; import org.apache.james.mailbox.store.PreDeletionHooks; import org.apache.james.mailbox.store.SessionProviderImpl; import org.apache.james.mailbox.store.StoreMailboxAnnotationManager; @@ -85,7 +86,7 @@ static void beforeAll() { mailboxManager = new PostgresMailboxManager(mapperFactory, sessionProvider, messageParser, new PostgresMessageId.Factory(), eventBus, annotationManager, - storeRightManager, quotaComponents, index, new NaiveThreadIdGuessingAlgorithm(), + storeRightManager, quotaComponents, index, new PostgresThreadIdGuessingAlgorithm(new PostgresThreadDAO.Factory(postgresExtension.getExecutorFactory())), PreDeletionHooks.NO_PRE_DELETION_HOOK, new UpdatableTickingClock(Instant.now())); } @@ -104,6 +105,11 @@ PostgresMailboxMessageDAO providePostgresMailboxMessageDAO() { return new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); } + @Override + PostgresThreadDAO threadDAO() { + return new PostgresThreadDAO(postgresExtension.getPostgresExecutor()); + } + @Override PostgresAttachmentDAO attachmentDAO() { return new PostgresAttachmentDAO(postgresExtension.getPostgresExecutor(), BLOB_ID_FACTORY); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java index d8dabc90b93..c2ad850a806 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java @@ -41,6 +41,7 @@ import org.apache.james.mailbox.postgres.mail.dao.PostgresAttachmentDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresThreadDAO; import org.apache.james.mailbox.store.PreDeletionHooks; import org.apache.james.mailbox.store.SessionProviderImpl; import org.apache.james.mailbox.store.StoreMailboxAnnotationManager; @@ -91,7 +92,7 @@ static void beforeAll() { mailboxManager = new PostgresMailboxManager(mapperFactory, sessionProvider, messageParser, new PostgresMessageId.Factory(), eventBus, annotationManager, - storeRightManager, quotaComponents, index, new NaiveThreadIdGuessingAlgorithm(), + storeRightManager, quotaComponents, index, new PostgresThreadIdGuessingAlgorithm(new PostgresThreadDAO.Factory(postgresExtension.getExecutorFactory())), PreDeletionHooks.NO_PRE_DELETION_HOOK, new UpdatableTickingClock(Instant.now())); } @@ -110,6 +111,11 @@ PostgresMailboxMessageDAO providePostgresMailboxMessageDAO() { return new PostgresMailboxMessageDAO(postgresExtension.getExecutorFactory().create(getUsername().getDomainPart())); } + @Override + PostgresThreadDAO threadDAO() { + return new PostgresThreadDAO.Factory(postgresExtension.getExecutorFactory()).create(getUsername().getDomainPart()); + } + @Override PostgresAttachmentDAO attachmentDAO() { return new PostgresAttachmentDAO(postgresExtension.getExecutorFactory().create(getUsername().getDomainPart()), BLOB_ID_FACTORY); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerAttachmentTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerAttachmentTest.java index c52a475dbee..c4e11687be5 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerAttachmentTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerAttachmentTest.java @@ -43,6 +43,7 @@ import org.apache.james.mailbox.postgres.mail.dao.PostgresAttachmentDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.apache.james.mailbox.postgres.mail.dao.PostgresThreadDAO; import org.apache.james.mailbox.quota.QuotaRootResolver; import org.apache.james.mailbox.store.AbstractMailboxManagerAttachmentTest; import org.apache.james.mailbox.store.MailboxSessionMapperFactory; @@ -101,9 +102,10 @@ void beforeAll() throws Exception { PostgresMessageDAO.Factory postgresMessageDAOFactory = new PostgresMessageDAO.Factory(BLOB_ID_FACTORY, postgresExtension.getExecutorFactory()); PostgresMailboxMessageDAO.Factory postgresMailboxMessageDAOFactory = new PostgresMailboxMessageDAO.Factory(postgresExtension.getExecutorFactory()); PostgresAttachmentDAO.Factory attachmentDAOFactory = new PostgresAttachmentDAO.Factory(postgresExtension.getExecutorFactory(), BLOB_ID_FACTORY); + PostgresThreadDAO.Factory threadDAOFactory = new PostgresThreadDAO.Factory(postgresExtension.getExecutorFactory()); eventBus.register(new DeleteMessageListener(blobStore, postgresMailboxMessageDAOFactory, postgresMessageDAOFactory, - attachmentDAOFactory, ImmutableSet.of())); + attachmentDAOFactory, threadDAOFactory, ImmutableSet.of())); mailboxManager = new PostgresMailboxManager(mapperFactory, sessionProvider, messageParser, new PostgresMessageId.Factory(), diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresThreadIdGuessingAlgorithmTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresThreadIdGuessingAlgorithmTest.java new file mode 100644 index 00000000000..8567d0f3489 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresThreadIdGuessingAlgorithmTest.java @@ -0,0 +1,166 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.core.Username; +import org.apache.james.events.EventBusTestFixture; +import org.apache.james.events.InVMEventBus; +import org.apache.james.events.MemoryEventDeadLetters; +import org.apache.james.events.delivery.InVmEventDelivery; +import org.apache.james.mailbox.MailboxSession; +import org.apache.james.mailbox.exception.MailboxException; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.model.ThreadId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresThreadDAO; +import org.apache.james.mailbox.store.CombinationManagerTestSystem; +import org.apache.james.mailbox.store.ThreadIdGuessingAlgorithmContract; +import org.apache.james.mailbox.store.mail.MessageMapper; +import org.apache.james.mailbox.store.mail.ThreadIdGuessingAlgorithm; +import org.apache.james.mailbox.store.mail.model.MimeMessageId; +import org.apache.james.mailbox.store.mail.model.Subject; +import org.apache.james.mailbox.store.quota.NoQuotaManager; +import org.apache.james.metrics.tests.RecordingMetricFactory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.testcontainers.shaded.com.google.common.collect.ImmutableSet; + +import com.google.common.collect.ImmutableList; +import com.google.common.hash.Hashing; + +import reactor.core.publisher.Flux; + +public class PostgresThreadIdGuessingAlgorithmTest extends ThreadIdGuessingAlgorithmContract { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); + + private PostgresMailboxManager mailboxManager; + private PostgresThreadDAO.Factory threadDAOFactory; + + @Override + protected CombinationManagerTestSystem createTestingData() { + eventBus = new InVMEventBus(new InVmEventDelivery(new RecordingMetricFactory()), EventBusTestFixture.RETRY_BACKOFF_CONFIGURATION, new MemoryEventDeadLetters()); + PostgresCombinationManagerTestSystem testSystem = (PostgresCombinationManagerTestSystem) PostgresCombinationManagerTestSystem.createTestingData(postgresExtension, new NoQuotaManager(), eventBus); + mailboxManager = (PostgresMailboxManager) testSystem.getMailboxManager(); + messageIdFactory = new PostgresMessageId.Factory(); + return testSystem; + } + + @Override + protected ThreadIdGuessingAlgorithm initThreadIdGuessingAlgorithm(CombinationManagerTestSystem testingData) { + threadDAOFactory = new PostgresThreadDAO.Factory(postgresExtension.getExecutorFactory()); + return new PostgresThreadIdGuessingAlgorithm(threadDAOFactory); + } + + @Override + protected MessageMapper createMessageMapper(MailboxSession mailboxSession) { + return mailboxManager.getMapperFactory().createMessageMapper(mailboxSession); + } + + @Override + protected MessageId initNewBasedMessageId() { + return messageIdFactory.generate(); + } + + @Override + protected MessageId initOtherBasedMessageId() { + return messageIdFactory.generate(); + } + + @Override + protected void saveThreadData(Username username, Set mimeMessageIds, MessageId messageId, ThreadId threadId, Optional baseSubject) { + PostgresThreadDAO threadDAO = threadDAOFactory.create(username.getDomainPart()); + threadDAO.insertSome(username, hashMimeMessagesIds(mimeMessageIds), PostgresMessageId.class.cast(messageId), threadId, hashSubject(baseSubject)).block(); + } + + @Test + void givenAMailInAThreadThenGetThreadShouldReturnAListWithOnlyOneMessageIdInThatThread() throws MailboxException { + Set mimeMessageIds = buildMimeMessageIdSet(Optional.of(new MimeMessageId("Message-ID")), + Optional.of(new MimeMessageId("someInReplyTo")), + Optional.of(List.of(new MimeMessageId("references1"), new MimeMessageId("references2")))); + + MessageId messageId = initNewBasedMessageId(); + ThreadId threadId = ThreadId.fromBaseMessageId(newBasedMessageId); + saveThreadData(mailboxSession.getUser(), mimeMessageIds, messageId, threadId, Optional.of(new Subject("Test"))); + + Flux messageIds = testee.getMessageIdsInThread(threadId, mailboxSession); + + assertThat(messageIds.collectList().block()) + .containsOnly(messageId); + } + + @Test + void givenTwoDistinctThreadsThenGetThreadShouldNotReturnUnrelatedMails() throws MailboxException { + Set mimeMessageIds = buildMimeMessageIdSet(Optional.of(new MimeMessageId("Message-ID")), + Optional.of(new MimeMessageId("someInReplyTo")), + Optional.of(List.of(new MimeMessageId("references1"), new MimeMessageId("references2")))); + + MessageId messageId1 = initNewBasedMessageId(); + MessageId messageId2 = initNewBasedMessageId(); + MessageId messageId3 = initNewBasedMessageId(); + ThreadId threadId1 = ThreadId.fromBaseMessageId(newBasedMessageId); + ThreadId threadId2 = ThreadId.fromBaseMessageId(otherBasedMessageId); + + saveThreadData(mailboxSession.getUser(), mimeMessageIds, messageId1, threadId1, Optional.of(new Subject("Test"))); + saveThreadData(mailboxSession.getUser(), mimeMessageIds, messageId2, threadId1, Optional.of(new Subject("Test"))); + saveThreadData(mailboxSession.getUser(), mimeMessageIds, messageId3, threadId2, Optional.of(new Subject("Test"))); + + Flux messageIds = testee.getMessageIdsInThread(ThreadId.fromBaseMessageId(otherBasedMessageId), mailboxSession); + + assertThat(messageIds.collectList().block()) + .doesNotContain(messageId1, messageId2); + } + + @Test + void givenThreeMailsInAThreadThenGetThreadShouldReturnAListWithThreeMessageIdsSortedByArrivalDate() { + Set mimeMessageIds = ImmutableSet.of(new MimeMessageId("Message-ID")); + + MessageId messageId1 = initNewBasedMessageId(); + MessageId messageId2 = initNewBasedMessageId(); + MessageId messageId3 = initNewBasedMessageId(); + ThreadId threadId1 = ThreadId.fromBaseMessageId(newBasedMessageId); + + saveThreadData(mailboxSession.getUser(), mimeMessageIds, messageId1, threadId1, Optional.of(new Subject("Test1"))); + saveThreadData(mailboxSession.getUser(), mimeMessageIds, messageId2, threadId1, Optional.of(new Subject("Test2"))); + saveThreadData(mailboxSession.getUser(), mimeMessageIds, messageId3, threadId1, Optional.of(new Subject("Test3"))); + + Flux messageIds = testee.getMessageIdsInThread(ThreadId.fromBaseMessageId(newBasedMessageId), mailboxSession); + + assertThat(messageIds.collectList().block()) + .isEqualTo(ImmutableList.of(messageId1, messageId2, messageId3)); + } + + private Set hashMimeMessagesIds(Set mimeMessageIds) { + return mimeMessageIds.stream() + .map(mimeMessageId -> Hashing.murmur3_32_fixed().hashBytes(mimeMessageId.getValue().getBytes()).asInt()) + .collect(Collectors.toSet()); + } + + private Optional hashSubject(Optional baseSubjectOptional) { + return baseSubjectOptional.map(baseSubject -> Hashing.murmur3_32_fixed().hashBytes(baseSubject.getValue().getBytes()).asInt()); + } +} diff --git a/mailbox/scanning-search/src/test/java/org/apache/james/mailbox/store/search/SearchThreadIdGuessingAlgorithmTest.java b/mailbox/scanning-search/src/test/java/org/apache/james/mailbox/store/search/SearchThreadIdGuessingAlgorithmTest.java index 223b6f0e1d6..345f3da4109 100644 --- a/mailbox/scanning-search/src/test/java/org/apache/james/mailbox/store/search/SearchThreadIdGuessingAlgorithmTest.java +++ b/mailbox/scanning-search/src/test/java/org/apache/james/mailbox/store/search/SearchThreadIdGuessingAlgorithmTest.java @@ -77,7 +77,6 @@ protected MessageId initOtherBasedMessageId() { } @Override - protected Flux saveThreadData(Username username, Set mimeMessageIds, MessageId messageId, ThreadId threadId, Optional baseSubject) { - return Flux.empty(); + protected void saveThreadData(Username username, Set mimeMessageIds, MessageId messageId, ThreadId threadId, Optional baseSubject) { } } diff --git a/mailbox/store/src/test/java/org/apache/james/mailbox/store/ThreadIdGuessingAlgorithmContract.java b/mailbox/store/src/test/java/org/apache/james/mailbox/store/ThreadIdGuessingAlgorithmContract.java index e415f26941f..a93e822ead1 100644 --- a/mailbox/store/src/test/java/org/apache/james/mailbox/store/ThreadIdGuessingAlgorithmContract.java +++ b/mailbox/store/src/test/java/org/apache/james/mailbox/store/ThreadIdGuessingAlgorithmContract.java @@ -75,12 +75,12 @@ public abstract class ThreadIdGuessingAlgorithmContract { protected MessageId.Factory messageIdFactory; protected ThreadIdGuessingAlgorithm testee; protected MessageId newBasedMessageId; + protected MessageId otherBasedMessageId; protected MailboxSession mailboxSession; private MailboxManager mailboxManager; private MessageManager inbox; private MessageMapper messageMapper; private CombinationManagerTestSystem testingData; - private MessageId otherBasedMessageId; private Mailbox mailbox; protected abstract CombinationManagerTestSystem createTestingData(); @@ -93,7 +93,7 @@ public abstract class ThreadIdGuessingAlgorithmContract { protected abstract MessageId initOtherBasedMessageId(); - protected abstract Flux saveThreadData(Username username, Set mimeMessageIds, MessageId messageId, ThreadId threadId, Optional baseSubject); + protected abstract void saveThreadData(Username username, Set mimeMessageIds, MessageId messageId, ThreadId threadId, Optional baseSubject); @BeforeEach void setUp() throws Exception { @@ -153,7 +153,7 @@ void givenOldMailWhenAddNewRelatedMailsThenGuessingThreadIdShouldReturnSameThrea Set mimeMessageIds = buildMimeMessageIdSet(Optional.of(new MimeMessageId("Message-ID")), Optional.of(new MimeMessageId("someInReplyTo")), Optional.of(List.of(new MimeMessageId("references1"), new MimeMessageId("references2")))); - saveThreadData(mailboxSession.getUser(), mimeMessageIds, message.getId().getMessageId(), message.getThreadId(), Optional.of(new Subject("Test"))).collectList().block(); + saveThreadData(mailboxSession.getUser(), mimeMessageIds, message.getId().getMessageId(), message.getThreadId(), Optional.of(new Subject("Test"))); // add new related mails ThreadId threadId = testee.guessThreadIdReactive(newBasedMessageId, mimeMessageId, inReplyTo, references, subject, mailboxSession).block(); @@ -186,7 +186,7 @@ void givenOldMailWhenAddNewMailsWithRelatedSubjectButHaveNonIdenticalMessageIDTh Set mimeMessageIds = buildMimeMessageIdSet(Optional.of(new MimeMessageId("Message-ID")), Optional.of(new MimeMessageId("someInReplyTo")), Optional.of(List.of(new MimeMessageId("references1"), new MimeMessageId("references2")))); - saveThreadData(mailboxSession.getUser(), mimeMessageIds, message.getId().getMessageId(), message.getThreadId(), Optional.of(new Subject("Test"))).collectList().block(); + saveThreadData(mailboxSession.getUser(), mimeMessageIds, message.getId().getMessageId(), message.getThreadId(), Optional.of(new Subject("Test"))); // add mails related to old message by subject but have non same identical Message-ID ThreadId threadId = testee.guessThreadIdReactive(newBasedMessageId, mimeMessageId, inReplyTo, references, subject, mailboxSession).block(); @@ -219,7 +219,7 @@ void givenOldMailWhenAddNewMailsWithNonRelatedSubjectButHaveSameIdenticalMessage Set mimeMessageIds = buildMimeMessageIdSet(Optional.of(new MimeMessageId("Message-ID")), Optional.of(new MimeMessageId("someInReplyTo")), Optional.of(List.of(new MimeMessageId("references1"), new MimeMessageId("references2")))); - saveThreadData(mailboxSession.getUser(), mimeMessageIds, message.getId().getMessageId(), message.getThreadId(), Optional.of(new Subject("Test"))).collectList().block(); + saveThreadData(mailboxSession.getUser(), mimeMessageIds, message.getId().getMessageId(), message.getThreadId(), Optional.of(new Subject("Test"))); // add mails related to old message by having identical Message-ID but non related subject ThreadId threadId = testee.guessThreadIdReactive(newBasedMessageId, mimeMessageId, inReplyTo, references, subject, mailboxSession).block(); @@ -252,7 +252,7 @@ void givenOldMailWhenAddNonRelatedMailsThenGuessingThreadIdShouldBasedOnGenerate Set mimeMessageIds = buildMimeMessageIdSet(Optional.of(new MimeMessageId("Message-ID")), Optional.of(new MimeMessageId("someInReplyTo")), Optional.of(List.of(new MimeMessageId("references1"), new MimeMessageId("references2")))); - saveThreadData(mailboxSession.getUser(), mimeMessageIds, message.getId().getMessageId(), message.getThreadId(), Optional.of(new Subject("Test"))).collectList().block(); + saveThreadData(mailboxSession.getUser(), mimeMessageIds, message.getId().getMessageId(), message.getThreadId(), Optional.of(new Subject("Test"))); // add mails non related to old message by both subject and identical Message-ID ThreadId threadId = testee.guessThreadIdReactive(newBasedMessageId, mimeMessageId, inReplyTo, references, subject, mailboxSession).block(); @@ -279,8 +279,6 @@ void givenThreeMailsInAThreadThenGetThreadShouldReturnAListWithThreeMessageIdsSo @Test void givenNonMailInAThreadThenGetThreadShouldThrowThreadNotFoundException() { - Flux messageIds = testee.getMessageIdsInThread(ThreadId.fromBaseMessageId(newBasedMessageId), mailboxSession); - assertThatThrownBy(() -> testee.getMessageIdsInThread(ThreadId.fromBaseMessageId(newBasedMessageId), mailboxSession).collectList().block()) .getCause() .isInstanceOf(ThreadNotFoundException.class); diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java index 555209275c5..9597503463f 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java @@ -52,6 +52,7 @@ import org.apache.james.mailbox.postgres.PostgresMailboxManager; import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.mailbox.postgres.PostgresThreadIdGuessingAlgorithm; import org.apache.james.mailbox.postgres.mail.PostgresAttachmentBlobReferenceSource; import org.apache.james.mailbox.postgres.mail.PostgresMessageBlobReferenceSource; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; @@ -69,7 +70,6 @@ import org.apache.james.mailbox.store.mail.AttachmentMapperFactory; import org.apache.james.mailbox.store.mail.MailboxMapperFactory; import org.apache.james.mailbox.store.mail.MessageMapperFactory; -import org.apache.james.mailbox.store.mail.NaiveThreadIdGuessingAlgorithm; import org.apache.james.mailbox.store.mail.ThreadIdGuessingAlgorithm; import org.apache.james.mailbox.store.user.SubscriptionMapperFactory; import org.apache.james.modules.data.PostgresCommonModule; @@ -103,7 +103,7 @@ protected void configure() { bind(UserRepositoryAuthorizator.class).in(Scopes.SINGLETON); bind(UnionMailboxACLResolver.class).in(Scopes.SINGLETON); bind(PostgresMessageId.Factory.class).in(Scopes.SINGLETON); - bind(NaiveThreadIdGuessingAlgorithm.class).in(Scopes.SINGLETON); + bind(PostgresThreadIdGuessingAlgorithm.class).in(Scopes.SINGLETON); bind(ReIndexerImpl.class).in(Scopes.SINGLETON); bind(SessionProviderImpl.class).in(Scopes.SINGLETON); bind(StoreMessageIdManager.class).in(Scopes.SINGLETON); @@ -114,7 +114,7 @@ protected void configure() { bind(MailboxMapperFactory.class).to(PostgresMailboxSessionMapperFactory.class); bind(MailboxSessionMapperFactory.class).to(PostgresMailboxSessionMapperFactory.class); bind(MessageId.Factory.class).to(PostgresMessageId.Factory.class); - bind(ThreadIdGuessingAlgorithm.class).to(NaiveThreadIdGuessingAlgorithm.class); + bind(ThreadIdGuessingAlgorithm.class).to(PostgresThreadIdGuessingAlgorithm.class); bind(SubscriptionManager.class).to(StoreSubscriptionManager.class); bind(MailboxPathLocker.class).to(NoMailboxPathLocker.class); From a816e7919be6c6304802e34fe15b397f9b47ba22 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 24 Jan 2024 16:41:27 +0700 Subject: [PATCH 214/341] JAMES-2586 Implement PostgresMailboxChangeRepository --- .../change/PostgresMailboxChangeDAO.java | 126 ++++++++++++++++++ .../change/PostgresMailboxChangeModule.java | 73 ++++++++++ .../PostgresMailboxChangeRepository.java | 115 ++++++++++++++++ .../PostgresMailboxChangeRepositoryTest.java | 58 ++++++++ 4 files changed, 372 insertions(+) create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeDAO.java create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeModule.java create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeRepository.java create mode 100644 server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeRepositoryTest.java diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeDAO.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeDAO.java new file mode 100644 index 00000000000..5a183fd2ba6 --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeDAO.java @@ -0,0 +1,126 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.change; + +import static org.apache.james.jmap.postgres.change.PostgresMailboxChangeModule.PostgresMailboxChangeTable.ACCOUNT_ID; +import static org.apache.james.jmap.postgres.change.PostgresMailboxChangeModule.PostgresMailboxChangeTable.CREATED; +import static org.apache.james.jmap.postgres.change.PostgresMailboxChangeModule.PostgresMailboxChangeTable.DATE; +import static org.apache.james.jmap.postgres.change.PostgresMailboxChangeModule.PostgresMailboxChangeTable.DESTROYED; +import static org.apache.james.jmap.postgres.change.PostgresMailboxChangeModule.PostgresMailboxChangeTable.IS_COUNT_CHANGE; +import static org.apache.james.jmap.postgres.change.PostgresMailboxChangeModule.PostgresMailboxChangeTable.IS_SHARED; +import static org.apache.james.jmap.postgres.change.PostgresMailboxChangeModule.PostgresMailboxChangeTable.STATE; +import static org.apache.james.jmap.postgres.change.PostgresMailboxChangeModule.PostgresMailboxChangeTable.TABLE_NAME; +import static org.apache.james.jmap.postgres.change.PostgresMailboxChangeModule.PostgresMailboxChangeTable.UPDATED; + +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.jmap.api.change.MailboxChange; +import org.apache.james.jmap.api.change.State; +import org.apache.james.jmap.api.model.AccountId; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.jooq.Record; + +import com.google.common.collect.ImmutableList; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresMailboxChangeDAO { + private final PostgresExecutor postgresExecutor; + + public PostgresMailboxChangeDAO(PostgresExecutor postgresExecutor) { + this.postgresExecutor = postgresExecutor; + } + + public Mono insert(MailboxChange change) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME) + .set(ACCOUNT_ID, change.getAccountId().getIdentifier()) + .set(STATE, change.getState().getValue()) + .set(IS_SHARED, change.isShared()) + .set(IS_COUNT_CHANGE, change.isCountChange()) + .set(CREATED, toUUIDArray(change.getCreated())) + .set(UPDATED, toUUIDArray(change.getUpdated())) + .set(DESTROYED, toUUIDArray(change.getDestroyed())) + .set(DATE, change.getDate().toOffsetDateTime()))); + } + + public Flux getAllChanges(AccountId accountId) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.selectFrom(TABLE_NAME) + .where(ACCOUNT_ID.eq(accountId.getIdentifier())))) + .map(record -> readRecord(record, accountId)); + } + + public Flux getChangesSince(AccountId accountId, State state) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.selectFrom(TABLE_NAME) + .where(ACCOUNT_ID.eq(accountId.getIdentifier())) + .and(STATE.greaterOrEqual(state.getValue())) + .orderBy(STATE))) + .map(record -> readRecord(record, accountId)); + } + + public Mono latestState(AccountId accountId) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select(STATE) + .from(TABLE_NAME) + .where(ACCOUNT_ID.eq(accountId.getIdentifier())) + .orderBy(STATE.desc()) + .limit(1))) + .map(record -> State.of(record.get(STATE))); + } + + public Mono latestStateNotDelegated(AccountId accountId) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select(STATE) + .from(TABLE_NAME) + .where(ACCOUNT_ID.eq(accountId.getIdentifier())) + .and(IS_SHARED.eq(false)) + .orderBy(STATE.desc()) + .limit(1))) + .map(record -> State.of(record.get(STATE))); + } + + private UUID[] toUUIDArray(List mailboxIds) { + return mailboxIds.stream() + .map(PostgresMailboxId.class::cast) + .map(PostgresMailboxId::asUuid) + .toArray(UUID[]::new); + } + + private MailboxChange readRecord(Record record, AccountId accountId) { + return MailboxChange.builder() + .accountId(accountId) + .state(State.of(record.get(STATE))) + .date(record.get(DATE).toZonedDateTime()) + .isCountChange(record.get(IS_COUNT_CHANGE)) + .shared(record.get(IS_SHARED)) + .created(toMailboxIds(record.get(CREATED))) + .updated(toMailboxIds(record.get(UPDATED))) + .destroyed(toMailboxIds(record.get(DESTROYED))) + .build(); + } + + private List toMailboxIds(UUID[] uuids) { + return Arrays.stream(uuids) + .map(PostgresMailboxId::of) + .collect(ImmutableList.toImmutableList()); + } +} diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeModule.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeModule.java new file mode 100644 index 00000000000..55b5bf643cb --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeModule.java @@ -0,0 +1,73 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.change; + +import static org.apache.james.jmap.postgres.change.PostgresMailboxChangeModule.PostgresMailboxChangeTable.INDEX; +import static org.apache.james.jmap.postgres.change.PostgresMailboxChangeModule.PostgresMailboxChangeTable.TABLE; + +import java.time.OffsetDateTime; +import java.util.UUID; + +import org.apache.james.backends.postgres.PostgresIndex; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresMailboxChangeModule { + interface PostgresMailboxChangeTable { + Table TABLE_NAME = DSL.table("mailbox_change"); + + Field ACCOUNT_ID = DSL.field("account_id", SQLDataType.VARCHAR.notNull()); + Field STATE = DSL.field("state", SQLDataType.UUID.notNull()); + Field DATE = DSL.field("date", SQLDataType.TIMESTAMPWITHTIMEZONE.notNull()); + Field IS_SHARED = DSL.field("is_shared", SQLDataType.BOOLEAN.notNull()); + Field IS_COUNT_CHANGE = DSL.field("is_count_change", SQLDataType.BOOLEAN.notNull()); + Field CREATED = DSL.field("created", SQLDataType.UUID.getArrayDataType().notNull()); + Field UPDATED = DSL.field("updated", SQLDataType.UUID.getArrayDataType().notNull()); + Field DESTROYED = DSL.field("destroyed", SQLDataType.UUID.getArrayDataType().notNull()); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(ACCOUNT_ID) + .column(STATE) + .column(DATE) + .column(IS_SHARED) + .column(IS_COUNT_CHANGE) + .column(CREATED) + .column(UPDATED) + .column(DESTROYED) + .constraint(DSL.primaryKey(ACCOUNT_ID, STATE)))) + .supportsRowLevelSecurity() + .build(); + + PostgresIndex INDEX = PostgresIndex.name("index_mailbox_change_date") + .createIndexStep((dslContext, indexName) -> dslContext.createIndex(indexName) + .on(TABLE_NAME, DATE)); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(TABLE) + .addIndex(INDEX) + .build(); +} diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeRepository.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeRepository.java new file mode 100644 index 00000000000..db1d2913b5a --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeRepository.java @@ -0,0 +1,115 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.change; + +import java.util.Optional; + +import javax.inject.Inject; +import javax.inject.Named; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.core.Username; +import org.apache.james.jmap.api.change.Limit; +import org.apache.james.jmap.api.change.MailboxChange; +import org.apache.james.jmap.api.change.MailboxChangeRepository; +import org.apache.james.jmap.api.change.MailboxChanges; +import org.apache.james.jmap.api.change.State; +import org.apache.james.jmap.api.exception.ChangeNotFoundException; +import org.apache.james.jmap.api.model.AccountId; + +import com.google.common.base.Preconditions; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresMailboxChangeRepository implements MailboxChangeRepository { + public static final String LIMIT_NAME = "mailboxChangeDefaultLimit"; + + private final PostgresExecutor.Factory executorFactory; + private final Limit defaultLimit; + + @Inject + public PostgresMailboxChangeRepository(PostgresExecutor.Factory executorFactory, @Named(LIMIT_NAME) Limit defaultLimit) { + this.executorFactory = executorFactory; + this.defaultLimit = defaultLimit; + } + + @Override + public Mono save(MailboxChange change) { + PostgresMailboxChangeDAO mailboxChangeDAO = createPostgresMailboxChangeDAO(change.getAccountId()); + return mailboxChangeDAO.insert(change); + } + + @Override + public Mono getSinceState(AccountId accountId, State state, Optional maxChanges) { + Preconditions.checkNotNull(accountId); + Preconditions.checkNotNull(state); + maxChanges.ifPresent(limit -> Preconditions.checkArgument(limit.getValue() > 0, "maxChanges must be a positive integer")); + + PostgresMailboxChangeDAO mailboxChangeDAO = createPostgresMailboxChangeDAO(accountId); + if (state.equals(State.INITIAL)) { + return mailboxChangeDAO.getAllChanges(accountId) + .filter(change -> !change.isShared()) + .collect(new MailboxChanges.MailboxChangesBuilder.MailboxChangeCollector(state, maxChanges.orElse(defaultLimit))); + } + + return mailboxChangeDAO.getChangesSince(accountId, state) + .switchIfEmpty(Flux.error(() -> new ChangeNotFoundException(state, String.format("State '%s' could not be found", state.getValue())))) + .filter(change -> !change.isShared()) + .filter(change -> !change.getState().equals(state)) + .collect(new MailboxChanges.MailboxChangesBuilder.MailboxChangeCollector(state, maxChanges.orElse(defaultLimit))); + } + + @Override + public Mono getSinceStateWithDelegation(AccountId accountId, State state, Optional maxChanges) { + Preconditions.checkNotNull(accountId); + Preconditions.checkNotNull(state); + maxChanges.ifPresent(limit -> Preconditions.checkArgument(limit.getValue() > 0, "maxChanges must be a positive integer")); + + PostgresMailboxChangeDAO mailboxChangeDAO = createPostgresMailboxChangeDAO(accountId); + if (state.equals(State.INITIAL)) { + return mailboxChangeDAO.getAllChanges(accountId) + .collect(new MailboxChanges.MailboxChangesBuilder.MailboxChangeCollector(state, maxChanges.orElse(defaultLimit))); + } + + return mailboxChangeDAO.getChangesSince(accountId, state) + .switchIfEmpty(Flux.error(() -> new ChangeNotFoundException(state, String.format("State '%s' could not be found", state.getValue())))) + .filter(change -> !change.getState().equals(state)) + .collect(new MailboxChanges.MailboxChangesBuilder.MailboxChangeCollector(state, maxChanges.orElse(defaultLimit))); + } + + @Override + public Mono getLatestState(AccountId accountId) { + PostgresMailboxChangeDAO mailboxChangeDAO = createPostgresMailboxChangeDAO(accountId); + return mailboxChangeDAO.latestStateNotDelegated(accountId) + .switchIfEmpty(Mono.just(State.INITIAL)); + } + + @Override + public Mono getLatestStateWithDelegation(AccountId accountId) { + PostgresMailboxChangeDAO mailboxChangeDAO = createPostgresMailboxChangeDAO(accountId); + return mailboxChangeDAO.latestState(accountId) + .switchIfEmpty(Mono.just(State.INITIAL)); + } + + private PostgresMailboxChangeDAO createPostgresMailboxChangeDAO(AccountId accountId) { + return new PostgresMailboxChangeDAO(executorFactory.create(Username.of(accountId.getIdentifier()).getDomainPart())); + } +} diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeRepositoryTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeRepositoryTest.java new file mode 100644 index 00000000000..d6b1dba21ff --- /dev/null +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeRepositoryTest.java @@ -0,0 +1,58 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.change; + +import java.util.UUID; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.jmap.api.change.MailboxChangeRepository; +import org.apache.james.jmap.api.change.MailboxChangeRepositoryContract; +import org.apache.james.jmap.api.change.State; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; + +class PostgresMailboxChangeRepositoryTest implements MailboxChangeRepositoryContract { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresMailboxChangeModule.MODULE); + + PostgresMailboxChangeRepository postgresMailboxChangeRepository; + + @BeforeEach + public void setUp() { + postgresMailboxChangeRepository = new PostgresMailboxChangeRepository(postgresExtension.getExecutorFactory(), DEFAULT_NUMBER_OF_CHANGES); + } + + @Override + public State.Factory stateFactory() { + return new PostgresStateFactory(); + } + + @Override + public MailboxChangeRepository mailboxChangeRepository() { + return postgresMailboxChangeRepository; + } + + @Override + public MailboxId generateNewMailboxId() { + return PostgresMailboxId.of(UUID.randomUUID()); + } +} From 493fa6dac575935f9d6c94b6d7eae1f1d0f17c23 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Thu, 25 Jan 2024 11:36:16 +0700 Subject: [PATCH 215/341] JAMES-2586 Guice binding PostgresMailboxChangeRepository --- .../java/org/apache/james/PostgresJmapModule.java | 14 +++++--------- .../postgres/PostgresDataJMapAggregateModule.java | 2 ++ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java index 018982f312b..9ca2329cbcf 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java @@ -26,11 +26,9 @@ import org.apache.james.jmap.api.change.State; import org.apache.james.jmap.api.pushsubscription.PushSubscriptionRepository; import org.apache.james.jmap.api.upload.UploadUsageRepository; -import org.apache.james.jmap.memory.change.MemoryEmailChangeRepository; -import org.apache.james.jmap.memory.change.MemoryMailboxChangeRepository; import org.apache.james.jmap.postgres.PostgresDataJMapAggregateModule; -import org.apache.james.jmap.postgres.change.PostgresEmailChangeModule; import org.apache.james.jmap.postgres.change.PostgresEmailChangeRepository; +import org.apache.james.jmap.postgres.change.PostgresMailboxChangeRepository; import org.apache.james.jmap.postgres.pushsubscription.PostgresPushSubscriptionRepository; import org.apache.james.jmap.postgres.upload.PostgresUploadUsageRepository; import org.apache.james.mailbox.AttachmentManager; @@ -56,16 +54,14 @@ public class PostgresJmapModule extends AbstractModule { protected void configure() { Multibinder.newSetBinder(binder(), PostgresModule.class).addBinding().toInstance(PostgresDataJMapAggregateModule.MODULE); - Multibinder.newSetBinder(binder(), PostgresModule.class).addBinding().toInstance(PostgresEmailChangeModule.MODULE); - bind(EmailChangeRepository.class).to(PostgresEmailChangeRepository.class); bind(PostgresEmailChangeRepository.class).in(Scopes.SINGLETON); - bind(MailboxChangeRepository.class).to(MemoryMailboxChangeRepository.class); - bind(MemoryMailboxChangeRepository.class).in(Scopes.SINGLETON); + bind(MailboxChangeRepository.class).to(PostgresMailboxChangeRepository.class); + bind(PostgresMailboxChangeRepository.class).in(Scopes.SINGLETON); - bind(Limit.class).annotatedWith(Names.named(MemoryEmailChangeRepository.LIMIT_NAME)).toInstance(Limit.of(256)); - bind(Limit.class).annotatedWith(Names.named(MemoryMailboxChangeRepository.LIMIT_NAME)).toInstance(Limit.of(256)); + bind(Limit.class).annotatedWith(Names.named(PostgresEmailChangeRepository.LIMIT_NAME)).toInstance(Limit.of(256)); + bind(Limit.class).annotatedWith(Names.named(PostgresMailboxChangeRepository.LIMIT_NAME)).toInstance(Limit.of(256)); bind(UploadUsageRepository.class).to(PostgresUploadUsageRepository.class); diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/PostgresDataJMapAggregateModule.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/PostgresDataJMapAggregateModule.java index 592f8fb2b2d..4ee553dcc8b 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/PostgresDataJMapAggregateModule.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/PostgresDataJMapAggregateModule.java @@ -21,6 +21,7 @@ import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.jmap.postgres.change.PostgresEmailChangeModule; +import org.apache.james.jmap.postgres.change.PostgresMailboxChangeModule; import org.apache.james.jmap.postgres.projections.PostgresMessageFastViewProjectionModule; import org.apache.james.jmap.postgres.pushsubscription.PostgresPushSubscriptionModule; import org.apache.james.jmap.postgres.upload.PostgresUploadModule; @@ -31,5 +32,6 @@ public interface PostgresDataJMapAggregateModule { PostgresUploadModule.MODULE, PostgresMessageFastViewProjectionModule.MODULE, PostgresEmailChangeModule.MODULE, + PostgresMailboxChangeModule.MODULE, PostgresPushSubscriptionModule.MODULE); } From f28cb249922bacb715224d01e145f28212b18af3 Mon Sep 17 00:00:00 2001 From: hung phan Date: Thu, 1 Feb 2024 11:43:51 +0700 Subject: [PATCH 216/341] JAMES-2586 Implement PostgresFilteringProjection --- .../modules/data/PostgresDataJmapModule.java | 4 +- server/data/data-jmap-postgres/pom.xml | 5 + .../PostgresDataJMapAggregateModule.java | 4 +- .../PostgresFilteringProjection.java | 65 +++++++++++ .../PostgresFilteringProjectionDAO.java | 109 ++++++++++++++++++ .../PostgresFilteringProjectionModule.java | 54 +++++++++ ...sEventSourcingFilteringManagementTest.java | 38 ++++++ .../FilteringIncrementalRuleChangeDTO.java | 2 + .../filtering/FilteringRuleSetDefinedDTO.java | 1 + 9 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/filtering/PostgresFilteringProjection.java create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/filtering/PostgresFilteringProjectionDAO.java create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/filtering/PostgresFilteringProjectionModule.java create mode 100644 server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/filtering/PostgresEventSourcingFilteringManagementTest.java diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java index cab393a93d8..08d826618ba 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java @@ -36,6 +36,7 @@ import org.apache.james.jmap.memory.identity.MemoryCustomIdentityDAO; import org.apache.james.jmap.memory.projections.MemoryEmailQueryView; import org.apache.james.jmap.memory.projections.MemoryMessageFastViewProjection; +import org.apache.james.jmap.postgres.filtering.PostgresFilteringProjection; import org.apache.james.jmap.postgres.upload.PostgresUploadRepository; import org.apache.james.mailbox.store.extractor.DefaultTextExtractor; import org.apache.james.user.api.DeleteUserDataTaskStep; @@ -59,7 +60,8 @@ protected void configure() { bind(EventSourcingFilteringManagement.class).in(Scopes.SINGLETON); bind(FilteringManagement.class).to(EventSourcingFilteringManagement.class); - bind(EventSourcingFilteringManagement.ReadProjection.class).to(EventSourcingFilteringManagement.NoReadProjection.class); + bind(PostgresFilteringProjection.class).in(Scopes.SINGLETON); + bind(EventSourcingFilteringManagement.ReadProjection.class).to(PostgresFilteringProjection.class); bind(DefaultTextExtractor.class).in(Scopes.SINGLETON); diff --git a/server/data/data-jmap-postgres/pom.xml b/server/data/data-jmap-postgres/pom.xml index 23abe4e8df6..18eafbe8cf1 100644 --- a/server/data/data-jmap-postgres/pom.xml +++ b/server/data/data-jmap-postgres/pom.xml @@ -68,6 +68,11 @@ blob-storage-strategy test + + ${james.groupId} + event-sourcing-event-store-memory + test + ${james.groupId} james-json diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/PostgresDataJMapAggregateModule.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/PostgresDataJMapAggregateModule.java index 4ee553dcc8b..16fe9ed4a4e 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/PostgresDataJMapAggregateModule.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/PostgresDataJMapAggregateModule.java @@ -22,6 +22,7 @@ import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.jmap.postgres.change.PostgresEmailChangeModule; import org.apache.james.jmap.postgres.change.PostgresMailboxChangeModule; +import org.apache.james.jmap.postgres.filtering.PostgresFilteringProjectionModule; import org.apache.james.jmap.postgres.projections.PostgresMessageFastViewProjectionModule; import org.apache.james.jmap.postgres.pushsubscription.PostgresPushSubscriptionModule; import org.apache.james.jmap.postgres.upload.PostgresUploadModule; @@ -33,5 +34,6 @@ public interface PostgresDataJMapAggregateModule { PostgresMessageFastViewProjectionModule.MODULE, PostgresEmailChangeModule.MODULE, PostgresMailboxChangeModule.MODULE, - PostgresPushSubscriptionModule.MODULE); + PostgresPushSubscriptionModule.MODULE, + PostgresFilteringProjectionModule.MODULE); } diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/filtering/PostgresFilteringProjection.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/filtering/PostgresFilteringProjection.java new file mode 100644 index 00000000000..9404d2626a0 --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/filtering/PostgresFilteringProjection.java @@ -0,0 +1,65 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.filtering; + +import java.util.Optional; + +import javax.inject.Inject; + +import org.apache.james.core.Username; +import org.apache.james.eventsourcing.Event; +import org.apache.james.eventsourcing.EventWithState; +import org.apache.james.eventsourcing.ReactiveSubscriber; +import org.apache.james.jmap.api.filtering.Rules; +import org.apache.james.jmap.api.filtering.Version; +import org.apache.james.jmap.api.filtering.impl.EventSourcingFilteringManagement; +import org.apache.james.jmap.api.filtering.impl.FilteringAggregate; +import org.reactivestreams.Publisher; + +public class PostgresFilteringProjection implements EventSourcingFilteringManagement.ReadProjection, ReactiveSubscriber { + private final PostgresFilteringProjectionDAO postgresFilteringProjectionDAO; + + @Inject + public PostgresFilteringProjection(PostgresFilteringProjectionDAO postgresFilteringProjectionDAO) { + this.postgresFilteringProjectionDAO = postgresFilteringProjectionDAO; + } + + @Override + public Publisher handleReactive(EventWithState eventWithState) { + Event event = eventWithState.event(); + FilteringAggregate.FilterState state = (FilteringAggregate.FilterState) eventWithState.state().get(); + return postgresFilteringProjectionDAO.upsert(event.getAggregateId(), event.eventId(), state.getRules()); + } + + @Override + public Publisher listRulesForUser(Username username) { + return postgresFilteringProjectionDAO.listRulesForUser(username); + } + + @Override + public Publisher getLatestVersion(Username username) { + return postgresFilteringProjectionDAO.getVersion(username); + } + + @Override + public Optional subscriber() { + return Optional.of(this); + } +} diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/filtering/PostgresFilteringProjectionDAO.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/filtering/PostgresFilteringProjectionDAO.java new file mode 100644 index 00000000000..b7dc907d5c9 --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/filtering/PostgresFilteringProjectionDAO.java @@ -0,0 +1,109 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.filtering; + +import static org.apache.james.jmap.postgres.filtering.PostgresFilteringProjectionModule.PostgresFilteringProjectionTable.AGGREGATE_ID; +import static org.apache.james.jmap.postgres.filtering.PostgresFilteringProjectionModule.PostgresFilteringProjectionTable.EVENT_ID; +import static org.apache.james.jmap.postgres.filtering.PostgresFilteringProjectionModule.PostgresFilteringProjectionTable.RULES; +import static org.apache.james.jmap.postgres.filtering.PostgresFilteringProjectionModule.PostgresFilteringProjectionTable.TABLE_NAME; + +import java.util.List; + +import javax.inject.Inject; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.core.Username; +import org.apache.james.eventsourcing.AggregateId; +import org.apache.james.eventsourcing.EventId; +import org.apache.james.jmap.api.filtering.Rule; +import org.apache.james.jmap.api.filtering.RuleDTO; +import org.apache.james.jmap.api.filtering.Rules; +import org.apache.james.jmap.api.filtering.Version; +import org.apache.james.jmap.api.filtering.impl.FilteringAggregateId; +import org.jooq.JSON; +import org.jooq.Record; +import org.reactivestreams.Publisher; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.google.common.collect.ImmutableList; + +import reactor.core.publisher.Mono; + +public class PostgresFilteringProjectionDAO { + private final PostgresExecutor postgresExecutor; + private final ObjectMapper objectMapper; + + @Inject + public PostgresFilteringProjectionDAO(PostgresExecutor postgresExecutor) { + this.postgresExecutor = postgresExecutor; + objectMapper = new ObjectMapper().registerModule(new Jdk8Module()); + } + + public Publisher listRulesForUser(Username username) { + return postgresExecutor.executeRow(dslContext -> dslContext.selectFrom(TABLE_NAME) + .where(AGGREGATE_ID.eq(new FilteringAggregateId(username).asAggregateKey()))) + .handle((row, sink) -> { + try { + Rules rules = parseRules(row); + sink.next(rules); + } catch (JsonProcessingException e) { + sink.error(e); + } + }); + } + + public Mono upsert(AggregateId aggregateId, EventId eventId, ImmutableList rules) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME) + .set(AGGREGATE_ID, aggregateId.asAggregateKey()) + .set(EVENT_ID, eventId.value()) + .set(RULES, convertToJooqJson(rules)) + .onConflict(AGGREGATE_ID) + .doUpdate() + .set(EVENT_ID, eventId.value()) + .set(RULES, convertToJooqJson(rules)))); + } + + public Publisher getVersion(Username username) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select(EVENT_ID) + .from(TABLE_NAME) + .where(AGGREGATE_ID.eq(new FilteringAggregateId(username).asAggregateKey())))) + .map(this::parseVersion); + } + + private Rules parseRules(Record record) throws JsonProcessingException { + List ruleDTOS = objectMapper.readValue(record.get(RULES).data(), new TypeReference<>() {}); + return new Rules(RuleDTO.toRules(ruleDTOS), parseVersion(record)); + } + + private Version parseVersion(Record record) { + return new Version(record.get(EVENT_ID)); + } + + private JSON convertToJooqJson(List rules) { + try { + return JSON.json(objectMapper.writeValueAsString(RuleDTO.from(rules))); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } +} diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/filtering/PostgresFilteringProjectionModule.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/filtering/PostgresFilteringProjectionModule.java new file mode 100644 index 00000000000..d87fb603b9c --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/filtering/PostgresFilteringProjectionModule.java @@ -0,0 +1,54 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.filtering; + +import static org.apache.james.jmap.postgres.filtering.PostgresFilteringProjectionModule.PostgresFilteringProjectionTable.TABLE; + +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.JSON; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresFilteringProjectionModule { + interface PostgresFilteringProjectionTable { + Table TABLE_NAME = DSL.table("filters_projection"); + + Field AGGREGATE_ID = DSL.field("aggregate_id", SQLDataType.VARCHAR.notNull()); + Field EVENT_ID = DSL.field("event_id", SQLDataType.INTEGER.notNull()); + Field RULES = DSL.field("rules", SQLDataType.JSON.notNull()); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(AGGREGATE_ID) + .column(EVENT_ID) + .column(RULES) + .constraint(DSL.primaryKey(AGGREGATE_ID)))) + .disableRowLevelSecurity() + .build(); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(TABLE) + .build(); +} diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/filtering/PostgresEventSourcingFilteringManagementTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/filtering/PostgresEventSourcingFilteringManagementTest.java new file mode 100644 index 00000000000..fa703a248e9 --- /dev/null +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/filtering/PostgresEventSourcingFilteringManagementTest.java @@ -0,0 +1,38 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.filtering; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.eventsourcing.eventstore.memory.InMemoryEventStore; +import org.apache.james.jmap.api.filtering.FilteringManagement; +import org.apache.james.jmap.api.filtering.FilteringManagementContract; +import org.apache.james.jmap.api.filtering.impl.EventSourcingFilteringManagement; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresEventSourcingFilteringManagementTest implements FilteringManagementContract { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresFilteringProjectionModule.MODULE); + + @Override + public FilteringManagement instantiateFilteringManagement() { + return new EventSourcingFilteringManagement(new InMemoryEventStore(), + new PostgresFilteringProjection(new PostgresFilteringProjectionDAO(postgresExtension.getPostgresExecutor()))); + } +} diff --git a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/filtering/FilteringIncrementalRuleChangeDTO.java b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/filtering/FilteringIncrementalRuleChangeDTO.java index a1475cf63be..4084226b23a 100644 --- a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/filtering/FilteringIncrementalRuleChangeDTO.java +++ b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/filtering/FilteringIncrementalRuleChangeDTO.java @@ -23,6 +23,8 @@ import org.apache.james.eventsourcing.EventId; import org.apache.james.eventsourcing.eventstore.dto.EventDTO; +import org.apache.james.jmap.api.filtering.Rule; +import org.apache.james.jmap.api.filtering.RuleDTO; import org.apache.james.jmap.api.filtering.impl.FilteringAggregateId; import org.apache.james.jmap.api.filtering.impl.IncrementalRuleChange; diff --git a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/filtering/FilteringRuleSetDefinedDTO.java b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/filtering/FilteringRuleSetDefinedDTO.java index 73ed1a9805e..c0856c72ab9 100644 --- a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/filtering/FilteringRuleSetDefinedDTO.java +++ b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/filtering/FilteringRuleSetDefinedDTO.java @@ -23,6 +23,7 @@ import org.apache.james.eventsourcing.EventId; import org.apache.james.eventsourcing.eventstore.dto.EventDTO; +import org.apache.james.jmap.api.filtering.RuleDTO; import org.apache.james.jmap.api.filtering.impl.FilteringAggregateId; import org.apache.james.jmap.api.filtering.impl.RuleSetDefined; From afb6e8537b18f264743aa3d7f2772c31ec6a4d41 Mon Sep 17 00:00:00 2001 From: hung phan Date: Mon, 29 Jan 2024 16:13:27 +0700 Subject: [PATCH 217/341] JAMES-2586 Implement PostgresCustomIdentityDAO --- .../modules/data/PostgresDataJmapModule.java | 6 +- .../PostgresDataJMapAggregateModule.java | 5 +- .../identity/PostgresCustomIdentityDAO.java | 228 ++++++++++++++++++ .../PostgresCustomIdentityModule.java | 77 ++++++ .../PostgresCustomIdentityDAOTest.java | 35 +++ 5 files changed, 346 insertions(+), 5 deletions(-) create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/identity/PostgresCustomIdentityDAO.java create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/identity/PostgresCustomIdentityModule.java create mode 100644 server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/identity/PostgresCustomIdentityDAOTest.java diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java index 08d826618ba..635f76d2a33 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java @@ -33,10 +33,10 @@ import org.apache.james.jmap.api.pushsubscription.PushDeleteUserDataTaskStep; import org.apache.james.jmap.api.upload.UploadRepository; import org.apache.james.jmap.memory.access.MemoryAccessTokenRepository; -import org.apache.james.jmap.memory.identity.MemoryCustomIdentityDAO; import org.apache.james.jmap.memory.projections.MemoryEmailQueryView; import org.apache.james.jmap.memory.projections.MemoryMessageFastViewProjection; import org.apache.james.jmap.postgres.filtering.PostgresFilteringProjection; +import org.apache.james.jmap.postgres.identity.PostgresCustomIdentityDAO; import org.apache.james.jmap.postgres.upload.PostgresUploadRepository; import org.apache.james.mailbox.store.extractor.DefaultTextExtractor; import org.apache.james.user.api.DeleteUserDataTaskStep; @@ -55,8 +55,8 @@ protected void configure() { bind(UploadRepository.class).to(PostgresUploadRepository.class); - bind(MemoryCustomIdentityDAO.class).in(Scopes.SINGLETON); - bind(CustomIdentityDAO.class).to(MemoryCustomIdentityDAO.class); + bind(PostgresCustomIdentityDAO.class).in(Scopes.SINGLETON); + bind(CustomIdentityDAO.class).to(PostgresCustomIdentityDAO.class); bind(EventSourcingFilteringManagement.class).in(Scopes.SINGLETON); bind(FilteringManagement.class).to(EventSourcingFilteringManagement.class); diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/PostgresDataJMapAggregateModule.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/PostgresDataJMapAggregateModule.java index 16fe9ed4a4e..76106ee0f4d 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/PostgresDataJMapAggregateModule.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/PostgresDataJMapAggregateModule.java @@ -23,17 +23,18 @@ import org.apache.james.jmap.postgres.change.PostgresEmailChangeModule; import org.apache.james.jmap.postgres.change.PostgresMailboxChangeModule; import org.apache.james.jmap.postgres.filtering.PostgresFilteringProjectionModule; +import org.apache.james.jmap.postgres.identity.PostgresCustomIdentityModule; import org.apache.james.jmap.postgres.projections.PostgresMessageFastViewProjectionModule; import org.apache.james.jmap.postgres.pushsubscription.PostgresPushSubscriptionModule; import org.apache.james.jmap.postgres.upload.PostgresUploadModule; public interface PostgresDataJMapAggregateModule { - PostgresModule MODULE = PostgresModule.aggregateModules( PostgresUploadModule.MODULE, PostgresMessageFastViewProjectionModule.MODULE, PostgresEmailChangeModule.MODULE, PostgresMailboxChangeModule.MODULE, PostgresPushSubscriptionModule.MODULE, - PostgresFilteringProjectionModule.MODULE); + PostgresFilteringProjectionModule.MODULE, + PostgresCustomIdentityModule.MODULE); } diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/identity/PostgresCustomIdentityDAO.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/identity/PostgresCustomIdentityDAO.java new file mode 100644 index 00000000000..9f3cd1b2c04 --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/identity/PostgresCustomIdentityDAO.java @@ -0,0 +1,228 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.identity; + +import static org.apache.james.jmap.postgres.identity.PostgresCustomIdentityModule.PostgresCustomIdentityTable.BCC; +import static org.apache.james.jmap.postgres.identity.PostgresCustomIdentityModule.PostgresCustomIdentityTable.EMAIL; +import static org.apache.james.jmap.postgres.identity.PostgresCustomIdentityModule.PostgresCustomIdentityTable.HTML_SIGNATURE; +import static org.apache.james.jmap.postgres.identity.PostgresCustomIdentityModule.PostgresCustomIdentityTable.ID; +import static org.apache.james.jmap.postgres.identity.PostgresCustomIdentityModule.PostgresCustomIdentityTable.MAY_DELETE; +import static org.apache.james.jmap.postgres.identity.PostgresCustomIdentityModule.PostgresCustomIdentityTable.NAME; +import static org.apache.james.jmap.postgres.identity.PostgresCustomIdentityModule.PostgresCustomIdentityTable.REPLY_TO; +import static org.apache.james.jmap.postgres.identity.PostgresCustomIdentityModule.PostgresCustomIdentityTable.SORT_ORDER; +import static org.apache.james.jmap.postgres.identity.PostgresCustomIdentityModule.PostgresCustomIdentityTable.TABLE_NAME; +import static org.apache.james.jmap.postgres.identity.PostgresCustomIdentityModule.PostgresCustomIdentityTable.TEXT_SIGNATURE; +import static org.apache.james.jmap.postgres.identity.PostgresCustomIdentityModule.PostgresCustomIdentityTable.USERNAME; + +import java.util.List; +import java.util.Optional; + +import javax.inject.Inject; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.core.MailAddress; +import org.apache.james.core.Username; +import org.apache.james.jmap.api.identity.CustomIdentityDAO; +import org.apache.james.jmap.api.identity.IdentityCreationRequest; +import org.apache.james.jmap.api.identity.IdentityNotFoundException; +import org.apache.james.jmap.api.identity.IdentityUpdate; +import org.apache.james.jmap.api.model.EmailAddress; +import org.apache.james.jmap.api.model.Identity; +import org.apache.james.jmap.api.model.IdentityId; +import org.jooq.JSON; +import org.jooq.Record; +import org.reactivestreams.Publisher; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.fge.lambdas.Throwing; +import com.google.common.collect.ImmutableList; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scala.publisher.SMono; +import scala.Option; +import scala.collection.immutable.Seq; +import scala.jdk.javaapi.CollectionConverters; +import scala.jdk.javaapi.OptionConverters; +import scala.runtime.BoxedUnit; + +public class PostgresCustomIdentityDAO implements CustomIdentityDAO { + static class Email { + private final String name; + private final String email; + + @JsonCreator + public Email(@JsonProperty("name") String name, + @JsonProperty("email") String email) { + this.name = name; + this.email = email; + } + + public String getName() { + return name; + } + + public String getEmail() { + return email; + } + } + + private final PostgresExecutor.Factory executorFactory; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Inject + public PostgresCustomIdentityDAO(PostgresExecutor.Factory executorFactory) { + this.executorFactory = executorFactory; + } + + @Override + public Publisher save(Username user, IdentityCreationRequest creationRequest) { + return save(user, IdentityId.generate(), creationRequest); + } + + @Override + public Publisher save(Username user, IdentityId identityId, IdentityCreationRequest creationRequest) { + final Identity identity = creationRequest.asIdentity(identityId); + return upsertReturnMono(user, identity); + } + + @Override + public Publisher list(Username user) { + return executorFactory.create(user.getDomainPart()) + .executeRows(dslContext -> Flux.from(dslContext.selectFrom(TABLE_NAME) + .where(USERNAME.eq(user.asString())))) + .map(Throwing.function(this::readRecord)); + } + + @Override + public SMono findByIdentityId(Username user, IdentityId identityId) { + return SMono.fromPublisher(executorFactory.create(user.getDomainPart()) + .executeRow(dslContext -> Mono.from(dslContext.selectFrom(TABLE_NAME) + .where(USERNAME.eq(user.asString())) + .and(ID.eq(identityId.id())))) + .map(Throwing.function(this::readRecord))); + } + + @Override + public Publisher update(Username user, IdentityId identityId, IdentityUpdate identityUpdate) { + return Mono.from(findByIdentityId(user, identityId)) + .switchIfEmpty(Mono.error(new IdentityNotFoundException(identityId))) + .map(identityUpdate::update) + .flatMap(identity -> upsertReturnMono(user, identity)) + .thenReturn(BoxedUnit.UNIT); + } + + @Override + public SMono upsert(Username user, Identity patch) { + return SMono.fromPublisher(upsertReturnMono(user, patch) + .thenReturn(BoxedUnit.UNIT)); + } + + private Mono upsertReturnMono(Username user, Identity identity) { + return executorFactory.create(user.getDomainPart()) + .executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME) + .set(USERNAME, user.asString()) + .set(ID, identity.id().id()) + .set(NAME, identity.name()) + .set(EMAIL, identity.email().asString()) + .set(TEXT_SIGNATURE, identity.textSignature()) + .set(HTML_SIGNATURE, identity.htmlSignature()) + .set(MAY_DELETE, identity.mayDelete()) + .set(SORT_ORDER, identity.sortOrder()) + .set(REPLY_TO, convertToJooqJson(identity.replyTo())) + .set(BCC, convertToJooqJson(identity.bcc())) + .onConflict(USERNAME, ID) + .doUpdate() + .set(NAME, identity.name()) + .set(EMAIL, identity.email().asString()) + .set(TEXT_SIGNATURE, identity.textSignature()) + .set(HTML_SIGNATURE, identity.htmlSignature()) + .set(MAY_DELETE, identity.mayDelete()) + .set(SORT_ORDER, identity.sortOrder()) + .set(REPLY_TO, convertToJooqJson(identity.replyTo())) + .set(BCC, convertToJooqJson(identity.bcc())))) + .thenReturn(identity); + } + + @Override + public Publisher delete(Username username, Seq ids) { + return executorFactory.create(username.getDomainPart()) + .executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(TABLE_NAME) + .where(USERNAME.eq(username.asString())) + .and(ID.in(CollectionConverters.asJavaCollection(ids).stream().map(IdentityId::id).collect(ImmutableList.toImmutableList()))))) + .thenReturn(BoxedUnit.UNIT); + } + + @Override + public Publisher delete(Username username) { + return executorFactory.create(username.getDomainPart()) + .executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(TABLE_NAME) + .where(USERNAME.eq(username.asString())))) + .thenReturn(BoxedUnit.UNIT); + } + + private Identity readRecord(Record record) throws Exception { + return new Identity(new IdentityId(record.get(ID)), + record.get(SORT_ORDER), + record.get(NAME), + new MailAddress(record.get(EMAIL)), + convertToScala(record.get(REPLY_TO)), + convertToScala(record.get(BCC)), + record.get(TEXT_SIGNATURE), + record.get(HTML_SIGNATURE), + record.get(MAY_DELETE)); + } + + private Option> convertToScala(JSON json) { + return OptionConverters.toScala(Optional.of(CollectionConverters.asScala(convertToObject(json.data()) + .stream() + .map(Throwing.function(email -> EmailAddress.from(Optional.ofNullable(email.getName()), new MailAddress(email.getEmail())))) + .iterator()) + .toList())); + } + + private JSON convertToJooqJson(Option> maybeEmailAddresses) { + return convertToJooqJson(OptionConverters.toJava(maybeEmailAddresses).map(emailAddresses -> + CollectionConverters.asJavaCollection(emailAddresses).stream() + .map(emailAddress -> new Email(emailAddress.nameAsString(), + emailAddress.email().asString())).collect(ImmutableList.toImmutableList())) + .orElse(ImmutableList.of())); + } + + private JSON convertToJooqJson(List list) { + try { + return JSON.json(objectMapper.writeValueAsString(list)); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + private List convertToObject(String json) { + try { + return objectMapper.readValue(json, new TypeReference<>() {}); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } +} diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/identity/PostgresCustomIdentityModule.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/identity/PostgresCustomIdentityModule.java new file mode 100644 index 00000000000..5bd3b627299 --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/identity/PostgresCustomIdentityModule.java @@ -0,0 +1,77 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.identity; + +import static org.apache.james.jmap.postgres.identity.PostgresCustomIdentityModule.PostgresCustomIdentityTable.TABLE; +import static org.apache.james.jmap.postgres.identity.PostgresCustomIdentityModule.PostgresCustomIdentityTable.USERNAME_INDEX; + +import java.util.UUID; + +import org.apache.james.backends.postgres.PostgresIndex; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.JSON; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresCustomIdentityModule { + interface PostgresCustomIdentityTable { + Table TABLE_NAME = DSL.table("custom_identity"); + + Field USERNAME = DSL.field("username", SQLDataType.VARCHAR(255).notNull()); + Field ID = DSL.field("id", SQLDataType.UUID.notNull()); + Field NAME = DSL.field("name", SQLDataType.VARCHAR(255).notNull()); + Field EMAIL = DSL.field("email", SQLDataType.VARCHAR(255).notNull()); + Field REPLY_TO = DSL.field("reply_to", SQLDataType.JSON.notNull()); + Field BCC = DSL.field("bcc", SQLDataType.JSON.notNull()); + Field TEXT_SIGNATURE = DSL.field("text_signature", SQLDataType.VARCHAR(255).notNull()); + Field HTML_SIGNATURE = DSL.field("html_signature", SQLDataType.VARCHAR(255).notNull()); + Field SORT_ORDER = DSL.field("sort_order", SQLDataType.INTEGER.notNull()); + Field MAY_DELETE = DSL.field("may_delete", SQLDataType.BOOLEAN.notNull()); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(USERNAME) + .column(ID) + .column(NAME) + .column(EMAIL) + .column(REPLY_TO) + .column(BCC) + .column(TEXT_SIGNATURE) + .column(HTML_SIGNATURE) + .column(SORT_ORDER) + .column(MAY_DELETE) + .constraint(DSL.primaryKey(USERNAME, ID)))) + .supportsRowLevelSecurity() + .build(); + + PostgresIndex USERNAME_INDEX = PostgresIndex.name("custom_identity_username_index") + .createIndexStep((dsl, indexName) -> dsl.createIndexIfNotExists(indexName) + .on(TABLE_NAME, USERNAME)); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(TABLE) + .addIndex(USERNAME_INDEX) + .build(); +} diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/identity/PostgresCustomIdentityDAOTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/identity/PostgresCustomIdentityDAOTest.java new file mode 100644 index 00000000000..7c72f9cceb3 --- /dev/null +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/identity/PostgresCustomIdentityDAOTest.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.identity; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.jmap.api.identity.CustomIdentityDAO; +import org.apache.james.jmap.api.identity.CustomIdentityDAOContract; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresCustomIdentityDAOTest implements CustomIdentityDAOContract { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresCustomIdentityModule.MODULE); + + @Override + public CustomIdentityDAO testee() { + return new PostgresCustomIdentityDAO(postgresExtension.getExecutorFactory()); + } +} From 99660784a4036ea9483c3bb22ea6aafb310ff9c5 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Tue, 6 Feb 2024 16:13:44 +0700 Subject: [PATCH 218/341] JAMES-2586 Handle case when Postgres index/constraint already exists --- .../mailbox/postgres/user/PostgresSubscriptionModule.java | 2 +- .../jmap/postgres/change/PostgresEmailChangeModule.java | 2 +- .../jmap/postgres/change/PostgresMailboxChangeModule.java | 2 +- .../postgres/PostgresMailRepositoryUrlStore.java | 6 +++--- .../rrt/postgres/PostgresRecipientRewriteTableModule.java | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java index bd8afd42b07..43f35ca48a1 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionModule.java @@ -46,7 +46,7 @@ public interface PostgresSubscriptionModule { .supportsRowLevelSecurity() .build(); PostgresIndex INDEX = PostgresIndex.name("subscription_user_index") - .createIndexStep((dsl, indexName) -> dsl.createIndex(indexName) + .createIndexStep((dsl, indexName) -> dsl.createIndexIfNotExists(indexName) .on(TABLE_NAME, USER)); PostgresModule MODULE = PostgresModule.builder() .addTable(TABLE) diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeModule.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeModule.java index fbd0ac877b4..9324be3e451 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeModule.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeModule.java @@ -60,7 +60,7 @@ interface PostgresEmailChangeTable { .build(); PostgresIndex INDEX = PostgresIndex.name("idx_email_change_date") - .createIndexStep((dslContext, indexName) -> dslContext.createIndex(indexName) + .createIndexStep((dslContext, indexName) -> dslContext.createIndexIfNotExists(indexName) .on(TABLE_NAME, DATE)); } diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeModule.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeModule.java index 55b5bf643cb..3d8a646c3b7 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeModule.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeModule.java @@ -62,7 +62,7 @@ interface PostgresMailboxChangeTable { .build(); PostgresIndex INDEX = PostgresIndex.name("index_mailbox_change_date") - .createIndexStep((dslContext, indexName) -> dslContext.createIndex(indexName) + .createIndexStep((dslContext, indexName) -> dslContext.createIndexIfNotExists(indexName) .on(TABLE_NAME, DATE)); } diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryUrlStore.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryUrlStore.java index c032db14a19..58525b1a494 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryUrlStore.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryUrlStore.java @@ -29,7 +29,6 @@ import javax.inject.Named; import org.apache.james.backends.postgres.utils.PostgresExecutor; -import org.apache.james.backends.postgres.utils.PostgresUtils; import org.apache.james.mailrepository.api.MailRepositoryUrl; import org.apache.james.mailrepository.api.MailRepositoryUrlStore; @@ -47,8 +46,9 @@ public PostgresMailRepositoryUrlStore(@Named(DEFAULT_INJECT) PostgresExecutor po @Override public void add(MailRepositoryUrl url) { postgresExecutor.executeVoid(context -> Mono.from(context.insertInto(TABLE_NAME, URL) - .values(url.asString()))) - .onErrorResume(PostgresUtils.UNIQUE_CONSTRAINT_VIOLATION_PREDICATE, e -> Mono.empty()) + .values(url.asString()) + .onConflict(URL) + .doNothing())) .block(); } diff --git a/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableModule.java b/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableModule.java index 40e5afa3643..dc64b602221 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableModule.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableModule.java @@ -49,7 +49,7 @@ interface PostgresRecipientRewriteTableTable { .build(); PostgresIndex INDEX = PostgresIndex.name("idx_rrt_target_address") - .createIndexStep((dslContext, indexName) -> dslContext.createIndex(indexName) + .createIndexStep((dslContext, indexName) -> dslContext.createIndexIfNotExists(indexName) .on(TABLE_NAME, TARGET_ADDRESS)); } From 3df63b8a319b453033aa719c1fe65e49883cc7e4 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Mon, 5 Feb 2024 15:19:03 +0700 Subject: [PATCH 219/341] JAMES-2586 More flexible on comparing Vacation's ZonedDateTime In the Postgres implementation, we accept to just store the input ZonedDateTime under the UTC time zone. Therefore, we need to be more flexible comparing ZonedDateTime between different time zones. E.g.: 2014-04-02T19:01Z[UTC] isEqual 2014-04-03T02:01+07:00[Asia/Vientiane] --- .../java/org/apache/james/vacation/api/Vacation.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/server/data/data-api/src/main/java/org/apache/james/vacation/api/Vacation.java b/server/data/data-api/src/main/java/org/apache/james/vacation/api/Vacation.java index 31804a516e5..c378d63700c 100644 --- a/server/data/data-api/src/main/java/org/apache/james/vacation/api/Vacation.java +++ b/server/data/data-api/src/main/java/org/apache/james/vacation/api/Vacation.java @@ -173,13 +173,20 @@ public boolean equals(Object o) { Vacation vacation = (Vacation) o; return Objects.equals(this.isEnabled, vacation.isEnabled) && - Objects.equals(this.fromDate, vacation.fromDate) && - Objects.equals(this.toDate, vacation.toDate) && + compareZonedDateTimeAcrossTimeZone(this.fromDate, vacation.fromDate) && + compareZonedDateTimeAcrossTimeZone(this.toDate, vacation.toDate) && Objects.equals(this.textBody, vacation.textBody) && Objects.equals(this.subject, vacation.subject) && Objects.equals(this.htmlBody, vacation.htmlBody); } + private boolean compareZonedDateTimeAcrossTimeZone(Optional thisZonedDateTimeOptional, Optional thatZonedDateTimeOptional) { + return thisZonedDateTimeOptional.map(thisZonedDateTime -> thatZonedDateTimeOptional + .map(thisZonedDateTime::isEqual) + .orElse(false)) + .orElseGet(thatZonedDateTimeOptional::isEmpty); + } + @Override public int hashCode() { return Objects.hash(isEnabled, fromDate, toDate, textBody, subject, htmlBody); From c738668d0d9d8ce5c7f8a28f903f90217a5ceb4d Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Mon, 5 Feb 2024 15:21:24 +0700 Subject: [PATCH 220/341] JAMES-2586 Implement PostgresVacationRepository --- .../postgres/utils/PostgresExecutor.java | 6 + .../postgres/PostgresVacationModule.java | 64 +++++++ .../postgres/PostgresVacationRepository.java | 64 +++++++ .../postgres/PostgresVacationResponseDAO.java | 159 ++++++++++++++++++ .../PostgresVacationRepositoryTest.java | 44 +++++ 5 files changed, 337 insertions(+) create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationModule.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationRepository.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationResponseDAO.java create mode 100644 server/data/data-postgres/src/test/java/org/apache/james/vacation/postgres/PostgresVacationRepositoryTest.java diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index 88ccd47746a..889c8151153 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -115,6 +115,12 @@ public Mono executeRow(Function> queryFunc .filter(preparedStatementConflictException())); } + public Mono> executeSingleRowOptional(Function> queryFunction) { + return executeRow(queryFunction) + .map(Optional::ofNullable) + .switchIfEmpty(Mono.just(Optional.empty())); + } + public Mono executeCount(Function>> queryFunction) { return dslContext() .flatMap(queryFunction) diff --git a/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationModule.java b/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationModule.java new file mode 100644 index 00000000000..c1deec31fd5 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationModule.java @@ -0,0 +1,64 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.vacation.postgres; + +import static org.apache.james.vacation.postgres.PostgresVacationModule.PostgresVacationResponseTable.TABLE; + +import java.time.LocalDateTime; + +import org.apache.james.backends.postgres.PostgresCommons; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresVacationModule { + interface PostgresVacationResponseTable { + Table TABLE_NAME = DSL.table("vacation_response"); + + Field ACCOUNT_ID = DSL.field("account_id", SQLDataType.VARCHAR.notNull()); + Field IS_ENABLED = DSL.field("is_enabled", SQLDataType.BOOLEAN); + Field FROM_DATE = DSL.field("from_date", PostgresCommons.DataTypes.TIMESTAMP); + Field TO_DATE = DSL.field("to_date", PostgresCommons.DataTypes.TIMESTAMP); + Field TEXT = DSL.field("text", SQLDataType.VARCHAR); + Field SUBJECT = DSL.field("subject", SQLDataType.VARCHAR); + Field HTML = DSL.field("html", SQLDataType.VARCHAR); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(ACCOUNT_ID) + .column(IS_ENABLED) + .column(FROM_DATE) + .column(TO_DATE) + .column(TEXT) + .column(SUBJECT) + .column(HTML) + .constraint(DSL.primaryKey(ACCOUNT_ID)))) + .supportsRowLevelSecurity() + .build(); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(TABLE) + .build(); +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationRepository.java b/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationRepository.java new file mode 100644 index 00000000000..6f859c7d973 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationRepository.java @@ -0,0 +1,64 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.vacation.postgres; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.core.Username; +import org.apache.james.vacation.api.AccountId; +import org.apache.james.vacation.api.Vacation; +import org.apache.james.vacation.api.VacationPatch; +import org.apache.james.vacation.api.VacationRepository; + +import com.google.common.base.Preconditions; + +import reactor.core.publisher.Mono; + +public class PostgresVacationRepository implements VacationRepository { + private final PostgresExecutor.Factory executorFactory; + + @Inject + @Singleton + public PostgresVacationRepository(PostgresExecutor.Factory executorFactory) { + this.executorFactory = executorFactory; + } + + @Override + public Mono modifyVacation(AccountId accountId, VacationPatch vacationPatch) { + Preconditions.checkNotNull(accountId); + Preconditions.checkNotNull(vacationPatch); + if (vacationPatch.isIdentity()) { + return Mono.empty(); + } else { + return vacationResponseDao(accountId).modifyVacation(accountId, vacationPatch); + } + } + + @Override + public Mono retrieveVacation(AccountId accountId) { + return vacationResponseDao(accountId).retrieveVacation(accountId).map(optional -> optional.orElse(DEFAULT_VACATION)); + } + + private PostgresVacationResponseDAO vacationResponseDao(AccountId accountId) { + return new PostgresVacationResponseDAO(executorFactory.create(Username.of(accountId.getIdentifier()).getDomainPart())); + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationResponseDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationResponseDAO.java new file mode 100644 index 00000000000..8131156da23 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationResponseDAO.java @@ -0,0 +1,159 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.vacation.postgres; + +import static org.apache.james.backends.postgres.PostgresCommons.LOCAL_DATE_TIME_ZONED_DATE_TIME_FUNCTION; +import static org.apache.james.vacation.postgres.PostgresVacationModule.PostgresVacationResponseTable.ACCOUNT_ID; +import static org.apache.james.vacation.postgres.PostgresVacationModule.PostgresVacationResponseTable.FROM_DATE; +import static org.apache.james.vacation.postgres.PostgresVacationModule.PostgresVacationResponseTable.HTML; +import static org.apache.james.vacation.postgres.PostgresVacationModule.PostgresVacationResponseTable.IS_ENABLED; +import static org.apache.james.vacation.postgres.PostgresVacationModule.PostgresVacationResponseTable.SUBJECT; +import static org.apache.james.vacation.postgres.PostgresVacationModule.PostgresVacationResponseTable.TABLE_NAME; +import static org.apache.james.vacation.postgres.PostgresVacationModule.PostgresVacationResponseTable.TEXT; +import static org.apache.james.vacation.postgres.PostgresVacationModule.PostgresVacationResponseTable.TO_DATE; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Optional; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.stream.Stream; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.util.ValuePatch; +import org.apache.james.vacation.api.AccountId; +import org.apache.james.vacation.api.Vacation; +import org.apache.james.vacation.api.VacationPatch; +import org.jooq.DSLContext; +import org.jooq.Field; +import org.jooq.InsertOnDuplicateSetMoreStep; +import org.jooq.InsertOnDuplicateSetStep; +import org.jooq.InsertSetMoreStep; +import org.jooq.Record; + +import reactor.core.publisher.Mono; + +public class PostgresVacationResponseDAO { + private final PostgresExecutor postgresExecutor; + + public PostgresVacationResponseDAO(PostgresExecutor postgresExecutor) { + this.postgresExecutor = postgresExecutor; + } + + public Mono modifyVacation(AccountId accountId, VacationPatch vacationPatch) { + return postgresExecutor.executeVoid(dsl -> { + if (vacationPatch.isIdentity()) { + return Mono.from(insertVacationQuery(accountId, vacationPatch, dsl) + .onConflictDoNothing()); + } else { + return Mono.from(withUpdateOnConflict(vacationPatch, insertVacationQuery(accountId, vacationPatch, dsl))); + } + }); + } + + private InsertSetMoreStep insertVacationQuery(AccountId accountId, VacationPatch vacationPatch, DSLContext dsl) { + InsertSetMoreStep baseInsert = dsl.insertInto(TABLE_NAME) + .set(ACCOUNT_ID, accountId.getIdentifier()); + + return Stream.of( + applyInsertForField(IS_ENABLED, VacationPatch::getIsEnabled), + applyInsertForField(SUBJECT, VacationPatch::getSubject), + applyInsertForField(HTML, VacationPatch::getHtmlBody), + applyInsertForField(TEXT, VacationPatch::getTextBody), + applyInsertForFieldZonedDateTime(FROM_DATE, VacationPatch::getFromDate), + applyInsertForFieldZonedDateTime(TO_DATE, VacationPatch::getToDate)) + .reduce((vacation, insert) -> insert, + (a, b) -> (vacation, insert) -> b.apply(vacation, a.apply(vacation, insert))) + .apply(vacationPatch, baseInsert); + } + + private InsertOnDuplicateSetMoreStep withUpdateOnConflict(VacationPatch vacationPatch, InsertSetMoreStep insertVacation) { + InsertOnDuplicateSetStep baseUpdateIfConflict = insertVacation.onConflict(ACCOUNT_ID) + .doUpdate(); + + return (InsertOnDuplicateSetMoreStep) Stream.of( + applyUpdateOnConflictForField(IS_ENABLED, VacationPatch::getIsEnabled), + applyUpdateOnConflictForField(SUBJECT, VacationPatch::getSubject), + applyUpdateOnConflictForField(HTML, VacationPatch::getHtmlBody), + applyUpdateOnConflictForField(TEXT, VacationPatch::getTextBody), + applyUpdateOnConflictForFieldZonedDateTime(FROM_DATE, VacationPatch::getFromDate), + applyUpdateOnConflictForFieldZonedDateTime(TO_DATE, VacationPatch::getToDate)) + .reduce((vacation, updateOnConflict) -> updateOnConflict, + (a, b) -> (vacation, updateOnConflict) -> b.apply(vacation, a.apply(vacation, updateOnConflict))) + .apply(vacationPatch, baseUpdateIfConflict); + } + + private BiFunction, InsertSetMoreStep> applyInsertForField(Field field, Function> getter) { + return (vacation, insert) -> + getter.apply(vacation) + .mapNotKeptToOptional(optionalValue -> applyInsertForField(field, optionalValue, insert)) + .orElse(insert); + } + + private BiFunction, InsertSetMoreStep> applyInsertForFieldZonedDateTime(Field field, Function> getter) { + return (vacation, insert) -> + getter.apply(vacation) + .mapNotKeptToOptional(optionalValue -> applyInsertForField(field, + optionalValue.map(zonedDateTime -> zonedDateTime.withZoneSameInstant(ZoneId.of("UTC")).toLocalDateTime()), + insert)) + .orElse(insert); + } + + private InsertSetMoreStep applyInsertForField(Field field, Optional value, InsertSetMoreStep insert) { + return insert.set(field, value.orElse(null)); + } + + private BiFunction, InsertOnDuplicateSetStep> applyUpdateOnConflictForField(Field field, Function> getter) { + return (vacation, update) -> + getter.apply(vacation) + .mapNotKeptToOptional(optionalValue -> applyUpdateOnConflictForField(field, optionalValue, update)) + .orElse(update); + } + + private BiFunction, InsertOnDuplicateSetStep> applyUpdateOnConflictForFieldZonedDateTime(Field field, Function> getter) { + return (vacation, update) -> + getter.apply(vacation) + .mapNotKeptToOptional(optionalValue -> applyUpdateOnConflictForField(field, + optionalValue.map(zonedDateTime -> zonedDateTime.withZoneSameInstant(ZoneId.of("UTC")).toLocalDateTime()), + update)) + .orElse(update); + } + + private InsertOnDuplicateSetStep applyUpdateOnConflictForField(Field field, Optional value, InsertOnDuplicateSetStep updateOnConflict) { + return updateOnConflict.set(field, value.orElse(null)); + } + + public Mono> retrieveVacation(AccountId accountId) { + return postgresExecutor.executeSingleRowOptional(dsl -> dsl.selectFrom(TABLE_NAME) + .where(ACCOUNT_ID.eq(accountId.getIdentifier()))) + .map(recordOptional -> recordOptional.map(record -> Vacation.builder() + .enabled(Optional.ofNullable(record.get(IS_ENABLED)) + .orElse(false)) + .fromDate(Optional.ofNullable(record.get(FROM_DATE, LocalDateTime.class)) + .map(LOCAL_DATE_TIME_ZONED_DATE_TIME_FUNCTION)) + .toDate(Optional.ofNullable(record.get(TO_DATE, LocalDateTime.class)) + .map(LOCAL_DATE_TIME_ZONED_DATE_TIME_FUNCTION)) + .subject(Optional.ofNullable(record.get(SUBJECT))) + .textBody(Optional.ofNullable(record.get(TEXT))) + .htmlBody(Optional.ofNullable(record.get(HTML))) + .build())); + } +} \ No newline at end of file diff --git a/server/data/data-postgres/src/test/java/org/apache/james/vacation/postgres/PostgresVacationRepositoryTest.java b/server/data/data-postgres/src/test/java/org/apache/james/vacation/postgres/PostgresVacationRepositoryTest.java new file mode 100644 index 00000000000..81488b86777 --- /dev/null +++ b/server/data/data-postgres/src/test/java/org/apache/james/vacation/postgres/PostgresVacationRepositoryTest.java @@ -0,0 +1,44 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.vacation.postgres; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.vacation.api.VacationRepository; +import org.apache.james.vacation.api.VacationRepositoryContract; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; + +class PostgresVacationRepositoryTest implements VacationRepositoryContract { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresModule.aggregateModules(PostgresVacationModule.MODULE)); + + VacationRepository vacationRepository; + + @BeforeEach + void setUp() { + vacationRepository = new PostgresVacationRepository(postgresExtension.getExecutorFactory()); + } + + @Override + public VacationRepository vacationRepository() { + return vacationRepository; + } +} From 7b83381b93f4c6c12097b1dde84e6db1de406eda Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Mon, 5 Feb 2024 16:57:46 +0700 Subject: [PATCH 221/341] JAMES-2586 Guice binding PostgresVacationRepository --- .../apache/james/PostgresJamesServerMain.java | 4 +- .../org/apache/james/PostgresJmapModule.java | 14 ----- .../modules/data/PostgresVacationModule.java | 56 +++++++++++++++++++ 3 files changed, 59 insertions(+), 15 deletions(-) create mode 100644 server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresVacationModule.java diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index 367148dcbe6..15660762749 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -34,6 +34,7 @@ import org.apache.james.modules.data.PostgresDataModule; import org.apache.james.modules.data.PostgresDelegationStoreModule; import org.apache.james.modules.data.PostgresUsersRepositoryModule; +import org.apache.james.modules.data.PostgresVacationModule; import org.apache.james.modules.data.SievePostgresRepositoryModules; import org.apache.james.modules.event.JMAPEventBusModule; import org.apache.james.modules.event.RabbitMQEventBusModule; @@ -114,7 +115,8 @@ public class PostgresJamesServerMain implements JamesServerMain { new TaskManagerModule(), new MemoryEventStoreModule(), new TikaMailboxModule(), - new PostgresDLPConfigurationStoreModule()); + new PostgresDLPConfigurationStoreModule(), + new PostgresVacationModule()); public static final Module JMAP = Modules.combine( new PostgresJmapModule(), diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java index 9ca2329cbcf..1952bfe1815 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java @@ -37,11 +37,6 @@ import org.apache.james.mailbox.store.StoreAttachmentManager; import org.apache.james.mailbox.store.StoreMessageIdManager; import org.apache.james.mailbox.store.StoreRightManager; -import org.apache.james.vacation.api.NotificationRegistry; -import org.apache.james.vacation.api.VacationRepository; -import org.apache.james.vacation.api.VacationService; -import org.apache.james.vacation.memory.MemoryNotificationRegistry; -import org.apache.james.vacation.memory.MemoryVacationRepository; import com.google.inject.AbstractModule; import com.google.inject.Scopes; @@ -65,15 +60,6 @@ protected void configure() { bind(UploadUsageRepository.class).to(PostgresUploadUsageRepository.class); - bind(DefaultVacationService.class).in(Scopes.SINGLETON); - bind(VacationService.class).to(DefaultVacationService.class); - - bind(MemoryNotificationRegistry.class).in(Scopes.SINGLETON); - bind(NotificationRegistry.class).to(MemoryNotificationRegistry.class); - - bind(MemoryVacationRepository.class).in(Scopes.SINGLETON); - bind(VacationRepository.class).to(MemoryVacationRepository.class); - bind(MessageIdManager.class).to(StoreMessageIdManager.class); bind(AttachmentManager.class).to(StoreAttachmentManager.class); bind(StoreMessageIdManager.class).in(Scopes.SINGLETON); diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresVacationModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresVacationModule.java new file mode 100644 index 00000000000..a174054f0ef --- /dev/null +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresVacationModule.java @@ -0,0 +1,56 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.data; + +import org.apache.james.DefaultVacationService; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.user.api.DeleteUserDataTaskStep; +import org.apache.james.vacation.api.NotificationRegistry; +import org.apache.james.vacation.api.VacationDeleteUserTaskStep; +import org.apache.james.vacation.api.VacationRepository; +import org.apache.james.vacation.api.VacationService; +import org.apache.james.vacation.memory.MemoryNotificationRegistry; +import org.apache.james.vacation.postgres.PostgresVacationRepository; + +import com.google.inject.AbstractModule; +import com.google.inject.Scopes; +import com.google.inject.multibindings.Multibinder; + +public class PostgresVacationModule extends AbstractModule { + + @Override + public void configure() { + bind(DefaultVacationService.class).in(Scopes.SINGLETON); + bind(VacationService.class).to(DefaultVacationService.class); + + bind(PostgresVacationRepository.class).in(Scopes.SINGLETON); + bind(VacationRepository.class).to(PostgresVacationRepository.class); + + bind(MemoryNotificationRegistry.class).in(Scopes.SINGLETON); + bind(NotificationRegistry.class).to(MemoryNotificationRegistry.class); + + Multibinder postgresVacationModules = Multibinder.newSetBinder(binder(), PostgresModule.class); + postgresVacationModules.addBinding().toInstance(org.apache.james.vacation.postgres.PostgresVacationModule.MODULE); + + Multibinder deleteUserDataTaskSteps = Multibinder.newSetBinder(binder(), DeleteUserDataTaskStep.class); + deleteUserDataTaskSteps.addBinding().to(VacationDeleteUserTaskStep.class); + } + +} From 3e1eb31d3727e12b8c909dbeac1242e292f9831b Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Tue, 6 Feb 2024 14:42:30 +0700 Subject: [PATCH 222/341] JAMES-2586 Improve PostgresVacationRepository --- .../james/vacation/postgres/PostgresVacationModule.java | 3 ++- .../vacation/postgres/PostgresVacationResponseDAO.java | 9 +++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationModule.java b/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationModule.java index c1deec31fd5..8149ed53a74 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationModule.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationModule.java @@ -37,7 +37,8 @@ interface PostgresVacationResponseTable { Table TABLE_NAME = DSL.table("vacation_response"); Field ACCOUNT_ID = DSL.field("account_id", SQLDataType.VARCHAR.notNull()); - Field IS_ENABLED = DSL.field("is_enabled", SQLDataType.BOOLEAN); + Field IS_ENABLED = DSL.field("is_enabled", SQLDataType.BOOLEAN.notNull() + .defaultValue(DSL.field("false", SQLDataType.BOOLEAN))); Field FROM_DATE = DSL.field("from_date", PostgresCommons.DataTypes.TIMESTAMP); Field TO_DATE = DSL.field("to_date", PostgresCommons.DataTypes.TIMESTAMP); Field TEXT = DSL.field("text", SQLDataType.VARCHAR); diff --git a/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationResponseDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationResponseDAO.java index 8131156da23..52e4328ffa6 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationResponseDAO.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationResponseDAO.java @@ -145,12 +145,9 @@ public Mono> retrieveVacation(AccountId accountId) { return postgresExecutor.executeSingleRowOptional(dsl -> dsl.selectFrom(TABLE_NAME) .where(ACCOUNT_ID.eq(accountId.getIdentifier()))) .map(recordOptional -> recordOptional.map(record -> Vacation.builder() - .enabled(Optional.ofNullable(record.get(IS_ENABLED)) - .orElse(false)) - .fromDate(Optional.ofNullable(record.get(FROM_DATE, LocalDateTime.class)) - .map(LOCAL_DATE_TIME_ZONED_DATE_TIME_FUNCTION)) - .toDate(Optional.ofNullable(record.get(TO_DATE, LocalDateTime.class)) - .map(LOCAL_DATE_TIME_ZONED_DATE_TIME_FUNCTION)) + .enabled(record.get(IS_ENABLED)) + .fromDate(Optional.ofNullable(LOCAL_DATE_TIME_ZONED_DATE_TIME_FUNCTION.apply(record.get(FROM_DATE, LocalDateTime.class)))) + .toDate(Optional.ofNullable(LOCAL_DATE_TIME_ZONED_DATE_TIME_FUNCTION.apply(record.get(TO_DATE, LocalDateTime.class)))) .subject(Optional.ofNullable(record.get(SUBJECT))) .textBody(Optional.ofNullable(record.get(TEXT))) .htmlBody(Optional.ofNullable(record.get(HTML))) From 8f059923bdfd6dd0349bf031c7a661d3b05566e6 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 7 Feb 2024 00:52:46 +0700 Subject: [PATCH 223/341] JAMES-2586 Temporarily disable a flaky PostgresUploadService test --- .../jmap/postgres/upload/PostgresUploadServiceTest.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadServiceTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadServiceTest.java index f5cb92a3384..e2f7fde4590 100644 --- a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadServiceTest.java +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadServiceTest.java @@ -36,6 +36,8 @@ import org.apache.james.jmap.api.upload.UploadUsageRepository; import org.apache.james.server.blob.deduplication.DeDuplicationBlobStore; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; public class PostgresUploadServiceTest implements UploadServiceContract { @@ -73,4 +75,11 @@ public UploadUsageRepository uploadUsageRepository() { public UploadService testee() { return testee; } + + @Override + @Test + @Disabled("Flaky test. TODO stabilize it.") + public void uploadShouldUpdateCurrentStoredUsageUponCleaningUploadSpace() { + + } } From c1561388be4d94347b3762bfab1d4a180ddd6800 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 7 Feb 2024 07:17:40 +0700 Subject: [PATCH 224/341] JAMES-2586 Optimize query increase/decrease for Quota Current Value --- .../quota/PostgresQuotaCurrentValueDAO.java | 72 +++++++++++-------- 1 file changed, 42 insertions(+), 30 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java index a3a539b1cb8..472f594f960 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java @@ -22,7 +22,6 @@ import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaCurrentValueTable.COMPONENT; import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaCurrentValueTable.CURRENT_VALUE; import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaCurrentValueTable.IDENTIFIER; -import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaCurrentValueTable.PRIMARY_KEY_CONSTRAINT_NAME; import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaCurrentValueTable.TABLE_NAME; import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaCurrentValueTable.TYPE; import static org.apache.james.backends.postgres.utils.PostgresExecutor.DEFAULT_INJECT; @@ -36,15 +35,14 @@ import org.apache.james.core.quota.QuotaComponent; import org.apache.james.core.quota.QuotaCurrentValue; import org.apache.james.core.quota.QuotaType; +import org.jooq.Field; import org.jooq.Record; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; public class PostgresQuotaCurrentValueDAO { - private static final Logger LOGGER = LoggerFactory.getLogger(PostgresQuotaCurrentValueDAO.class); + private static final boolean IS_INCREASE = true; private final PostgresExecutor postgresExecutor; @@ -54,35 +52,49 @@ public PostgresQuotaCurrentValueDAO(@Named(DEFAULT_INJECT) PostgresExecutor post } public Mono increase(QuotaCurrentValue.Key quotaKey, long amount) { - return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME) - .set(IDENTIFIER, quotaKey.getIdentifier()) - .set(COMPONENT, quotaKey.getQuotaComponent().getValue()) - .set(TYPE, quotaKey.getQuotaType().getValue()) - .set(CURRENT_VALUE, amount) - .onConflictOnConstraint(PRIMARY_KEY_CONSTRAINT_NAME) - .doUpdate() - .set(CURRENT_VALUE, CURRENT_VALUE.plus(amount)))) - .onErrorResume(ex -> { - LOGGER.warn("Failure when increasing {} {} quota for {}. Quota current value is thus not updated and needs re-computation", - quotaKey.getQuotaComponent().getValue(), quotaKey.getQuotaType().getValue(), quotaKey.getIdentifier(), ex); - return Mono.empty(); - }); + return updateCurrentValue(quotaKey, amount, IS_INCREASE) + .switchIfEmpty(Mono.defer(() -> insert(quotaKey, amount, IS_INCREASE))) + .then(); + } + + public Mono updateCurrentValue(QuotaCurrentValue.Key quotaKey, long amount, boolean isIncrease) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.update(TABLE_NAME) + .set(CURRENT_VALUE, getCurrentValueOperator(isIncrease, amount)) + .where(IDENTIFIER.eq(quotaKey.getIdentifier()), + COMPONENT.eq(quotaKey.getQuotaComponent().getValue()), + TYPE.eq(quotaKey.getQuotaType().getValue())) + .returning(CURRENT_VALUE))) + .map(record -> record.get(CURRENT_VALUE)); + } + + private Field getCurrentValueOperator(boolean isIncrease, long amount) { + if (isIncrease) { + return CURRENT_VALUE.plus(amount); + } + return CURRENT_VALUE.minus(amount); + } + + public Mono insert(QuotaCurrentValue.Key quotaKey, long amount, boolean isIncrease) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME) + .set(IDENTIFIER, quotaKey.getIdentifier()) + .set(COMPONENT, quotaKey.getQuotaComponent().getValue()) + .set(TYPE, quotaKey.getQuotaType().getValue()) + .set(CURRENT_VALUE, newCurrentValue(amount, isIncrease)) + .returning(CURRENT_VALUE))) + .map(record -> record.get(CURRENT_VALUE)); + } + + private Long newCurrentValue(long amount, boolean isIncrease) { + if (isIncrease) { + return amount; + } + return -amount; } public Mono decrease(QuotaCurrentValue.Key quotaKey, long amount) { - return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME) - .set(IDENTIFIER, quotaKey.getIdentifier()) - .set(COMPONENT, quotaKey.getQuotaComponent().getValue()) - .set(TYPE, quotaKey.getQuotaType().getValue()) - .set(CURRENT_VALUE, -amount) - .onConflictOnConstraint(PRIMARY_KEY_CONSTRAINT_NAME) - .doUpdate() - .set(CURRENT_VALUE, CURRENT_VALUE.minus(amount)))) - .onErrorResume(ex -> { - LOGGER.warn("Failure when decreasing {} {} quota for {}. Quota current value is thus not updated and needs re-computation", - quotaKey.getQuotaComponent().getValue(), quotaKey.getQuotaType().getValue(), quotaKey.getIdentifier(), ex); - return Mono.empty(); - }); + return updateCurrentValue(quotaKey, amount, !IS_INCREASE) + .switchIfEmpty(Mono.defer(() -> insert(quotaKey, amount, !IS_INCREASE))) + .then(); } public Mono getQuotaCurrentValue(QuotaCurrentValue.Key quotaKey) { From 2b4df1034827054f4ecf2cb4eb90b558ff3a3161 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 7 Feb 2024 07:18:42 +0700 Subject: [PATCH 225/341] JAMES-2586 Add Index for Postgres Mailbox table --- .../james/mailbox/postgres/mail/PostgresMailboxModule.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java index 2d55f8eda97..1b56199ad78 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java @@ -23,6 +23,7 @@ import java.util.UUID; +import org.apache.james.backends.postgres.PostgresIndex; import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.backends.postgres.PostgresTable; import org.jooq.Field; @@ -60,9 +61,13 @@ interface PostgresMailboxTable { .constraint(DSL.unique(MAILBOX_NAME, USER_NAME, MAILBOX_NAMESPACE)))) .supportsRowLevelSecurity() .build(); + PostgresIndex MAILBOX_USERNAME_NAMESPACE_INDEX = PostgresIndex.name("mailbox_username_namespace_index") + .createIndexStep((dsl, indexName) -> dsl.createIndexIfNotExists(indexName) + .on(TABLE_NAME, USER_NAME, MAILBOX_NAMESPACE)); } PostgresModule MODULE = PostgresModule.builder() .addTable(PostgresMailboxTable.TABLE) + .addIndex(PostgresMailboxTable.MAILBOX_USERNAME_NAMESPACE_INDEX) .build(); } \ No newline at end of file From 490dbe029481e8b0342dd80457dcfb42f01bd340 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 7 Feb 2024 15:16:16 +0700 Subject: [PATCH 226/341] JAMES-2586 Implement PostgresNotificationRegistry --- .../PostgresNotificationRegistry.java | 79 +++++++++++++++++++ .../PostgresNotificationRegistryDAO.java | 72 +++++++++++++++++ .../postgres/PostgresVacationModule.java | 32 +++++++- .../PostgresNotificationRegistryTest.java | 52 ++++++++++++ 4 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresNotificationRegistry.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresNotificationRegistryDAO.java create mode 100644 server/data/data-postgres/src/test/java/org/apache/james/vacation/postgres/PostgresNotificationRegistryTest.java diff --git a/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresNotificationRegistry.java b/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresNotificationRegistry.java new file mode 100644 index 00000000000..7dd3238ac81 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresNotificationRegistry.java @@ -0,0 +1,79 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.vacation.postgres; + +import java.time.ZonedDateTime; +import java.util.Optional; + +import javax.inject.Inject; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.core.Username; +import org.apache.james.util.date.ZonedDateTimeProvider; +import org.apache.james.vacation.api.AccountId; +import org.apache.james.vacation.api.NotificationRegistry; +import org.apache.james.vacation.api.RecipientId; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import reactor.core.publisher.Mono; + +public class PostgresNotificationRegistry implements NotificationRegistry { + private static final Logger LOGGER = LoggerFactory.getLogger(PostgresNotificationRegistry.class); + + private final ZonedDateTimeProvider zonedDateTimeProvider; + private final PostgresExecutor.Factory executorFactory; + + @Inject + public PostgresNotificationRegistry(ZonedDateTimeProvider zonedDateTimeProvider, + PostgresExecutor.Factory executorFactory) { + this.zonedDateTimeProvider = zonedDateTimeProvider; + this.executorFactory = executorFactory; + } + + @Override + public Mono register(AccountId accountId, RecipientId recipientId, Optional expiryDate) { + if (isValid(expiryDate)) { + return notificationRegistryDAO(accountId).register(accountId, recipientId, expiryDate); + } else { + LOGGER.warn("Invalid vacation notification expiry date for {} {} : {}", accountId, recipientId, expiryDate); + return Mono.empty(); + } + } + + @Override + public Mono isRegistered(AccountId accountId, RecipientId recipientId) { + return notificationRegistryDAO(accountId).isRegistered(accountId, recipientId); + } + + @Override + public Mono flush(AccountId accountId) { + return notificationRegistryDAO(accountId).flush(accountId); + } + + private boolean isValid(Optional expiryDate) { + return expiryDate.isEmpty() || expiryDate.get().isAfter(zonedDateTimeProvider.get()); + } + + private PostgresNotificationRegistryDAO notificationRegistryDAO(AccountId accountId) { + return new PostgresNotificationRegistryDAO(executorFactory.create(Username.of(accountId.getIdentifier()).getDomainPart()), + zonedDateTimeProvider); + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresNotificationRegistryDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresNotificationRegistryDAO.java new file mode 100644 index 00000000000..4638ad9805a --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresNotificationRegistryDAO.java @@ -0,0 +1,72 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.vacation.postgres; + +import static org.apache.james.vacation.postgres.PostgresVacationModule.PostgresVacationNotificationRegistryTable.ACCOUNT_ID; +import static org.apache.james.vacation.postgres.PostgresVacationModule.PostgresVacationNotificationRegistryTable.EXPIRY_DATE; +import static org.apache.james.vacation.postgres.PostgresVacationModule.PostgresVacationNotificationRegistryTable.RECIPIENT_ID; +import static org.apache.james.vacation.postgres.PostgresVacationModule.PostgresVacationNotificationRegistryTable.TABLE_NAME; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.Optional; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.util.date.ZonedDateTimeProvider; +import org.apache.james.vacation.api.AccountId; +import org.apache.james.vacation.api.RecipientId; + +import reactor.core.publisher.Mono; + +public class PostgresNotificationRegistryDAO { + private final PostgresExecutor postgresExecutor; + private final ZonedDateTimeProvider zonedDateTimeProvider; + + public PostgresNotificationRegistryDAO(PostgresExecutor postgresExecutor, + ZonedDateTimeProvider zonedDateTimeProvider) { + this.postgresExecutor = postgresExecutor; + this.zonedDateTimeProvider = zonedDateTimeProvider; + } + + public Mono register(AccountId accountId, RecipientId recipientId, Optional expiryDate) { + return postgresExecutor.executeVoid(dsl -> Mono.from(dsl.insertInto(TABLE_NAME) + .set(ACCOUNT_ID, accountId.getIdentifier()) + .set(RECIPIENT_ID, recipientId.getAsString()) + .set(EXPIRY_DATE, expiryDate.map(zonedDateTime -> zonedDateTime.withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()) + .orElse(null)))); + } + + public Mono isRegistered(AccountId accountId, RecipientId recipientId) { + LocalDateTime currentUTCTime = zonedDateTimeProvider.get().withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime(); + + return postgresExecutor.executeRow(dsl -> Mono.from(dsl.select(ACCOUNT_ID) + .from(TABLE_NAME) + .where(ACCOUNT_ID.eq(accountId.getIdentifier()), + RECIPIENT_ID.eq(recipientId.getAsString()), + EXPIRY_DATE.ge(currentUTCTime).or(EXPIRY_DATE.isNull())))) + .hasElement(); + } + + public Mono flush(AccountId accountId) { + return postgresExecutor.executeVoid(dsl -> Mono.from(dsl.deleteFrom(TABLE_NAME) + .where(ACCOUNT_ID.eq(accountId.getIdentifier())))); + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationModule.java b/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationModule.java index 8149ed53a74..f3066518228 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationModule.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationModule.java @@ -19,11 +19,10 @@ package org.apache.james.vacation.postgres; -import static org.apache.james.vacation.postgres.PostgresVacationModule.PostgresVacationResponseTable.TABLE; - import java.time.LocalDateTime; import org.apache.james.backends.postgres.PostgresCommons; +import org.apache.james.backends.postgres.PostgresIndex; import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.backends.postgres.PostgresTable; import org.jooq.Field; @@ -59,7 +58,34 @@ interface PostgresVacationResponseTable { .build(); } + interface PostgresVacationNotificationRegistryTable { + Table TABLE_NAME = DSL.table("vacation_notification_registry"); + + Field ACCOUNT_ID = DSL.field("account_id", SQLDataType.VARCHAR.notNull()); + Field RECIPIENT_ID = DSL.field("recipient_id", SQLDataType.VARCHAR.notNull()); + Field EXPIRY_DATE = DSL.field("expiry_date", PostgresCommons.DataTypes.TIMESTAMP); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(ACCOUNT_ID) + .column(RECIPIENT_ID) + .column(EXPIRY_DATE) + .constraint(DSL.primaryKey(ACCOUNT_ID, RECIPIENT_ID)))) + .supportsRowLevelSecurity() + .build(); + + PostgresIndex ACCOUNT_ID_INDEX = PostgresIndex.name("vacation_notification_registry_accountId_index") + .createIndexStep((dsl, indexName) -> dsl.createIndexIfNotExists(indexName) + .on(TABLE_NAME, ACCOUNT_ID)); + + PostgresIndex FULL_COMPOSITE_INDEX = PostgresIndex.name("vacation_notification_registry_accountId_recipientId_expiryDate_index") + .createIndexStep((dsl, indexName) -> dsl.createIndexIfNotExists(indexName) + .on(TABLE_NAME, ACCOUNT_ID, RECIPIENT_ID, EXPIRY_DATE)); + } + PostgresModule MODULE = PostgresModule.builder() - .addTable(TABLE) + .addTable(PostgresVacationResponseTable.TABLE) + .addTable(PostgresVacationNotificationRegistryTable.TABLE) + .addIndex(PostgresVacationNotificationRegistryTable.ACCOUNT_ID_INDEX, PostgresVacationNotificationRegistryTable.FULL_COMPOSITE_INDEX) .build(); } diff --git a/server/data/data-postgres/src/test/java/org/apache/james/vacation/postgres/PostgresNotificationRegistryTest.java b/server/data/data-postgres/src/test/java/org/apache/james/vacation/postgres/PostgresNotificationRegistryTest.java new file mode 100644 index 00000000000..19c55dbf2ad --- /dev/null +++ b/server/data/data-postgres/src/test/java/org/apache/james/vacation/postgres/PostgresNotificationRegistryTest.java @@ -0,0 +1,52 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.vacation.postgres; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.core.MailAddress; +import org.apache.james.vacation.api.NotificationRegistry; +import org.apache.james.vacation.api.NotificationRegistryContract; +import org.apache.james.vacation.api.RecipientId; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; + +class PostgresNotificationRegistryTest implements NotificationRegistryContract { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresModule.aggregateModules(PostgresVacationModule.MODULE)); + + NotificationRegistry notificationRegistry; + RecipientId recipientId; + + @BeforeEach + public void setUp() throws Exception { + notificationRegistry = new PostgresNotificationRegistry(zonedDateTimeProvider, postgresExtension.getExecutorFactory()); + recipientId = RecipientId.fromMailAddress(new MailAddress("benwa@apache.org")); + } + @Override + public NotificationRegistry notificationRegistry() { + return notificationRegistry; + } + + @Override + public RecipientId recipientId() { + return recipientId; + } +} From 9b21d23b3eb2bed1a6e52b4e2393848bd1b330a2 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 7 Feb 2024 15:16:46 +0700 Subject: [PATCH 227/341] JAMES-2586 SQL script to clean outdated vacation notifications --- server/apps/postgres-app/README.adoc | 3 ++- server/apps/postgres-app/clean_up.sql | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/server/apps/postgres-app/README.adoc b/server/apps/postgres-app/README.adoc index 219d276b29d..f30f9c48516 100644 --- a/server/apps/postgres-app/README.adoc +++ b/server/apps/postgres-app/README.adoc @@ -131,4 +131,5 @@ docker compose -f docker-compose-distributed.yml up -d To clean up some specific data, that will never be used again after a long time, you can execute the SQL queries `clean_up.sql`. The never used data are: - mailbox_change -- email_change \ No newline at end of file +- email_change +- vacation_notification_registry \ No newline at end of file diff --git a/server/apps/postgres-app/clean_up.sql b/server/apps/postgres-app/clean_up.sql index cee84dce803..c0f8f0b8432 100644 --- a/server/apps/postgres-app/clean_up.sql +++ b/server/apps/postgres-app/clean_up.sql @@ -17,5 +17,10 @@ $$ DELETE FROM email_change WHERE date < current_timestamp - interval '1 day' * days_to_keep; + + -- Delete outdated vacation notifications (older than the current UTC timestamp) + DELETE + FROM vacation_notification_registry + WHERE expiry_date < CURRENT_TIMESTAMP AT TIME ZONE 'UTC'; END $$; \ No newline at end of file From fb4b8e20319f7efdda2efda539bc80745fad7861 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 7 Feb 2024 15:17:05 +0700 Subject: [PATCH 228/341] JAMES-2586 Guice binding for PostgresNotificationRegistry --- .../apache/james/modules/data/PostgresVacationModule.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresVacationModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresVacationModule.java index a174054f0ef..c7dddf4fd4a 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresVacationModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresVacationModule.java @@ -26,7 +26,7 @@ import org.apache.james.vacation.api.VacationDeleteUserTaskStep; import org.apache.james.vacation.api.VacationRepository; import org.apache.james.vacation.api.VacationService; -import org.apache.james.vacation.memory.MemoryNotificationRegistry; +import org.apache.james.vacation.postgres.PostgresNotificationRegistry; import org.apache.james.vacation.postgres.PostgresVacationRepository; import com.google.inject.AbstractModule; @@ -43,8 +43,8 @@ public void configure() { bind(PostgresVacationRepository.class).in(Scopes.SINGLETON); bind(VacationRepository.class).to(PostgresVacationRepository.class); - bind(MemoryNotificationRegistry.class).in(Scopes.SINGLETON); - bind(NotificationRegistry.class).to(MemoryNotificationRegistry.class); + bind(PostgresNotificationRegistry.class).in(Scopes.SINGLETON); + bind(NotificationRegistry.class).to(PostgresNotificationRegistry.class); Multibinder postgresVacationModules = Multibinder.newSetBinder(binder(), PostgresModule.class); postgresVacationModules.addBinding().toInstance(org.apache.james.vacation.postgres.PostgresVacationModule.MODULE); From 3843d0982815cfe292cfd366f10781569fd5447c Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Wed, 7 Feb 2024 16:42:53 +0700 Subject: [PATCH 229/341] JAMES-2586 Fix contract test NotificationRegistryContract::registerShouldNotPersistWhenExpiryDateIsPresent The scenario was not really as same as the test name. --- .../apache/james/vacation/api/NotificationRegistryContract.java | 2 +- .../james/vacation/cassandra/CassandraNotificationRegistry.java | 2 +- .../james/vacation/memory/MemoryNotificationRegistry.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/data/data-api/src/test/java/org/apache/james/vacation/api/NotificationRegistryContract.java b/server/data/data-api/src/test/java/org/apache/james/vacation/api/NotificationRegistryContract.java index b5196b8167c..5f13091c4b7 100644 --- a/server/data/data-api/src/test/java/org/apache/james/vacation/api/NotificationRegistryContract.java +++ b/server/data/data-api/src/test/java/org/apache/james/vacation/api/NotificationRegistryContract.java @@ -115,7 +115,7 @@ default void registerShouldNotPersistWhenExpiryDateIsPresent() { notificationRegistry().register(ACCOUNT_ID, recipientId(), Optional.of(ZONED_DATE_TIME)).block(); - assertThat(notificationRegistry().isRegistered(ACCOUNT_ID, recipientId()).block()).isTrue(); + assertThat(notificationRegistry().isRegistered(ACCOUNT_ID, recipientId()).block()).isFalse(); } @Test diff --git a/server/data/data-cassandra/src/main/java/org/apache/james/vacation/cassandra/CassandraNotificationRegistry.java b/server/data/data-cassandra/src/main/java/org/apache/james/vacation/cassandra/CassandraNotificationRegistry.java index 76033531252..43091bd04b9 100644 --- a/server/data/data-cassandra/src/main/java/org/apache/james/vacation/cassandra/CassandraNotificationRegistry.java +++ b/server/data/data-cassandra/src/main/java/org/apache/james/vacation/cassandra/CassandraNotificationRegistry.java @@ -81,6 +81,6 @@ public Mono flush(AccountId accountId) { } private boolean isValid(Optional waitDelay) { - return waitDelay.isEmpty() || waitDelay.get() >= 0; + return waitDelay.isEmpty() || waitDelay.get() > 0; } } diff --git a/server/data/data-memory/src/main/java/org/apache/james/vacation/memory/MemoryNotificationRegistry.java b/server/data/data-memory/src/main/java/org/apache/james/vacation/memory/MemoryNotificationRegistry.java index eb10cff902c..36b449d5777 100644 --- a/server/data/data-memory/src/main/java/org/apache/james/vacation/memory/MemoryNotificationRegistry.java +++ b/server/data/data-memory/src/main/java/org/apache/james/vacation/memory/MemoryNotificationRegistry.java @@ -84,7 +84,7 @@ public Mono isRegistered(AccountId accountId, RecipientId recipientId) } private boolean isStrictlyBefore(ZonedDateTime currentTime, ZonedDateTime registrationEnd) { - return ! currentTime.isAfter(registrationEnd); + return currentTime.isBefore(registrationEnd); } @Override From 472cc1c8fcead99e2c962bb43a53c8196bda4701 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Fri, 16 Feb 2024 08:45:14 +0700 Subject: [PATCH 230/341] JAMES-2586 [Documentation] Using pg_stat_statements extension for track the stats of the SQL statement execution --- server/apps/postgres-app/README.adoc | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/server/apps/postgres-app/README.adoc b/server/apps/postgres-app/README.adoc index f30f9c48516..c3c9e084d52 100644 --- a/server/apps/postgres-app/README.adoc +++ b/server/apps/postgres-app/README.adoc @@ -132,4 +132,26 @@ To clean up some specific data, that will never be used again after a long time, The never used data are: - mailbox_change - email_change -- vacation_notification_registry \ No newline at end of file +- vacation_notification_registry + +## Development + +### How to track the stats of the statement execution + +Using the [`pg_stat_statements` extension](https://www.postgresql.org/docs/current/pgstatstatements.html), you can track the stats of the statement execution. To install it, you can execute the following SQL query: + +```sql +create extension if not exists pg_stat_statements; +alter system set shared_preload_libraries='pg_stat_statements'; + +-- restart postgres +-- optional +alter system set pg_stat_statements.max = 100000; +alter system set pg_stat_statements.track = 'all'; +``` + +Then you can query the stats of the statement execution by executing the following SQL query: + +```sql +select query, mean_exec_time, total_exec_time, calls from pg_stat_statements order by total_exec_time desc; +``` From 27d82bc6a515914c2d277390ce864e1fc901417a Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Sun, 18 Feb 2024 11:41:02 +0700 Subject: [PATCH 231/341] JAMES-2586 Avoid Using COUNT() in SQL When You Could Use EXISTS() --- .../backends/postgres/utils/PostgresExecutor.java | 9 +++++++++ .../james/events/PostgresEventDeadLetters.java | 6 ++---- .../mailbox/postgres/DeleteMessageListener.java | 7 +++---- .../postgres/mail/dao/PostgresMailboxDAO.java | 12 ++++-------- .../postgres/mail/dao/PostgresMailboxMessageDAO.java | 9 ++++----- .../PostgresPushSubscriptionDAO.java | 10 ++++------ .../james/sieve/postgres/PostgresSieveScriptDAO.java | 7 +++---- .../apache/james/user/postgres/PostgresUsersDAO.java | 7 ++++++- .../postgres/PostgresNotificationRegistryDAO.java | 5 ++--- 9 files changed, 37 insertions(+), 35 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index 889c8151153..37d3726e140 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -19,6 +19,9 @@ package org.apache.james.backends.postgres.utils; +import static org.jooq.impl.DSL.exists; +import static org.jooq.impl.DSL.field; + import java.time.Duration; import java.util.Optional; import java.util.function.Function; @@ -32,6 +35,7 @@ import org.jooq.Record; import org.jooq.Record1; import org.jooq.SQLDialect; +import org.jooq.SelectConditionStep; import org.jooq.conf.Settings; import org.jooq.conf.StatementType; import org.jooq.impl.DSL; @@ -129,6 +133,11 @@ public Mono executeCount(Function>> q .map(Record1::value1); } + public Mono executeExists(Function> queryFunction) { + return executeRow(dslContext -> Mono.from(dslContext.select(field(exists(queryFunction.apply(dslContext)))))) + .map(record -> record.get(0, Boolean.class)); + } + public Mono executeReturnAffectedRowsCount(Function> queryFunction) { return dslContext() .flatMap(queryFunction) diff --git a/event-bus/postgres/src/main/java/org/apache/james/events/PostgresEventDeadLetters.java b/event-bus/postgres/src/main/java/org/apache/james/events/PostgresEventDeadLetters.java index be6db0c7bed..540400266a3 100644 --- a/event-bus/postgres/src/main/java/org/apache/james/events/PostgresEventDeadLetters.java +++ b/event-bus/postgres/src/main/java/org/apache/james/events/PostgresEventDeadLetters.java @@ -111,10 +111,8 @@ public Flux groupsWithFailedEvents() { @Override public Mono containEvents() { - return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext - .select(INSERTION_ID) + return postgresExecutor.executeExists(dslContext -> dslContext.selectOne() .from(TABLE_NAME) - .limit(1))) - .hasElement(); + .where()); } } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java index 79f739b0e0c..22826c0cbfe 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java @@ -38,6 +38,7 @@ import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresThreadDAO; +import org.apache.james.util.FunctionalUtils; import org.apache.james.util.ReactorUtils; import org.reactivestreams.Publisher; @@ -157,10 +158,8 @@ private Mono deleteBodyBlob(PostgresMessageId id, PostgresMessageDAO postg } private Mono isUnreferenced(PostgresMessageId id, PostgresMailboxMessageDAO postgresMailboxMessageDAO) { - return postgresMailboxMessageDAO.countByMessageId(id) - .filter(count -> count == 0) - .map(count -> true) - .defaultIfEmpty(false); + return postgresMailboxMessageDAO.existsByMessageId(id) + .map(FunctionalUtils.negate()); } private Mono deleteAttachment(PostgresMessageId messageId, PostgresAttachmentDAO attachmentDAO) { diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java index cedcfb12afb..e4f1a6f71d6 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java @@ -30,7 +30,6 @@ import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.TABLE_NAME; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.USER_NAME; import static org.jooq.impl.DSL.coalesce; -import static org.jooq.impl.DSL.count; import java.util.LinkedHashMap; import java.util.Map; @@ -199,13 +198,10 @@ public Flux findMailboxWithPathLike(MailboxQuery.UserBound quer public Mono hasChildren(Mailbox mailbox, char delimiter) { String name = mailbox.getName() + delimiter + SQL_WILDCARD_CHAR; - return postgresExecutor.executeRows(dsl -> Flux.from(dsl.select(count()).from(TABLE_NAME) - .where(MAILBOX_NAME.like(name) - .and(USER_NAME.eq(mailbox.getUser().asString())) - .and(MAILBOX_NAMESPACE.eq(mailbox.getNamespace()))))) - .map(record -> record.get(0, Integer.class)) - .filter(count -> count > 0) - .hasElements(); + return postgresExecutor.executeExists(dsl -> dsl.selectOne().from(TABLE_NAME) + .where(MAILBOX_NAME.like(name) + .and(USER_NAME.eq(mailbox.getUser().asString())) + .and(MAILBOX_NAMESPACE.eq(mailbox.getNamespace())))); } public Flux getAll() { diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java index fd51fcc2fb3..e3018fc014f 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java @@ -385,11 +385,10 @@ public Flux listNotDeletedUids(PostgresMailboxId mailboxId, MessageR .map(RECORD_TO_MESSAGE_UID_FUNCTION); } - public Mono countByMessageId(PostgresMessageId messageId) { - return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.selectCount() - .from(TABLE_NAME) - .where(MESSAGE_ID.eq(messageId.asUuid())))) - .map(record -> record.get(0, Long.class)); + public Mono existsByMessageId(PostgresMessageId messageId) { + return postgresExecutor.executeExists(dslContext -> dslContext.selectOne() + .from(TABLE_NAME) + .where(MESSAGE_ID.eq(messageId.asUuid()))); } public Flux findMessagesMetadata(PostgresMailboxId mailboxId, MessageRange range) { diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionDAO.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionDAO.java index 69f0abf41c5..0c611859c28 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionDAO.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionDAO.java @@ -136,12 +136,10 @@ public Mono updateExpireTime(Username username, PushSubscriptionI } public Mono existDeviceClientId(Username username, String deviceClientId) { - return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select(PushSubscriptionTable.DEVICE_CLIENT_ID) - .from(PushSubscriptionTable.TABLE_NAME) - .where(PushSubscriptionTable.USER.eq(username.asString())) - .and(PushSubscriptionTable.DEVICE_CLIENT_ID.eq(deviceClientId)) - .limit(1))) - .hasElement(); + return postgresExecutor.executeExists(dslContext -> dslContext.selectOne() + .from(PushSubscriptionTable.TABLE_NAME) + .where(PushSubscriptionTable.USER.eq(username.asString())) + .and(PushSubscriptionTable.DEVICE_CLIENT_ID.eq(deviceClientId))); } private PushSubscription recordAsPushSubscription(Record record) { diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveScriptDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveScriptDAO.java index a1d8b93b496..88ff9c40342 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveScriptDAO.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveScriptDAO.java @@ -94,11 +94,10 @@ public Mono getIsActive(Username username, ScriptName scriptName) { } public Mono scriptExists(Username username, ScriptName scriptName) { - return postgresExecutor.executeCount(dslContext -> Mono.from(dslContext.selectCount() + return postgresExecutor.executeExists(dslContext -> dslContext.selectOne() .from(TABLE_NAME) - .where(USERNAME.eq(username.asString()), - SCRIPT_NAME.eq(scriptName.getValue())))) - .map(count -> count > 0); + .where(USERNAME.eq(username.asString()), + SCRIPT_NAME.eq(scriptName.getValue()))); } public Flux getScripts(Username username) { diff --git a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java index d0467bf847f..0b58bf0b9be 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java @@ -116,7 +116,12 @@ public void removeUser(Username name) throws UsersRepositoryException { @Override public boolean contains(Username name) { - return getUserByName(name).isPresent(); + return containsReactive(name).block(); + } + + @Override + public Mono containsReactive(Username name) { + return postgresExecutor.executeExists(dsl -> dsl.selectOne().from(TABLE_NAME).where(USERNAME.eq(name.asString()))); } @Override diff --git a/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresNotificationRegistryDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresNotificationRegistryDAO.java index 4638ad9805a..8ae01ce36f2 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresNotificationRegistryDAO.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresNotificationRegistryDAO.java @@ -57,12 +57,11 @@ public Mono register(AccountId accountId, RecipientId recipientId, Optiona public Mono isRegistered(AccountId accountId, RecipientId recipientId) { LocalDateTime currentUTCTime = zonedDateTimeProvider.get().withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime(); - return postgresExecutor.executeRow(dsl -> Mono.from(dsl.select(ACCOUNT_ID) + return postgresExecutor.executeExists(dsl -> dsl.selectOne() .from(TABLE_NAME) .where(ACCOUNT_ID.eq(accountId.getIdentifier()), RECIPIENT_ID.eq(recipientId.getAsString()), - EXPIRY_DATE.ge(currentUTCTime).or(EXPIRY_DATE.isNull())))) - .hasElement(); + EXPIRY_DATE.ge(currentUTCTime).or(EXPIRY_DATE.isNull()))); } public Mono flush(AccountId accountId) { From 4adfbb090deb5be71e6f3abcf4d45468071777d0 Mon Sep 17 00:00:00 2001 From: hung phan Date: Wed, 7 Feb 2024 17:54:51 +0700 Subject: [PATCH 232/341] JAMES-2586 Implement PostgresEventStore --- event-sourcing/event-store-postgres/pom.xml | 103 +++++++++++++++ .../postgres/PostgresEventStore.java | 81 ++++++++++++ .../postgres/PostgresEventStoreDAO.java | 124 ++++++++++++++++++ .../postgres/PostgresEventStoreModule.java | 63 +++++++++ .../PostgresEventSourcingSystemTest.java | 27 ++++ .../postgres/PostgresEventStoreExtension.java | 72 ++++++++++ ...tgresEventStoreExtensionForTestEvents.java | 29 ++++ .../postgres/PostgresEventStoreTest.java | 65 +++++++++ event-sourcing/pom.xml | 1 + .../apache/james/PostgresJamesServerMain.java | 4 +- .../org/apache/james/GuiceJamesServer.java | 2 +- .../container/guice/postgres-common/pom.xml | 5 + .../data/PostgresEventStoreModule.java | 54 ++++++++ server/data/data-jmap-postgres/pom.xml | 4 +- ...ngFilteringManagementNoProjectionTest.java | 46 +++++++ ...sEventSourcingFilteringManagementTest.java | 14 +- 16 files changed, 686 insertions(+), 8 deletions(-) create mode 100644 event-sourcing/event-store-postgres/pom.xml create mode 100644 event-sourcing/event-store-postgres/src/main/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStore.java create mode 100644 event-sourcing/event-store-postgres/src/main/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreDAO.java create mode 100644 event-sourcing/event-store-postgres/src/main/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreModule.java create mode 100644 event-sourcing/event-store-postgres/src/test/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventSourcingSystemTest.java create mode 100644 event-sourcing/event-store-postgres/src/test/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreExtension.java create mode 100644 event-sourcing/event-store-postgres/src/test/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreExtensionForTestEvents.java create mode 100644 event-sourcing/event-store-postgres/src/test/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreTest.java create mode 100644 server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresEventStoreModule.java create mode 100644 server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/filtering/PostgresEventSourcingFilteringManagementNoProjectionTest.java diff --git a/event-sourcing/event-store-postgres/pom.xml b/event-sourcing/event-store-postgres/pom.xml new file mode 100644 index 00000000000..daf4273b9c6 --- /dev/null +++ b/event-sourcing/event-store-postgres/pom.xml @@ -0,0 +1,103 @@ + + + + 4.0.0 + + + org.apache.james + event-sourcing + 3.9.0-SNAPSHOT + + + event-sourcing-event-store-postgres + + Apache James :: Event sourcing :: Event Store :: Postgres + Postgres implementation for James Event Store + + + + ${james.groupId} + apache-james-backends-postgres + + + ${james.groupId} + apache-james-backends-postgres + test-jar + test + + + ${james.groupId} + event-sourcing-core + test-jar + test + + + ${james.groupId} + event-sourcing-event-store-api + + + ${james.groupId} + event-sourcing-event-store-api + test-jar + test + + + ${james.groupId} + event-sourcing-pojo + test-jar + test + + + ${james.groupId} + james-json + + + ${james.groupId} + james-server-guice-common + test-jar + test + + + net.javacrumbs.json-unit + json-unit-assertj + test + + + org.assertj + assertj-core + test + + + org.junit.jupiter + junit-jupiter-engine + test + + + org.mockito + mockito-core + test + + + org.testcontainers + postgresql + test + + + diff --git a/event-sourcing/event-store-postgres/src/main/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStore.java b/event-sourcing/event-store-postgres/src/main/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStore.java new file mode 100644 index 00000000000..237744e9cbd --- /dev/null +++ b/event-sourcing/event-store-postgres/src/main/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStore.java @@ -0,0 +1,81 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.eventsourcing.eventstore.postgres; + +import static org.apache.james.backends.postgres.utils.PostgresUtils.UNIQUE_CONSTRAINT_VIOLATION_PREDICATE; + +import java.util.List; +import java.util.Optional; + +import javax.inject.Inject; + +import org.apache.james.eventsourcing.AggregateId; +import org.apache.james.eventsourcing.Event; +import org.apache.james.eventsourcing.EventId; +import org.apache.james.eventsourcing.eventstore.EventStore; +import org.apache.james.eventsourcing.eventstore.EventStoreFailedException; +import org.apache.james.eventsourcing.eventstore.History; +import org.reactivestreams.Publisher; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; + +import reactor.core.publisher.Mono; +import scala.jdk.javaapi.CollectionConverters; + +public class PostgresEventStore implements EventStore { + private final PostgresEventStoreDAO eventStoreDAO; + + @Inject + public PostgresEventStore(PostgresEventStoreDAO eventStoreDAO) { + this.eventStoreDAO = eventStoreDAO; + } + + @Override + public Publisher appendAll(scala.collection.Iterable scalaEvents) { + if (scalaEvents.isEmpty()) { + return Mono.empty(); + } + Preconditions.checkArgument(Event.belongsToSameAggregate(scalaEvents)); + List events = ImmutableList.copyOf(CollectionConverters.asJava(scalaEvents)); + Optional snapshotId = events.stream().filter(Event::isASnapshot).map(Event::eventId).findFirst(); + return eventStoreDAO.appendAll(events, snapshotId) + .onErrorMap(UNIQUE_CONSTRAINT_VIOLATION_PREDICATE, + e -> new EventStoreFailedException("Concurrent update to the EventStore detected")); + } + + @Override + public Publisher getEventsOfAggregate(AggregateId aggregateId) { + return eventStoreDAO.getSnapshot(aggregateId) + .flatMap(snapshotId -> eventStoreDAO.getEventsOfAggregate(aggregateId, snapshotId)) + .flatMap(history -> { + if (history.getEventsJava().isEmpty()) { + return Mono.from(eventStoreDAO.getEventsOfAggregate(aggregateId)); + } else { + return Mono.just(history); + } + }).defaultIfEmpty(History.empty()); + } + + @Override + public Publisher remove(AggregateId aggregateId) { + return eventStoreDAO.delete(aggregateId); + } +} diff --git a/event-sourcing/event-store-postgres/src/main/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreDAO.java b/event-sourcing/event-store-postgres/src/main/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreDAO.java new file mode 100644 index 00000000000..8cb2afb5863 --- /dev/null +++ b/event-sourcing/event-store-postgres/src/main/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreDAO.java @@ -0,0 +1,124 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.eventsourcing.eventstore.postgres; + +import static org.apache.james.eventsourcing.eventstore.postgres.PostgresEventStoreModule.PostgresEventStoreTable.AGGREGATE_ID; +import static org.apache.james.eventsourcing.eventstore.postgres.PostgresEventStoreModule.PostgresEventStoreTable.EVENT; +import static org.apache.james.eventsourcing.eventstore.postgres.PostgresEventStoreModule.PostgresEventStoreTable.EVENT_ID; +import static org.apache.james.eventsourcing.eventstore.postgres.PostgresEventStoreModule.PostgresEventStoreTable.SNAPSHOT; +import static org.apache.james.eventsourcing.eventstore.postgres.PostgresEventStoreModule.PostgresEventStoreTable.TABLE_NAME; + +import java.util.List; +import java.util.Optional; + +import javax.inject.Inject; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.eventsourcing.AggregateId; +import org.apache.james.eventsourcing.Event; +import org.apache.james.eventsourcing.EventId; +import org.apache.james.eventsourcing.eventstore.History; +import org.apache.james.eventsourcing.eventstore.JsonEventSerializer; +import org.apache.james.util.ReactorUtils; +import org.jooq.JSON; +import org.jooq.Record; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.google.common.collect.ImmutableList; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import scala.jdk.javaapi.CollectionConverters; + +public class PostgresEventStoreDAO { + private PostgresExecutor postgresExecutor; + private JsonEventSerializer jsonEventSerializer; + + @Inject + public PostgresEventStoreDAO(PostgresExecutor postgresExecutor, JsonEventSerializer jsonEventSerializer) { + this.postgresExecutor = postgresExecutor; + this.jsonEventSerializer = jsonEventSerializer; + } + + public Mono appendAll(List events, Optional lastSnapshot) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME, AGGREGATE_ID, EVENT_ID, EVENT) + .valuesOfRecords(events.stream().map(event -> dslContext.newRecord(AGGREGATE_ID, EVENT_ID, EVENT) + .value1(event.getAggregateId().asAggregateKey()) + .value2(event.eventId().serialize()) + .value3(convertToJooqJson(event))) + .collect(ImmutableList.toImmutableList())))) + .then(lastSnapshot.map(eventId -> insertSnapshot(events.iterator().next().getAggregateId(), eventId)).orElse(Mono.empty())); + } + + private Mono insertSnapshot(AggregateId aggregateId, EventId snapshotId) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.update(TABLE_NAME) + .set(SNAPSHOT, snapshotId.serialize()) + .where(AGGREGATE_ID.eq(aggregateId.asAggregateKey())))); + } + + private JSON convertToJooqJson(Event event) { + try { + return JSON.json(jsonEventSerializer.serialize(event)); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + public Mono getSnapshot(AggregateId aggregateId) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.select(SNAPSHOT) + .from(TABLE_NAME) + .where(AGGREGATE_ID.eq(aggregateId.asAggregateKey())) + .limit(1))) + .map(record -> EventId.fromSerialized(Optional.ofNullable(record.get(SNAPSHOT)).orElse(0))); + } + + public Mono getEventsOfAggregate(AggregateId aggregateId, EventId snapshotId) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.selectFrom(TABLE_NAME) + .where(AGGREGATE_ID.eq(aggregateId.asAggregateKey())) + .and(EVENT_ID.greaterOrEqual(snapshotId.value())) + .orderBy(EVENT_ID))) + .concatMap(this::toEvent) + .collect(ImmutableList.toImmutableList()) + .map(this::asHistory); + } + + public Mono getEventsOfAggregate(AggregateId aggregateId) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.selectFrom(TABLE_NAME) + .where(AGGREGATE_ID.eq(aggregateId.asAggregateKey())) + .orderBy(EVENT_ID))) + .concatMap(this::toEvent) + .collect(ImmutableList.toImmutableList()) + .map(this::asHistory); + } + + public Mono delete(AggregateId aggregateId) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(TABLE_NAME) + .where(AGGREGATE_ID.eq(aggregateId.asAggregateKey())))); + } + + private History asHistory(List events) { + return History.of(CollectionConverters.asScala(events).toList()); + } + + private Mono toEvent(Record record) { + return Mono.fromCallable(() -> jsonEventSerializer.deserialize(record.get(EVENT).data())) + .subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER); + } +} diff --git a/event-sourcing/event-store-postgres/src/main/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreModule.java b/event-sourcing/event-store-postgres/src/main/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreModule.java new file mode 100644 index 00000000000..f90eb5c1cc1 --- /dev/null +++ b/event-sourcing/event-store-postgres/src/main/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreModule.java @@ -0,0 +1,63 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.eventsourcing.eventstore.postgres; + +import static org.apache.james.eventsourcing.eventstore.postgres.PostgresEventStoreModule.PostgresEventStoreTable.INDEX; +import static org.apache.james.eventsourcing.eventstore.postgres.PostgresEventStoreModule.PostgresEventStoreTable.TABLE; + +import org.apache.james.backends.postgres.PostgresIndex; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.JSON; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresEventStoreModule { + interface PostgresEventStoreTable { + Table TABLE_NAME = DSL.table("event_store"); + + Field AGGREGATE_ID = DSL.field("aggregate_id", SQLDataType.VARCHAR.notNull()); + Field EVENT_ID = DSL.field("event_id", SQLDataType.INTEGER.notNull()); + Field SNAPSHOT = DSL.field("snapshot", SQLDataType.INTEGER); + Field EVENT = DSL.field("event", SQLDataType.JSON.notNull()); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(AGGREGATE_ID) + .column(EVENT_ID) + .column(SNAPSHOT) + .column(EVENT) + .constraint(DSL.primaryKey(AGGREGATE_ID, EVENT_ID)))) + .disableRowLevelSecurity() + .build(); + + PostgresIndex INDEX = PostgresIndex.name("event_store_aggregate_id_index") + .createIndexStep((dslContext, indexName) -> dslContext.createIndexIfNotExists(indexName) + .on(TABLE_NAME, AGGREGATE_ID)); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(TABLE) + .addIndex(INDEX) + .build(); +} diff --git a/event-sourcing/event-store-postgres/src/test/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventSourcingSystemTest.java b/event-sourcing/event-store-postgres/src/test/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventSourcingSystemTest.java new file mode 100644 index 00000000000..1faf9842e2d --- /dev/null +++ b/event-sourcing/event-store-postgres/src/test/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventSourcingSystemTest.java @@ -0,0 +1,27 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.eventsourcing.eventstore.postgres; + +import org.apache.james.eventsourcing.EventSourcingSystemTest; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(PostgresEventStoreExtensionForTestEvents.class) +public class PostgresEventSourcingSystemTest implements EventSourcingSystemTest { +} diff --git a/event-sourcing/event-store-postgres/src/test/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreExtension.java b/event-sourcing/event-store-postgres/src/test/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreExtension.java new file mode 100644 index 00000000000..652d8af6a45 --- /dev/null +++ b/event-sourcing/event-store-postgres/src/test/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreExtension.java @@ -0,0 +1,72 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.eventsourcing.eventstore.postgres; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.eventsourcing.eventstore.EventStore; +import org.apache.james.eventsourcing.eventstore.JsonEventSerializer; +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junit.jupiter.api.extension.ParameterResolver; + +public class PostgresEventStoreExtension implements AfterAllCallback, BeforeAllCallback, AfterEachCallback, BeforeEachCallback, ParameterResolver { + private PostgresExtension postgresExtension; + private JsonEventSerializer jsonEventSerializer; + + public PostgresEventStoreExtension(JsonEventSerializer jsonEventSerializer) { + this.jsonEventSerializer = jsonEventSerializer; + this.postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresEventStoreModule.MODULE); + } + + @Override + public void afterAll(ExtensionContext extensionContext) { + postgresExtension.afterAll(extensionContext); + } + + @Override + public void afterEach(ExtensionContext extensionContext) { + postgresExtension.afterEach(extensionContext); + } + + @Override + public void beforeAll(ExtensionContext extensionContext) throws Exception { + postgresExtension.beforeAll(extensionContext); + } + + @Override + public void beforeEach(ExtensionContext extensionContext) { + postgresExtension.beforeEach(extensionContext); + } + + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + return parameterContext.getParameter().getType() == EventStore.class; + } + + @Override + public PostgresEventStore resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { + return new PostgresEventStore(new PostgresEventStoreDAO(postgresExtension.getPostgresExecutor(), jsonEventSerializer)); + } +} diff --git a/event-sourcing/event-store-postgres/src/test/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreExtensionForTestEvents.java b/event-sourcing/event-store-postgres/src/test/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreExtensionForTestEvents.java new file mode 100644 index 00000000000..dcebb2932ad --- /dev/null +++ b/event-sourcing/event-store-postgres/src/test/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreExtensionForTestEvents.java @@ -0,0 +1,29 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.eventsourcing.eventstore.postgres; + +import org.apache.james.eventsourcing.eventstore.JsonEventSerializer; +import org.apache.james.eventsourcing.eventstore.dto.TestEventDTOModules; + +public class PostgresEventStoreExtensionForTestEvents extends PostgresEventStoreExtension { + public PostgresEventStoreExtensionForTestEvents() { + super(JsonEventSerializer.forModules(TestEventDTOModules.TEST_TYPE(), TestEventDTOModules.SNAPSHOT_TYPE()).withoutNestedType()); + } +} diff --git a/event-sourcing/event-store-postgres/src/test/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreTest.java b/event-sourcing/event-store-postgres/src/test/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreTest.java new file mode 100644 index 00000000000..a1a00f8a3d4 --- /dev/null +++ b/event-sourcing/event-store-postgres/src/test/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreTest.java @@ -0,0 +1,65 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.eventsourcing.eventstore.postgres; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.apache.james.eventsourcing.Event; +import org.apache.james.eventsourcing.EventId; +import org.apache.james.eventsourcing.TestEvent; +import org.apache.james.eventsourcing.eventstore.EventStore; +import org.apache.james.eventsourcing.eventstore.EventStoreContract; +import org.apache.james.eventsourcing.eventstore.History; +import org.apache.james.eventsourcing.eventstore.dto.SnapshotEvent; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import reactor.core.publisher.Mono; + +@ExtendWith(PostgresEventStoreExtensionForTestEvents.class) +public class PostgresEventStoreTest implements EventStoreContract { + @Test + void getEventsOfAggregateShouldResumeFromSnapshot(EventStore testee) { + Event event1 = new TestEvent(EventId.first(), EventStoreContract.AGGREGATE_1(), "first"); + Event event2 = new SnapshotEvent(EventId.first().next(), EventStoreContract.AGGREGATE_1(), "second"); + Event event3 = new TestEvent(EventId.first().next().next(), EventStoreContract.AGGREGATE_1(), "third"); + + Mono.from(testee.append(event1)).block(); + Mono.from(testee.append(event2)).block(); + Mono.from(testee.append(event3)).block(); + + assertThat(Mono.from(testee.getEventsOfAggregate(EventStoreContract.AGGREGATE_1())).block()) + .isEqualTo(History.of(event2, event3)); + } + + @Test + void getEventsOfAggregateShouldResumeFromLatestSnapshot(EventStore testee) { + Event event1 = new SnapshotEvent(EventId.first(), EventStoreContract.AGGREGATE_1(), "first"); + Event event2 = new TestEvent(EventId.first().next(), EventStoreContract.AGGREGATE_1(), "second"); + Event event3 = new SnapshotEvent(EventId.first().next().next(), EventStoreContract.AGGREGATE_1(), "third"); + + Mono.from(testee.append(event1)).block(); + Mono.from(testee.append(event2)).block(); + Mono.from(testee.append(event3)).block(); + + assertThat(Mono.from(testee.getEventsOfAggregate(EventStoreContract.AGGREGATE_1())).block()) + .isEqualTo(History.of(event3)); + } +} \ No newline at end of file diff --git a/event-sourcing/pom.xml b/event-sourcing/pom.xml index f14f296631e..836edca1473 100644 --- a/event-sourcing/pom.xml +++ b/event-sourcing/pom.xml @@ -37,6 +37,7 @@ event-store-api event-store-cassandra event-store-memory + event-store-postgres diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index 15660762749..1062367e597 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -33,13 +33,13 @@ import org.apache.james.modules.data.PostgresDataJmapModule; import org.apache.james.modules.data.PostgresDataModule; import org.apache.james.modules.data.PostgresDelegationStoreModule; +import org.apache.james.modules.data.PostgresEventStoreModule; import org.apache.james.modules.data.PostgresUsersRepositoryModule; import org.apache.james.modules.data.PostgresVacationModule; import org.apache.james.modules.data.SievePostgresRepositoryModules; import org.apache.james.modules.event.JMAPEventBusModule; import org.apache.james.modules.event.RabbitMQEventBusModule; import org.apache.james.modules.events.PostgresDeadLetterModule; -import org.apache.james.modules.eventstore.MemoryEventStoreModule; import org.apache.james.modules.mailbox.DefaultEventModule; import org.apache.james.modules.mailbox.PostgresDeletedMessageVaultModule; import org.apache.james.modules.mailbox.PostgresMailboxModule; @@ -113,7 +113,7 @@ public class PostgresJamesServerMain implements JamesServerMain { new MailboxModule(), new SievePostgresRepositoryModules(), new TaskManagerModule(), - new MemoryEventStoreModule(), + new PostgresEventStoreModule(), new TikaMailboxModule(), new PostgresDLPConfigurationStoreModule(), new PostgresVacationModule()); diff --git a/server/container/guice/common/src/main/java/org/apache/james/GuiceJamesServer.java b/server/container/guice/common/src/main/java/org/apache/james/GuiceJamesServer.java index 98fcb294230..ab32d103292 100644 --- a/server/container/guice/common/src/main/java/org/apache/james/GuiceJamesServer.java +++ b/server/container/guice/common/src/main/java/org/apache/james/GuiceJamesServer.java @@ -92,8 +92,8 @@ public void start() throws Exception { preDestroy = injector.getInstance(Key.get(new TypeLiteral>() { })); injector.getInstance(ConfigurationSanitizingPerformer.class).sanitize(); - injector.getInstance(StartUpChecksPerformer.class).performCheck(); injector.getInstance(InitializationOperations.class).initModules(); + injector.getInstance(StartUpChecksPerformer.class).performCheck(); isStartedProbe.notifyStarted(); LOGGER.info("JAMES server started in: {}ms", stopwatch.elapsed(TimeUnit.MILLISECONDS)); } catch (Throwable e) { diff --git a/server/container/guice/postgres-common/pom.xml b/server/container/guice/postgres-common/pom.xml index 9bf67159596..5cc0f9d2b30 100644 --- a/server/container/guice/postgres-common/pom.xml +++ b/server/container/guice/postgres-common/pom.xml @@ -48,6 +48,11 @@ ${james.groupId} dead-letter-postgres + + ${james.groupId} + event-sourcing-event-store-postgres + ${project.version} + ${james.groupId} james-server-data-file diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresEventStoreModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresEventStoreModule.java new file mode 100644 index 00000000000..fefe5aa309a --- /dev/null +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresEventStoreModule.java @@ -0,0 +1,54 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.data; + +import java.util.Set; + +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.eventsourcing.Event; +import org.apache.james.eventsourcing.eventstore.EventNestedTypes; +import org.apache.james.eventsourcing.eventstore.EventStore; +import org.apache.james.eventsourcing.eventstore.dto.EventDTO; +import org.apache.james.eventsourcing.eventstore.dto.EventDTOModule; +import org.apache.james.eventsourcing.eventstore.postgres.PostgresEventStore; +import org.apache.james.json.DTO; +import org.apache.james.json.DTOModule; + +import com.google.common.collect.ImmutableSet; +import com.google.inject.AbstractModule; +import com.google.inject.Scopes; +import com.google.inject.TypeLiteral; +import com.google.inject.multibindings.Multibinder; +import com.google.inject.name.Names; + +public class PostgresEventStoreModule extends AbstractModule { + @Override + protected void configure() { + bind(PostgresEventStore.class).in(Scopes.SINGLETON); + bind(EventStore.class).to(PostgresEventStore.class); + + Multibinder postgresDataDefinitions = Multibinder.newSetBinder(binder(), PostgresModule.class); + postgresDataDefinitions.addBinding().toInstance(org.apache.james.eventsourcing.eventstore.postgres.PostgresEventStoreModule.MODULE); + + bind(new TypeLiteral>>() {}).annotatedWith(Names.named(EventNestedTypes.EVENT_NESTED_TYPES_INJECTION_NAME)) + .toInstance(ImmutableSet.of()); + Multibinder.newSetBinder(binder(), new TypeLiteral>() {}); + } +} diff --git a/server/data/data-jmap-postgres/pom.xml b/server/data/data-jmap-postgres/pom.xml index 18eafbe8cf1..ffb09f7ff0a 100644 --- a/server/data/data-jmap-postgres/pom.xml +++ b/server/data/data-jmap-postgres/pom.xml @@ -70,8 +70,8 @@ ${james.groupId} - event-sourcing-event-store-memory - test + event-sourcing-event-store-postgres + ${project.version} ${james.groupId} diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/filtering/PostgresEventSourcingFilteringManagementNoProjectionTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/filtering/PostgresEventSourcingFilteringManagementNoProjectionTest.java new file mode 100644 index 00000000000..86041e4f84f --- /dev/null +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/filtering/PostgresEventSourcingFilteringManagementNoProjectionTest.java @@ -0,0 +1,46 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.filtering; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.eventsourcing.eventstore.EventStore; +import org.apache.james.eventsourcing.eventstore.JsonEventSerializer; +import org.apache.james.eventsourcing.eventstore.postgres.PostgresEventStore; +import org.apache.james.eventsourcing.eventstore.postgres.PostgresEventStoreDAO; +import org.apache.james.eventsourcing.eventstore.postgres.PostgresEventStoreModule; +import org.apache.james.jmap.api.filtering.FilteringManagement; +import org.apache.james.jmap.api.filtering.FilteringManagementContract; +import org.apache.james.jmap.api.filtering.FilteringRuleSetDefineDTOModules; +import org.apache.james.jmap.api.filtering.impl.EventSourcingFilteringManagement; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresEventSourcingFilteringManagementNoProjectionTest implements FilteringManagementContract { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresEventStoreModule.MODULE); + + @Override + public FilteringManagement instantiateFilteringManagement() { + EventStore eventStore = new PostgresEventStore(new PostgresEventStoreDAO(postgresExtension.getPostgresExecutor(), + JsonEventSerializer.forModules(FilteringRuleSetDefineDTOModules.FILTERING_RULE_SET_DEFINED, + FilteringRuleSetDefineDTOModules.FILTERING_INCREMENT).withoutNestedType())); + return new EventSourcingFilteringManagement(eventStore, + new EventSourcingFilteringManagement.NoReadProjection(eventStore)); + } +} diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/filtering/PostgresEventSourcingFilteringManagementTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/filtering/PostgresEventSourcingFilteringManagementTest.java index fa703a248e9..49d84230944 100644 --- a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/filtering/PostgresEventSourcingFilteringManagementTest.java +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/filtering/PostgresEventSourcingFilteringManagementTest.java @@ -20,19 +20,27 @@ package org.apache.james.jmap.postgres.filtering; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.eventsourcing.eventstore.memory.InMemoryEventStore; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.eventsourcing.eventstore.JsonEventSerializer; +import org.apache.james.eventsourcing.eventstore.postgres.PostgresEventStore; +import org.apache.james.eventsourcing.eventstore.postgres.PostgresEventStoreDAO; +import org.apache.james.eventsourcing.eventstore.postgres.PostgresEventStoreModule; import org.apache.james.jmap.api.filtering.FilteringManagement; import org.apache.james.jmap.api.filtering.FilteringManagementContract; +import org.apache.james.jmap.api.filtering.FilteringRuleSetDefineDTOModules; import org.apache.james.jmap.api.filtering.impl.EventSourcingFilteringManagement; import org.junit.jupiter.api.extension.RegisterExtension; public class PostgresEventSourcingFilteringManagementTest implements FilteringManagementContract { @RegisterExtension - static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresFilteringProjectionModule.MODULE); + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresModule.aggregateModules(PostgresFilteringProjectionModule.MODULE, + PostgresEventStoreModule.MODULE)); @Override public FilteringManagement instantiateFilteringManagement() { - return new EventSourcingFilteringManagement(new InMemoryEventStore(), + return new EventSourcingFilteringManagement(new PostgresEventStore(new PostgresEventStoreDAO(postgresExtension.getPostgresExecutor(), + JsonEventSerializer.forModules(FilteringRuleSetDefineDTOModules.FILTERING_RULE_SET_DEFINED, + FilteringRuleSetDefineDTOModules.FILTERING_INCREMENT).withoutNestedType())), new PostgresFilteringProjection(new PostgresFilteringProjectionDAO(postgresExtension.getPostgresExecutor()))); } } From cc4bab57d05328835f32514481a51019b2c4f381 Mon Sep 17 00:00:00 2001 From: hungphan227 <45198168+hungphan227@users.noreply.github.com> Date: Wed, 21 Feb 2024 15:12:16 +0700 Subject: [PATCH 233/341] JAMES-2586 Implement PostgresEmailQueryView (#2007) --- .../modules/data/CassandraJmapModule.java | 4 + .../modules/data/MemoryDataJmapModule.java | 4 + .../modules/data/PostgresDataJmapModule.java | 10 +- .../PostgresDataJMapAggregateModule.java | 4 +- .../projections/PostgresEmailQueryView.java | 88 +++++++++++ .../PostgresEmailQueryViewDAO.java | 143 ++++++++++++++++++ .../PostgresEmailQueryViewManager.java | 41 +++++ .../PostgresEmailQueryViewModule.java | 81 ++++++++++ .../PostgresEmailQueryViewManagerRLSTest.java | 73 +++++++++ .../PostgresEmailQueryViewTest.java | 71 +++++++++ .../DefaultEmailQueryViewManager.java | 38 +++++ .../projections/EmailQueryViewManager.java | 26 ++++ .../projections/EmailQueryViewContract.java | 2 +- .../draft/methods/GetMessageListMethod.java | 0 .../event/PopulateEmailQueryViewListener.java | 23 +-- .../james/jmap/method/EmailQueryMethod.scala | 19 ++- .../PopulateEmailQueryViewListenerTest.java | 24 +-- 17 files changed, 617 insertions(+), 34 deletions(-) create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryView.java create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewDAO.java create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewManager.java create mode 100644 server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewModule.java create mode 100644 server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewManagerRLSTest.java create mode 100644 server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewTest.java create mode 100644 server/data/data-jmap/src/main/java/org/apache/james/jmap/api/projections/DefaultEmailQueryViewManager.java create mode 100644 server/data/data-jmap/src/main/java/org/apache/james/jmap/api/projections/EmailQueryViewManager.java create mode 100644 server/protocols/jmap-draft/src/main/java/org/apache/james/jmap/draft/methods/GetMessageListMethod.java diff --git a/server/container/guice/cassandra/src/main/java/org/apache/james/modules/data/CassandraJmapModule.java b/server/container/guice/cassandra/src/main/java/org/apache/james/modules/data/CassandraJmapModule.java index cebbbb50e2f..fce2ca29ea8 100644 --- a/server/container/guice/cassandra/src/main/java/org/apache/james/modules/data/CassandraJmapModule.java +++ b/server/container/guice/cassandra/src/main/java/org/apache/james/modules/data/CassandraJmapModule.java @@ -35,7 +35,9 @@ import org.apache.james.jmap.api.filtering.impl.FilterUsernameChangeTaskStep; import org.apache.james.jmap.api.identity.CustomIdentityDAO; import org.apache.james.jmap.api.identity.IdentityUserDeletionTaskStep; +import org.apache.james.jmap.api.projections.DefaultEmailQueryViewManager; import org.apache.james.jmap.api.projections.EmailQueryView; +import org.apache.james.jmap.api.projections.EmailQueryViewManager; import org.apache.james.jmap.api.projections.MessageFastViewProjection; import org.apache.james.jmap.api.projections.MessageFastViewProjectionHealthCheck; import org.apache.james.jmap.api.pushsubscription.PushDeleteUserDataTaskStep; @@ -95,6 +97,8 @@ protected void configure() { bind(CassandraEmailQueryView.class).in(Scopes.SINGLETON); bind(EmailQueryView.class).to(CassandraEmailQueryView.class); + bind(DefaultEmailQueryViewManager.class).in(Scopes.SINGLETON); + bind(EmailQueryViewManager.class).to(DefaultEmailQueryViewManager.class); Multibinder cassandraDataDefinitions = Multibinder.newSetBinder(binder(), CassandraModule.class); cassandraDataDefinitions.addBinding().toInstance(CassandraMessageFastViewProjectionModule.MODULE); diff --git a/server/container/guice/memory/src/main/java/org/apache/james/modules/data/MemoryDataJmapModule.java b/server/container/guice/memory/src/main/java/org/apache/james/modules/data/MemoryDataJmapModule.java index cea7c2d0d6e..4c92db5f0af 100644 --- a/server/container/guice/memory/src/main/java/org/apache/james/modules/data/MemoryDataJmapModule.java +++ b/server/container/guice/memory/src/main/java/org/apache/james/modules/data/MemoryDataJmapModule.java @@ -26,7 +26,9 @@ import org.apache.james.jmap.api.filtering.impl.FilterUsernameChangeTaskStep; import org.apache.james.jmap.api.identity.CustomIdentityDAO; import org.apache.james.jmap.api.identity.IdentityUserDeletionTaskStep; +import org.apache.james.jmap.api.projections.DefaultEmailQueryViewManager; import org.apache.james.jmap.api.projections.EmailQueryView; +import org.apache.james.jmap.api.projections.EmailQueryViewManager; import org.apache.james.jmap.api.projections.MessageFastViewProjection; import org.apache.james.jmap.api.projections.MessageFastViewProjectionHealthCheck; import org.apache.james.jmap.api.pushsubscription.PushDeleteUserDataTaskStep; @@ -67,6 +69,8 @@ protected void configure() { bind(MemoryEmailQueryView.class).in(Scopes.SINGLETON); bind(EmailQueryView.class).to(MemoryEmailQueryView.class); + bind(DefaultEmailQueryViewManager.class).in(Scopes.SINGLETON); + bind(EmailQueryViewManager.class).to(DefaultEmailQueryViewManager.class); bind(MessageFastViewProjectionHealthCheck.class).in(Scopes.SINGLETON); Multibinder.newSetBinder(binder(), HealthCheck.class) diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java index 635f76d2a33..63afa8f77a9 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java @@ -28,15 +28,17 @@ import org.apache.james.jmap.api.identity.CustomIdentityDAO; import org.apache.james.jmap.api.identity.IdentityUserDeletionTaskStep; import org.apache.james.jmap.api.projections.EmailQueryView; +import org.apache.james.jmap.api.projections.EmailQueryViewManager; import org.apache.james.jmap.api.projections.MessageFastViewProjection; import org.apache.james.jmap.api.projections.MessageFastViewProjectionHealthCheck; import org.apache.james.jmap.api.pushsubscription.PushDeleteUserDataTaskStep; import org.apache.james.jmap.api.upload.UploadRepository; import org.apache.james.jmap.memory.access.MemoryAccessTokenRepository; -import org.apache.james.jmap.memory.projections.MemoryEmailQueryView; import org.apache.james.jmap.memory.projections.MemoryMessageFastViewProjection; import org.apache.james.jmap.postgres.filtering.PostgresFilteringProjection; import org.apache.james.jmap.postgres.identity.PostgresCustomIdentityDAO; +import org.apache.james.jmap.postgres.projections.PostgresEmailQueryView; +import org.apache.james.jmap.postgres.projections.PostgresEmailQueryViewManager; import org.apache.james.jmap.postgres.upload.PostgresUploadRepository; import org.apache.james.mailbox.store.extractor.DefaultTextExtractor; import org.apache.james.user.api.DeleteUserDataTaskStep; @@ -68,8 +70,10 @@ protected void configure() { bind(MemoryMessageFastViewProjection.class).in(Scopes.SINGLETON); bind(MessageFastViewProjection.class).to(MemoryMessageFastViewProjection.class); - bind(MemoryEmailQueryView.class).in(Scopes.SINGLETON); - bind(EmailQueryView.class).to(MemoryEmailQueryView.class); + bind(PostgresEmailQueryView.class).in(Scopes.SINGLETON); + bind(EmailQueryView.class).to(PostgresEmailQueryView.class); + bind(PostgresEmailQueryView.class).in(Scopes.SINGLETON); + bind(EmailQueryViewManager.class).to(PostgresEmailQueryViewManager.class); bind(MessageFastViewProjectionHealthCheck.class).in(Scopes.SINGLETON); Multibinder.newSetBinder(binder(), HealthCheck.class) diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/PostgresDataJMapAggregateModule.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/PostgresDataJMapAggregateModule.java index 76106ee0f4d..6943fbd9f9a 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/PostgresDataJMapAggregateModule.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/PostgresDataJMapAggregateModule.java @@ -24,6 +24,7 @@ import org.apache.james.jmap.postgres.change.PostgresMailboxChangeModule; import org.apache.james.jmap.postgres.filtering.PostgresFilteringProjectionModule; import org.apache.james.jmap.postgres.identity.PostgresCustomIdentityModule; +import org.apache.james.jmap.postgres.projections.PostgresEmailQueryViewModule; import org.apache.james.jmap.postgres.projections.PostgresMessageFastViewProjectionModule; import org.apache.james.jmap.postgres.pushsubscription.PostgresPushSubscriptionModule; import org.apache.james.jmap.postgres.upload.PostgresUploadModule; @@ -36,5 +37,6 @@ public interface PostgresDataJMapAggregateModule { PostgresMailboxChangeModule.MODULE, PostgresPushSubscriptionModule.MODULE, PostgresFilteringProjectionModule.MODULE, - PostgresCustomIdentityModule.MODULE); + PostgresCustomIdentityModule.MODULE, + PostgresEmailQueryViewModule.MODULE); } diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryView.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryView.java new file mode 100644 index 00000000000..cf00a306d7f --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryView.java @@ -0,0 +1,88 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.projections; + +import java.time.ZonedDateTime; + +import javax.inject.Inject; + +import org.apache.james.jmap.api.projections.EmailQueryView; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.util.streams.Limit; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresEmailQueryView implements EmailQueryView { + private PostgresEmailQueryViewDAO emailQueryViewDAO; + + @Inject + public PostgresEmailQueryView(PostgresEmailQueryViewDAO emailQueryViewDAO) { + this.emailQueryViewDAO = emailQueryViewDAO; + } + + @Override + public Flux listMailboxContentSortedBySentAt(MailboxId mailboxId, Limit limit) { + return emailQueryViewDAO.listMailboxContentSortedBySentAt(PostgresMailboxId.class.cast(mailboxId), limit); + } + + @Override + public Flux listMailboxContentSortedByReceivedAt(MailboxId mailboxId, Limit limit) { + return emailQueryViewDAO.listMailboxContentSortedByReceivedAt(PostgresMailboxId.class.cast(mailboxId), limit); + } + + @Override + public Flux listMailboxContentSinceAfterSortedBySentAt(MailboxId mailboxId, ZonedDateTime since, Limit limit) { + return emailQueryViewDAO.listMailboxContentSinceAfterSortedBySentAt(PostgresMailboxId.class.cast(mailboxId), since, limit); + } + + @Override + public Flux listMailboxContentSinceAfterSortedByReceivedAt(MailboxId mailboxId, ZonedDateTime since, Limit limit) { + return emailQueryViewDAO.listMailboxContentSinceAfterSortedByReceivedAt(PostgresMailboxId.class.cast(mailboxId), since, limit); + } + + @Override + public Flux listMailboxContentBeforeSortedByReceivedAt(MailboxId mailboxId, ZonedDateTime since, Limit limit) { + return emailQueryViewDAO.listMailboxContentBeforeSortedByReceivedAt(PostgresMailboxId.class.cast(mailboxId), since, limit); + } + + @Override + public Flux listMailboxContentSinceSentAt(MailboxId mailboxId, ZonedDateTime since, Limit limit) { + return emailQueryViewDAO.listMailboxContentSinceSentAt(PostgresMailboxId.class.cast(mailboxId), since, limit); + } + + @Override + public Mono delete(MailboxId mailboxId, MessageId messageId) { + return emailQueryViewDAO.delete(PostgresMailboxId.class.cast(mailboxId), PostgresMessageId.class.cast(messageId)); + } + + @Override + public Mono delete(MailboxId mailboxId) { + return emailQueryViewDAO.delete(PostgresMailboxId.class.cast(mailboxId)); + } + + @Override + public Mono save(MailboxId mailboxId, ZonedDateTime sentAt, ZonedDateTime receivedAt, MessageId messageId) { + return emailQueryViewDAO.save(PostgresMailboxId.class.cast(mailboxId), sentAt, receivedAt, PostgresMessageId.class.cast(messageId)); + } +} diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewDAO.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewDAO.java new file mode 100644 index 00000000000..e13e7247ca2 --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewDAO.java @@ -0,0 +1,143 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.projections; + +import static org.apache.james.jmap.postgres.projections.PostgresEmailQueryViewModule.PostgresEmailQueryViewTable.MAILBOX_ID; +import static org.apache.james.jmap.postgres.projections.PostgresEmailQueryViewModule.PostgresEmailQueryViewTable.MESSAGE_ID; +import static org.apache.james.jmap.postgres.projections.PostgresEmailQueryViewModule.PostgresEmailQueryViewTable.PK_CONSTRAINT_NAME; +import static org.apache.james.jmap.postgres.projections.PostgresEmailQueryViewModule.PostgresEmailQueryViewTable.RECEIVED_AT; +import static org.apache.james.jmap.postgres.projections.PostgresEmailQueryViewModule.PostgresEmailQueryViewTable.SENT_AT; +import static org.apache.james.jmap.postgres.projections.PostgresEmailQueryViewModule.PostgresEmailQueryViewTable.TABLE_NAME; + +import java.time.ZonedDateTime; + +import javax.inject.Inject; +import javax.inject.Named; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.util.streams.Limit; + +import com.google.common.base.Preconditions; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresEmailQueryViewDAO { + private PostgresExecutor postgresExecutor; + + @Inject + public PostgresEmailQueryViewDAO(@Named(PostgresExecutor.NON_RLS_INJECT) PostgresExecutor postgresExecutor) { + this.postgresExecutor = postgresExecutor; + } + + public Flux listMailboxContentSortedBySentAt(PostgresMailboxId mailboxId, Limit limit) { + Preconditions.checkArgument(!limit.isUnlimited(), "Limit should be defined"); + + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(MESSAGE_ID) + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .orderBy(SENT_AT.desc()) + .limit(limit.getLimit().get()))) + .map(record -> PostgresMessageId.Factory.of(record.get(MESSAGE_ID))); + } + + public Flux listMailboxContentSortedByReceivedAt(PostgresMailboxId mailboxId, Limit limit) { + Preconditions.checkArgument(!limit.isUnlimited(), "Limit should be defined"); + + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(MESSAGE_ID) + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .orderBy(RECEIVED_AT.desc()) + .limit(limit.getLimit().get()))) + .map(record -> PostgresMessageId.Factory.of(record.get(MESSAGE_ID))); + } + + public Flux listMailboxContentSinceAfterSortedBySentAt(PostgresMailboxId mailboxId, ZonedDateTime since, Limit limit) { + Preconditions.checkArgument(!limit.isUnlimited(), "Limit should be defined"); + + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(MESSAGE_ID) + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(RECEIVED_AT.greaterOrEqual(since.toOffsetDateTime())) + .orderBy(SENT_AT.desc()) + .limit(limit.getLimit().get()))) + .map(record -> PostgresMessageId.Factory.of(record.get(MESSAGE_ID))); + } + + public Flux listMailboxContentSinceAfterSortedByReceivedAt(PostgresMailboxId mailboxId, ZonedDateTime since, Limit limit) { + Preconditions.checkArgument(!limit.isUnlimited(), "Limit should be defined"); + + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(MESSAGE_ID) + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(RECEIVED_AT.greaterOrEqual(since.toOffsetDateTime())) + .orderBy(RECEIVED_AT.desc()) + .limit(limit.getLimit().get()))) + .map(record -> PostgresMessageId.Factory.of(record.get(MESSAGE_ID))); + } + + public Flux listMailboxContentBeforeSortedByReceivedAt(PostgresMailboxId mailboxId, ZonedDateTime since, Limit limit) { + Preconditions.checkArgument(!limit.isUnlimited(), "Limit should be defined"); + + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(MESSAGE_ID) + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(RECEIVED_AT.lessOrEqual(since.toOffsetDateTime())) + .orderBy(RECEIVED_AT.desc()) + .limit(limit.getLimit().get()))) + .map(record -> PostgresMessageId.Factory.of(record.get(MESSAGE_ID))); + } + + public Flux listMailboxContentSinceSentAt(PostgresMailboxId mailboxId, ZonedDateTime since, Limit limit) { + Preconditions.checkArgument(!limit.isUnlimited(), "Limit should be defined"); + + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(MESSAGE_ID) + .from(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(SENT_AT.greaterOrEqual(since.toOffsetDateTime())) + .orderBy(SENT_AT.desc()) + .limit(limit.getLimit().get()))) + .map(record -> PostgresMessageId.Factory.of(record.get(MESSAGE_ID))); + } + + public Mono delete(PostgresMailboxId mailboxId, PostgresMessageId messageId) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())) + .and(MESSAGE_ID.eq(messageId.asUuid())))); + } + + public Mono delete(PostgresMailboxId mailboxId) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(TABLE_NAME) + .where(MAILBOX_ID.eq(mailboxId.asUuid())))); + } + + public Mono save(PostgresMailboxId mailboxId, ZonedDateTime sentAt, ZonedDateTime receivedAt, PostgresMessageId messageId) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME) + .set(MAILBOX_ID, mailboxId.asUuid()) + .set(MESSAGE_ID, messageId.asUuid()) + .set(SENT_AT, sentAt.toOffsetDateTime()) + .set(RECEIVED_AT, receivedAt.toOffsetDateTime()) + .onConflictOnConstraint(PK_CONSTRAINT_NAME) + .doNothing())); + } +} diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewManager.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewManager.java new file mode 100644 index 00000000000..1ca2c84f80e --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewManager.java @@ -0,0 +1,41 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.projections; + +import javax.inject.Inject; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.core.Username; +import org.apache.james.jmap.api.projections.EmailQueryView; +import org.apache.james.jmap.api.projections.EmailQueryViewManager; + +public class PostgresEmailQueryViewManager implements EmailQueryViewManager { + private final PostgresExecutor.Factory executorFactory; + + @Inject + public PostgresEmailQueryViewManager(PostgresExecutor.Factory executorFactory) { + this.executorFactory = executorFactory; + } + + @Override + public EmailQueryView getEmailQueryView(Username username) { + return new PostgresEmailQueryView(new PostgresEmailQueryViewDAO(executorFactory.create(username.getDomainPart()))); + } +} diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewModule.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewModule.java new file mode 100644 index 00000000000..cd413128faf --- /dev/null +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewModule.java @@ -0,0 +1,81 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.projections; + +import static org.apache.james.jmap.postgres.projections.PostgresEmailQueryViewModule.PostgresEmailQueryViewTable.MAILBOX_ID_INDEX; +import static org.apache.james.jmap.postgres.projections.PostgresEmailQueryViewModule.PostgresEmailQueryViewTable.MAILBOX_ID_RECEIVED_AT_INDEX; +import static org.apache.james.jmap.postgres.projections.PostgresEmailQueryViewModule.PostgresEmailQueryViewTable.MAILBOX_ID_SENT_AT_INDEX; +import static org.apache.james.jmap.postgres.projections.PostgresEmailQueryViewModule.PostgresEmailQueryViewTable.TABLE; + +import java.time.OffsetDateTime; +import java.util.UUID; + +import org.apache.james.backends.postgres.PostgresIndex; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.apache.james.mailbox.postgres.mail.PostgresMessageModule; +import org.jooq.Field; +import org.jooq.Name; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresEmailQueryViewModule { + interface PostgresEmailQueryViewTable { + Table TABLE_NAME = DSL.table("email_query_view"); + + Field MAILBOX_ID = DSL.field("mailbox_id", SQLDataType.UUID.notNull()); + Field MESSAGE_ID = PostgresMessageModule.MESSAGE_ID; + Field RECEIVED_AT = DSL.field("received_at", SQLDataType.TIMESTAMPWITHTIMEZONE.notNull()); + Field SENT_AT = DSL.field("sent_at", SQLDataType.TIMESTAMPWITHTIMEZONE.notNull()); + + Name PK_CONSTRAINT_NAME = DSL.name("email_query_view_pkey"); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(MAILBOX_ID) + .column(MESSAGE_ID) + .column(RECEIVED_AT) + .column(SENT_AT) + .constraint(DSL.constraint(PK_CONSTRAINT_NAME).primaryKey(MAILBOX_ID, MESSAGE_ID)))) + .supportsRowLevelSecurity() + .build(); + + PostgresIndex MAILBOX_ID_INDEX = PostgresIndex.name("email_query_view_mailbox_id_index") + .createIndexStep((dslContext, indexName) -> dslContext.createIndexIfNotExists(indexName) + .on(TABLE_NAME, MAILBOX_ID)); + + PostgresIndex MAILBOX_ID_RECEIVED_AT_INDEX = PostgresIndex.name("email_query_view_mailbox_id__received_at_index") + .createIndexStep((dslContext, indexName) -> dslContext.createIndexIfNotExists(indexName) + .on(TABLE_NAME, MAILBOX_ID, RECEIVED_AT)); + + PostgresIndex MAILBOX_ID_SENT_AT_INDEX = PostgresIndex.name("email_query_view_mailbox_id_sent_at_index") + .createIndexStep((dslContext, indexName) -> dslContext.createIndexIfNotExists(indexName) + .on(TABLE_NAME, MAILBOX_ID, SENT_AT)); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(TABLE) + .addIndex(MAILBOX_ID_INDEX) + .addIndex(MAILBOX_ID_RECEIVED_AT_INDEX) + .addIndex(MAILBOX_ID_SENT_AT_INDEX) + .build(); +} \ No newline at end of file diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewManagerRLSTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewManagerRLSTest.java new file mode 100644 index 00000000000..f296470d4ff --- /dev/null +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewManagerRLSTest.java @@ -0,0 +1,73 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.projections; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.ZonedDateTime; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.core.Username; +import org.apache.james.jmap.api.projections.EmailQueryViewManager; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.util.streams.Limit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresEmailQueryViewManagerRLSTest { + public static final PostgresMailboxId MAILBOX_ID_1 = PostgresMailboxId.generate(); + public static final PostgresMessageId.Factory MESSAGE_ID_FACTORY = new PostgresMessageId.Factory(); + public static final PostgresMessageId MESSAGE_ID_1 = MESSAGE_ID_FACTORY.generate(); + ZonedDateTime DATE_1 = ZonedDateTime.parse("2010-10-30T15:12:00Z"); + ZonedDateTime DATE_2 = ZonedDateTime.parse("2010-10-30T16:12:00Z"); + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresEmailQueryViewModule.MODULE); + + private EmailQueryViewManager emailQueryViewManager; + + @BeforeEach + public void setUp() { + emailQueryViewManager = new PostgresEmailQueryViewManager(postgresExtension.getExecutorFactory()); + } + + @Test + void emailQueryViewCanBeAccessedAtTheDataLevelByMembersOfTheSameDomain() { + Username username = Username.of("alice@domain1"); + + emailQueryViewManager.getEmailQueryView(username).save(MAILBOX_ID_1, DATE_1, DATE_2, MESSAGE_ID_1).block(); + + assertThat(emailQueryViewManager.getEmailQueryView(username).listMailboxContentSortedByReceivedAt(MAILBOX_ID_1, Limit.limit(1)).collectList().block()) + .isNotEmpty(); + } + + @Test + void emailQueryViewShouldBeIsolatedByDomain() { + Username username = Username.of("alice@domain1"); + Username username2 = Username.of("bob@domain2"); + + emailQueryViewManager.getEmailQueryView(username).save(MAILBOX_ID_1, DATE_1, DATE_2, MESSAGE_ID_1).block(); + + assertThat(emailQueryViewManager.getEmailQueryView(username2).listMailboxContentSortedByReceivedAt(MAILBOX_ID_1, Limit.limit(1)).collectList().block()) + .isEmpty(); + } +} diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewTest.java new file mode 100644 index 00000000000..2bc02c86903 --- /dev/null +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewTest.java @@ -0,0 +1,71 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.projections; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.jmap.api.projections.EmailQueryView; +import org.apache.james.jmap.api.projections.EmailQueryViewContract; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresEmailQueryViewTest implements EmailQueryViewContract { + public static final PostgresMailboxId MAILBOX_ID_1 = PostgresMailboxId.generate(); + public static final PostgresMessageId.Factory MESSAGE_ID_FACTORY = new PostgresMessageId.Factory(); + public static final PostgresMessageId MESSAGE_ID_1 = MESSAGE_ID_FACTORY.generate(); + public static final PostgresMessageId MESSAGE_ID_2 = MESSAGE_ID_FACTORY.generate(); + public static final PostgresMessageId MESSAGE_ID_3 = MESSAGE_ID_FACTORY.generate(); + public static final PostgresMessageId MESSAGE_ID_4 = MESSAGE_ID_FACTORY.generate(); + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresEmailQueryViewModule.MODULE); + + @Override + public EmailQueryView testee() { + return new PostgresEmailQueryView(new PostgresEmailQueryViewDAO(postgresExtension.getPostgresExecutor())); + } + + @Override + public MailboxId mailboxId1() { + return MAILBOX_ID_1; + } + + @Override + public MessageId messageId1() { + return MESSAGE_ID_1; + } + + @Override + public MessageId messageId2() { + return MESSAGE_ID_2; + } + + @Override + public MessageId messageId3() { + return MESSAGE_ID_3; + } + + @Override + public MessageId messageId4() { + return MESSAGE_ID_4; + } +} diff --git a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/projections/DefaultEmailQueryViewManager.java b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/projections/DefaultEmailQueryViewManager.java new file mode 100644 index 00000000000..4fa6831e63b --- /dev/null +++ b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/projections/DefaultEmailQueryViewManager.java @@ -0,0 +1,38 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.api.projections; + +import javax.inject.Inject; + +import org.apache.james.core.Username; + +public class DefaultEmailQueryViewManager implements EmailQueryViewManager { + private EmailQueryView emailQueryView; + + @Inject + public DefaultEmailQueryViewManager(EmailQueryView emailQueryView) { + this.emailQueryView = emailQueryView; + } + + @Override + public EmailQueryView getEmailQueryView(Username username) { + return emailQueryView; + } +} diff --git a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/projections/EmailQueryViewManager.java b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/projections/EmailQueryViewManager.java new file mode 100644 index 00000000000..e4a281829e9 --- /dev/null +++ b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/projections/EmailQueryViewManager.java @@ -0,0 +1,26 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.api.projections; + +import org.apache.james.core.Username; + +public interface EmailQueryViewManager { + EmailQueryView getEmailQueryView(Username username); +} diff --git a/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/projections/EmailQueryViewContract.java b/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/projections/EmailQueryViewContract.java index 4e1b3abb4ea..ac99142ec40 100644 --- a/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/projections/EmailQueryViewContract.java +++ b/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/projections/EmailQueryViewContract.java @@ -200,7 +200,7 @@ default void datesCanBeDuplicated() { testee().save(mailboxId1(), DATE_1, DATE_2, messageId2()).block(); assertThat(testee().listMailboxContentSortedBySentAt(mailboxId1(), Limit.limit(12)).collectList().block()) - .containsExactly(messageId1(), messageId2()); + .containsExactlyInAnyOrder(messageId1(), messageId2()); } @Test diff --git a/server/protocols/jmap-draft/src/main/java/org/apache/james/jmap/draft/methods/GetMessageListMethod.java b/server/protocols/jmap-draft/src/main/java/org/apache/james/jmap/draft/methods/GetMessageListMethod.java new file mode 100644 index 00000000000..e69de29bb2d diff --git a/server/protocols/jmap-rfc-8621/src/main/java/org/apache/james/jmap/event/PopulateEmailQueryViewListener.java b/server/protocols/jmap-rfc-8621/src/main/java/org/apache/james/jmap/event/PopulateEmailQueryViewListener.java index f7b78c69c5f..af9faf78c24 100644 --- a/server/protocols/jmap-rfc-8621/src/main/java/org/apache/james/jmap/event/PopulateEmailQueryViewListener.java +++ b/server/protocols/jmap-rfc-8621/src/main/java/org/apache/james/jmap/event/PopulateEmailQueryViewListener.java @@ -30,10 +30,11 @@ import jakarta.inject.Inject; +import org.apache.james.core.Username; import org.apache.james.events.Event; import org.apache.james.events.EventListener.ReactiveGroupEventListener; import org.apache.james.events.Group; -import org.apache.james.jmap.api.projections.EmailQueryView; +import org.apache.james.jmap.api.projections.EmailQueryViewManager; import org.apache.james.mailbox.MailboxSession; import org.apache.james.mailbox.MessageIdManager; import org.apache.james.mailbox.Role; @@ -72,13 +73,13 @@ public static class PopulateEmailQueryViewListenerGroup extends Group { private static final int CONCURRENCY = 5; private final MessageIdManager messageIdManager; - private final EmailQueryView view; + private final EmailQueryViewManager viewManager; private final SessionProvider sessionProvider; @Inject - public PopulateEmailQueryViewListener(MessageIdManager messageIdManager, EmailQueryView view, SessionProvider sessionProvider) { + public PopulateEmailQueryViewListener(MessageIdManager messageIdManager, EmailQueryViewManager viewManager, SessionProvider sessionProvider) { this.messageIdManager = messageIdManager; - this.view = view; + this.viewManager = viewManager; this.sessionProvider = sessionProvider; } @@ -113,13 +114,13 @@ public Publisher reactiveEvent(Event event) { } private Publisher handleMailboxDeletion(MailboxDeletion mailboxDeletion) { - return view.delete(mailboxDeletion.getMailboxId()); + return viewManager.getEmailQueryView(mailboxDeletion.getUsername()).delete(mailboxDeletion.getMailboxId()); } private Publisher handleExpunged(Expunged expunged) { return Flux.fromStream(expunged.getUids().stream() .map(uid -> expunged.getMetaData(uid).getMessageId())) - .concatMap(messageId -> view.delete(expunged.getMailboxId(), messageId)) + .concatMap(messageId -> viewManager.getEmailQueryView(expunged.getUsername()).delete(expunged.getMailboxId(), messageId)) .then(); } @@ -131,7 +132,7 @@ private Publisher handleFlagsUpdated(FlagsUpdated flagsUpdated) { .filter(updatedFlags -> updatedFlags.isModifiedToSet(DELETED)) .map(UpdatedFlags::getMessageId) .handle(publishIfPresent()) - .concatMap(messageId -> view.delete(flagsUpdated.getMailboxId(), messageId)) + .concatMap(messageId -> viewManager.getEmailQueryView(flagsUpdated.getUsername()).delete(flagsUpdated.getMailboxId(), messageId)) .then(); Mono addMessagesNoLongerMarkedAsDeleted = Flux.fromIterable(flagsUpdated.getUpdatedFlags()) @@ -141,7 +142,7 @@ private Publisher handleFlagsUpdated(FlagsUpdated flagsUpdated) { .concatMap(messageId -> Flux.from(messageIdManager.getMessagesReactive(ImmutableList.of(messageId), FetchGroup.HEADERS, session)) .next()) - .concatMap(message -> handleAdded(flagsUpdated.getMailboxId(), message)) + .concatMap(message -> handleAdded(flagsUpdated.getMailboxId(), message, flagsUpdated.getUsername())) .then(); return removeMessagesMarkedAsDeleted @@ -163,7 +164,7 @@ private Mono handleAdded(Added added, MessageMetaData messageMetaData, Mai Mono doHandleAdded = Flux.from(messageIdManager.getMessagesReactive(ImmutableList.of(messageId), FetchGroup.HEADERS, session)) .next() .filter(message -> !message.getFlags().contains(DELETED)) - .flatMap(messageResult -> handleAdded(added.getMailboxId(), messageResult)); + .flatMap(messageResult -> handleAdded(added.getMailboxId(), messageResult, added.getUsername())); if (Role.from(added.getMailboxPath().getName()).equals(Optional.of(Role.OUTBOX))) { return checkMessageStillInOriginMailbox(messageId, session, mailboxId) .filter(FunctionalUtils.identityPredicate()) @@ -178,13 +179,13 @@ private Mono checkMessageStillInOriginMailbox(MessageId messageId, Mail .hasElements(); } - public Mono handleAdded(MailboxId mailboxId, MessageResult messageResult) { + public Mono handleAdded(MailboxId mailboxId, MessageResult messageResult, Username username) { ZonedDateTime receivedAt = ZonedDateTime.ofInstant(messageResult.getInternalDate().toInstant(), ZoneOffset.UTC); return Mono.fromCallable(() -> parseMessage(messageResult)) .map(header -> date(header).orElse(messageResult.getInternalDate())) .map(date -> ZonedDateTime.ofInstant(date.toInstant(), ZoneOffset.UTC)) - .flatMap(sentAt -> view.save(mailboxId, sentAt, receivedAt, messageResult.getMessageId())) + .flatMap(sentAt -> viewManager.getEmailQueryView(username).save(mailboxId, sentAt, receivedAt, messageResult.getMessageId())) .then(); } diff --git a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailQueryMethod.scala b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailQueryMethod.scala index 26d2869ef81..a24a7e225c0 100644 --- a/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailQueryMethod.scala +++ b/server/protocols/jmap-rfc-8621/src/main/scala/org/apache/james/jmap/method/EmailQueryMethod.scala @@ -25,7 +25,7 @@ import eu.timepit.refined.auto._ import jakarta.inject.Inject import jakarta.mail.Flags.Flag.DELETED import org.apache.james.jmap.JMAPConfiguration -import org.apache.james.jmap.api.projections.EmailQueryView +import org.apache.james.jmap.api.projections.{EmailQueryView, EmailQueryViewManager} import org.apache.james.jmap.core.CapabilityIdentifier.{CapabilityIdentifier, JMAP_CORE, JMAP_MAIL} import org.apache.james.jmap.core.Invocation.{Arguments, MethodName} import org.apache.james.jmap.core.Limit.Limit @@ -52,7 +52,7 @@ class EmailQueryMethod @Inject() (serializer: EmailQuerySerializer, val sessionSupplier: SessionSupplier, val sessionTranslator: SessionTranslator, val configuration: JMAPConfiguration, - val emailQueryView: EmailQueryView) extends MethodRequiringAccountId[EmailQueryRequest] { + val emailQueryViewManager: EmailQueryViewManager) extends MethodRequiringAccountId[EmailQueryRequest] { override val methodName: MethodName = MethodName("Email/query") override val requiredCapabilities: Set[CapabilityIdentifier] = Set(JMAP_CORE, JMAP_MAIL) @@ -114,7 +114,8 @@ class EmailQueryMethod @Inject() (serializer: EmailQuerySerializer, val mailboxId: MailboxId = condition.inMailbox.get val after: ZonedDateTime = condition.after.get.asUTC - val queryViewEntries: SFlux[MessageId] = SFlux.fromPublisher(emailQueryView.listMailboxContentSinceAfterSortedBySentAt(mailboxId, after, JavaLimit.from(limitToUse.value + position.value))) + val queryViewEntries: SFlux[MessageId] = SFlux.fromPublisher(emailQueryViewManager.getEmailQueryView(mailboxSession.getUser) + .listMailboxContentSinceAfterSortedBySentAt(mailboxId, after, JavaLimit.from(limitToUse.value + position.value))) fromQueryViewEntries(mailboxId, queryViewEntries, mailboxSession, position, limitToUse, namespace) } @@ -123,7 +124,8 @@ class EmailQueryMethod @Inject() (serializer: EmailQuerySerializer, val condition: FilterCondition = request.filter.get.asInstanceOf[FilterCondition] val mailboxId: MailboxId = condition.inMailbox.get val after: ZonedDateTime = condition.after.get.asUTC - val queryViewEntries: SFlux[MessageId] = SFlux.fromPublisher(emailQueryView.listMailboxContentSinceAfterSortedByReceivedAt(mailboxId, after, JavaLimit.from(limitToUse.value + position.value))) + val queryViewEntries: SFlux[MessageId] = SFlux.fromPublisher(emailQueryViewManager.getEmailQueryView(mailboxSession.getUser) + .listMailboxContentSinceAfterSortedByReceivedAt(mailboxId, after, JavaLimit.from(limitToUse.value + position.value))) fromQueryViewEntries(mailboxId, queryViewEntries, mailboxSession, position, limitToUse, namespace) } @@ -132,21 +134,24 @@ class EmailQueryMethod @Inject() (serializer: EmailQuerySerializer, val condition: FilterCondition = request.filter.get.asInstanceOf[FilterCondition] val mailboxId: MailboxId = condition.inMailbox.get val before: ZonedDateTime = condition.before.get.asUTC - val queryViewEntries: SFlux[MessageId] = SFlux.fromPublisher(emailQueryView.listMailboxContentBeforeSortedByReceivedAt(mailboxId, before, JavaLimit.from(limitToUse.value + position.value))) + val queryViewEntries: SFlux[MessageId] = SFlux.fromPublisher(emailQueryViewManager.getEmailQueryView(mailboxSession.getUser) + .listMailboxContentBeforeSortedByReceivedAt(mailboxId, before, JavaLimit.from(limitToUse.value + position.value))) fromQueryViewEntries(mailboxId, queryViewEntries, mailboxSession, position, limitToUse, namespace) } private def queryViewForListingSortedBySentAt(mailboxSession: MailboxSession, position: Position, limitToUse: Limit, request: EmailQueryRequest, namespace: Namespace): SMono[Seq[MessageId]] = { val mailboxId: MailboxId = request.filter.get.asInstanceOf[FilterCondition].inMailbox.get - val queryViewEntries: SFlux[MessageId] = SFlux.fromPublisher(emailQueryView.listMailboxContentSortedBySentAt(mailboxId, JavaLimit.from(limitToUse.value + position.value))) + val queryViewEntries: SFlux[MessageId] = SFlux.fromPublisher(emailQueryViewManager.getEmailQueryView(mailboxSession.getUser) + .listMailboxContentSortedBySentAt(mailboxId, JavaLimit.from(limitToUse.value + position.value))) fromQueryViewEntries(mailboxId, queryViewEntries, mailboxSession, position, limitToUse, namespace) } private def queryViewForListingSortedByReceivedAt(mailboxSession: MailboxSession, position: Position, limitToUse: Limit, request: EmailQueryRequest, namespace: Namespace): SMono[Seq[MessageId]] = { val mailboxId: MailboxId = request.filter.get.asInstanceOf[FilterCondition].inMailbox.get - val queryViewEntries: SFlux[MessageId] = SFlux.fromPublisher(emailQueryView.listMailboxContentSortedByReceivedAt(mailboxId, JavaLimit.from(limitToUse.value + position.value))) + val queryViewEntries: SFlux[MessageId] = SFlux.fromPublisher(emailQueryViewManager + .getEmailQueryView(mailboxSession.getUser).listMailboxContentSortedByReceivedAt(mailboxId, JavaLimit.from(limitToUse.value + position.value))) fromQueryViewEntries(mailboxId, queryViewEntries, mailboxSession, position, limitToUse, namespace) } diff --git a/server/protocols/jmap-rfc-8621/src/test/java/org/apache/james/jmap/event/PopulateEmailQueryViewListenerTest.java b/server/protocols/jmap-rfc-8621/src/test/java/org/apache/james/jmap/event/PopulateEmailQueryViewListenerTest.java index 97446748900..bdd6d8e532f 100644 --- a/server/protocols/jmap-rfc-8621/src/test/java/org/apache/james/jmap/event/PopulateEmailQueryViewListenerTest.java +++ b/server/protocols/jmap-rfc-8621/src/test/java/org/apache/james/jmap/event/PopulateEmailQueryViewListenerTest.java @@ -39,6 +39,8 @@ import org.apache.james.events.MemoryEventDeadLetters; import org.apache.james.events.RetryBackoffConfiguration; import org.apache.james.events.delivery.InVmEventDelivery; +import org.apache.james.jmap.api.projections.DefaultEmailQueryViewManager; +import org.apache.james.jmap.api.projections.EmailQueryViewManager; import org.apache.james.jmap.memory.projections.MemoryEmailQueryView; import org.apache.james.mailbox.MailboxSession; import org.apache.james.mailbox.MailboxSessionUtil; @@ -82,7 +84,7 @@ public class PopulateEmailQueryViewListenerTest { PopulateEmailQueryViewListener listener; MessageIdManager messageIdManager; SessionProviderImpl sessionProvider; - private MemoryEmailQueryView view; + private EmailQueryViewManager viewManager; private MailboxId inboxId; @BeforeEach @@ -112,8 +114,8 @@ void setup() throws Exception { authenticator.addUser(BOB, "12345"); sessionProvider = new SessionProviderImpl(authenticator, FakeAuthorizator.defaultReject()); - view = new MemoryEmailQueryView(); - listener = new PopulateEmailQueryViewListener(messageIdManager, view, sessionProvider); + viewManager = new DefaultEmailQueryViewManager(new MemoryEmailQueryView()); + listener = new PopulateEmailQueryViewListener(messageIdManager, viewManager, sessionProvider); resources.getEventBus().register(listener); @@ -141,7 +143,7 @@ void appendingAMessageShouldAddItToTheView() throws Exception { .build(emptyMessage(Date.from(ZonedDateTime.parse("2014-10-30T14:12:00Z").toInstant()))), mailboxSession).getId(); - assertThat(view.listMailboxContentSortedBySentAt(inboxId, Limit.limit(12)).collectList().block()) + assertThat(viewManager.getEmailQueryView(mailboxSession.getUser()).listMailboxContentSortedBySentAt(inboxId, Limit.limit(12)).collectList().block()) .containsOnly(composedId.getMessageId()); } @@ -154,13 +156,13 @@ void appendingADeletedMessageShouldNotAddItToTheView() throws Exception { .build(emptyMessage(Date.from(ZonedDateTime.parse("2014-10-30T14:12:00Z").toInstant()))), mailboxSession).getId(); - assertThat(view.listMailboxContentSortedBySentAt(inboxId, Limit.limit(12)).collectList().block()) + assertThat(viewManager.getEmailQueryView(mailboxSession.getUser()).listMailboxContentSortedBySentAt(inboxId, Limit.limit(12)).collectList().block()) .isEmpty(); } @Test void appendingAOutdatedMessageInOutBoxShouldNotAddItToTheView() throws Exception { - MemoryEmailQueryView emailQueryView = new MemoryEmailQueryView(); + EmailQueryViewManager emailQueryView = new DefaultEmailQueryViewManager(new MemoryEmailQueryView()); PopulateEmailQueryViewListener queryViewListener = new PopulateEmailQueryViewListener(messageIdManager, emailQueryView, sessionProvider); MailboxPath outboxPath = MailboxPath.forUser(BOB, "Outbox"); MailboxId outboxId = mailboxManager.createMailbox(outboxPath, mailboxSession).orElseThrow(); @@ -193,7 +195,7 @@ void appendingAOutdatedMessageInOutBoxShouldNotAddItToTheView() throws Exception Mono.from(queryViewListener.reactiveEvent(addedOutDatedEvent)).block(); - assertThat(emailQueryView.listMailboxContentSortedBySentAt(outboxId, Limit.limit(12)).collectList().block()) + assertThat(viewManager.getEmailQueryView(mailboxSession.getUser()).listMailboxContentSortedBySentAt(outboxId, Limit.limit(12)).collectList().block()) .isEmpty(); } @@ -209,7 +211,7 @@ void removingDeletedFlagsShouldAddItToTheView() throws Exception { inboxMessageManager.setFlags(new Flags(), MessageManager.FlagsUpdateMode.REPLACE, MessageRange.all(), mailboxSession); - assertThat(view.listMailboxContentSortedBySentAt(inboxId, Limit.limit(12)).collectList().block()) + assertThat(viewManager.getEmailQueryView(mailboxSession.getUser()).listMailboxContentSortedBySentAt(inboxId, Limit.limit(12)).collectList().block()) .containsOnly(composedId.getMessageId()); } @@ -223,7 +225,7 @@ void addingDeletedFlagsShouldRemoveItToTheView() throws Exception { inboxMessageManager.setFlags(new Flags(DELETED), MessageManager.FlagsUpdateMode.REPLACE, MessageRange.all(), mailboxSession); - assertThat(view.listMailboxContentSortedBySentAt(inboxId, Limit.limit(12)).collectList().block()) + assertThat(viewManager.getEmailQueryView(mailboxSession.getUser()).listMailboxContentSortedBySentAt(inboxId, Limit.limit(12)).collectList().block()) .isEmpty(); } @@ -237,7 +239,7 @@ void deletingMailboxShouldClearTheView() throws Exception { mailboxManager.deleteMailbox(inboxId, mailboxSession); - assertThat(view.listMailboxContentSortedBySentAt(inboxId, Limit.limit(12)).collectList().block()) + assertThat(viewManager.getEmailQueryView(mailboxSession.getUser()).listMailboxContentSortedBySentAt(inboxId, Limit.limit(12)).collectList().block()) .isEmpty(); } @@ -251,7 +253,7 @@ void deletingEmailShouldClearTheView() throws Exception { inboxMessageManager.delete(ImmutableList.of(composedMessageId.getUid()), mailboxSession); - assertThat(view.listMailboxContentSortedBySentAt(inboxId, Limit.limit(12)).collectList().block()) + assertThat(viewManager.getEmailQueryView(mailboxSession.getUser()).listMailboxContentSortedBySentAt(inboxId, Limit.limit(12)).collectList().block()) .isEmpty(); } From d33439e4d9d1ee20f1ab6d6d94747df6acaabb9c Mon Sep 17 00:00:00 2001 From: vttran Date: Thu, 29 Feb 2024 15:18:27 +0700 Subject: [PATCH 234/341] JAMES-2586 - Postgres push subscription - expires value should be stored Offset time --- .../backends/postgres/PostgresCommons.java | 7 +++++++ .../PostgresPushSubscriptionDAO.java | 13 ++++++------- .../PostgresPushSubscriptionModule.java | 4 ++-- .../PushSubscriptionRepositoryContract.scala | 19 +++++++++++++++++++ 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresCommons.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresCommons.java index d465740e40e..5557b591b90 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresCommons.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresCommons.java @@ -21,6 +21,7 @@ import java.time.Instant; import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.time.ZoneId; import java.time.ZoneOffset; import java.time.ZonedDateTime; @@ -48,6 +49,8 @@ public interface DataTypes { // timestamp(6) DataType TIMESTAMP = SQLDataType.LOCALDATETIME(6); + DataType TIMESTAMP_WITH_TIMEZONE = SQLDataType.TIMESTAMPWITHTIMEZONE(6); + // text[] DataType STRING_ARRAY = SQLDataType.VARCHAR.getArrayDataType(); } @@ -74,6 +77,10 @@ public static Field tableField(Table table, Field field) { .map(value -> value.atZone(ZoneId.of("UTC"))) .orElse(null); + public static final Function OFFSET_DATE_TIME_ZONED_DATE_TIME_FUNCTION = offsetDateTime -> Optional.ofNullable(offsetDateTime) + .map(value -> value.atZoneSameInstant(ZoneId.of("UTC"))) + .orElse(null); + public static final Function LOCAL_DATE_TIME_INSTANT_FUNCTION = localDateTime -> Optional.ofNullable(localDateTime) .map(value -> value.toInstant(ZoneOffset.UTC)) .orElse(null); diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionDAO.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionDAO.java index 0c611859c28..5d59e08fe20 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionDAO.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionDAO.java @@ -20,9 +20,8 @@ package org.apache.james.jmap.postgres.pushsubscription; import static org.apache.james.backends.postgres.PostgresCommons.IN_CLAUSE_MAX_SIZE; -import static org.apache.james.backends.postgres.PostgresCommons.LOCAL_DATE_TIME_ZONED_DATE_TIME_FUNCTION; +import static org.apache.james.backends.postgres.PostgresCommons.OFFSET_DATE_TIME_ZONED_DATE_TIME_FUNCTION; -import java.time.LocalDateTime; import java.time.ZonedDateTime; import java.util.Arrays; import java.util.Collection; @@ -65,7 +64,7 @@ public Mono save(Username username, PushSubscription pushSubscription) { .set(PushSubscriptionTable.USER, username.asString()) .set(PushSubscriptionTable.DEVICE_CLIENT_ID, pushSubscription.deviceClientId()) .set(PushSubscriptionTable.ID, pushSubscription.id().value()) - .set(PushSubscriptionTable.EXPIRES, pushSubscription.expires().value().toLocalDateTime()) + .set(PushSubscriptionTable.EXPIRES, pushSubscription.expires().value().toOffsetDateTime()) .set(PushSubscriptionTable.TYPES, CollectionConverters.asJava(pushSubscription.types()) .stream().map(TypeName::asString).toArray(String[]::new)) .set(PushSubscriptionTable.URL, pushSubscription.url().value().toString()) @@ -128,11 +127,11 @@ public Mono updateValidated(Username username, PushSubscriptionId id, b public Mono updateExpireTime(Username username, PushSubscriptionId id, ZonedDateTime newExpire) { Preconditions.checkNotNull(newExpire, "newExpire should not be null"); return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.update(PushSubscriptionTable.TABLE_NAME) - .set(PushSubscriptionTable.EXPIRES, newExpire.toLocalDateTime()) + .set(PushSubscriptionTable.EXPIRES, newExpire.toOffsetDateTime()) .where(PushSubscriptionTable.USER.eq(username.asString())) .and(PushSubscriptionTable.ID.eq(id.value())) .returning(PushSubscriptionTable.EXPIRES))) - .map(record -> LOCAL_DATE_TIME_ZONED_DATE_TIME_FUNCTION.apply(record.get(PushSubscriptionTable.EXPIRES))); + .map(record -> OFFSET_DATE_TIME_ZONED_DATE_TIME_FUNCTION.apply(record.get(PushSubscriptionTable.EXPIRES))); } public Mono existDeviceClientId(Username username, String deviceClientId) { @@ -152,8 +151,8 @@ private PushSubscription recordAsPushSubscription(Record record) { .map(secret -> new PushSubscriptionKeys(key, secret)))), record.get(PushSubscriptionTable.VERIFICATION_CODE), record.get(PushSubscriptionTable.VALIDATED), - Optional.ofNullable(record.get(PushSubscriptionTable.EXPIRES, LocalDateTime.class)) - .map(LOCAL_DATE_TIME_ZONED_DATE_TIME_FUNCTION) + Optional.ofNullable(record.get(PushSubscriptionTable.EXPIRES)) + .map(OFFSET_DATE_TIME_ZONED_DATE_TIME_FUNCTION) .map(PushSubscriptionExpiredTime::new).get(), CollectionConverters.asScala(extractTypes(record)).toSeq()); } catch (Exception e) { diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionModule.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionModule.java index eceda1c2b10..4a16bed563f 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionModule.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionModule.java @@ -19,7 +19,7 @@ package org.apache.james.jmap.postgres.pushsubscription; -import java.time.LocalDateTime; +import java.time.OffsetDateTime; import java.util.UUID; import org.apache.james.backends.postgres.PostgresCommons; @@ -40,7 +40,7 @@ interface PushSubscriptionTable { Field DEVICE_CLIENT_ID = DSL.field("device_client_id", SQLDataType.VARCHAR.notNull()); Field ID = DSL.field("id", SQLDataType.UUID.notNull()); - Field EXPIRES = DSL.field("expires", PostgresCommons.DataTypes.TIMESTAMP); + Field EXPIRES = DSL.field("expires", PostgresCommons.DataTypes.TIMESTAMP_WITH_TIMEZONE); Field TYPES = DSL.field("types", PostgresCommons.DataTypes.STRING_ARRAY.notNull()); Field URL = DSL.field("url", SQLDataType.VARCHAR.notNull()); diff --git a/server/data/data-jmap/src/test/scala/org/apache/james/jmap/api/pushsubscription/PushSubscriptionRepositoryContract.scala b/server/data/data-jmap/src/test/scala/org/apache/james/jmap/api/pushsubscription/PushSubscriptionRepositoryContract.scala index 78a013f5ae0..7b2caba10c4 100644 --- a/server/data/data-jmap/src/test/scala/org/apache/james/jmap/api/pushsubscription/PushSubscriptionRepositoryContract.scala +++ b/server/data/data-jmap/src/test/scala/org/apache/james/jmap/api/pushsubscription/PushSubscriptionRepositoryContract.scala @@ -450,5 +450,24 @@ trait PushSubscriptionRepositoryContract { .isInstanceOf(classOf[InvalidPushSubscriptionKeys]) } + @Test + def updateShouldUpdateCorrectOffsetDateTime(): Unit = { + val validRequest = PushSubscriptionCreationRequest( + deviceClientId = DeviceClientId("1"), + url = PushSubscriptionServerURL(new URL("https://example.com/push")), + types = Seq(CustomTypeName1)) + + val pushSubscriptionId = SMono.fromPublisher(testee.save(ALICE, validRequest)).block().id + + val ZONE_ID: ZoneId = ZoneId.of("Europe/Paris") + val CLOCK: Clock = Clock.fixed(Instant.parse("2021-10-25T07:05:39.160Z"), ZONE_ID) + + val zonedDateTime: ZonedDateTime = ZonedDateTime.now(CLOCK) + SMono.fromPublisher(testee.updateExpireTime(ALICE, pushSubscriptionId, zonedDateTime)).block() + + val updatedSubscription = SFlux.fromPublisher(testee.get(ALICE, Set(pushSubscriptionId).asJava)).blockFirst().get + assertThat(updatedSubscription.expires.value).isEqualTo(zonedDateTime) + } + } From e22e8972139b7202fe7b07ac5d61b9ad4fd677b9 Mon Sep 17 00:00:00 2001 From: hung phan Date: Tue, 20 Feb 2024 18:09:35 +0700 Subject: [PATCH 235/341] JAMES-2586 Integration tests for JMAP postgres --- Jenkinsfile | 1 + .../backends/postgres/PostgresExtension.java | 14 ++ .../org/apache/james/PostgresJmapModule.java | 3 +- .../mailbox/PostgresMailboxModule.java | 3 +- .../PushSubscriptionSetMethodContract.scala | 3 + .../contract/QuotaGetMethodContract.scala | 134 +++++++++--------- .../jmap-rfc-8621-integration-tests/pom.xml | 1 + .../pom.xml | 97 +++++++++++++ .../postgres/PostgresAuthenticationTest.java | 57 ++++++++ .../jmap/rfc8621/postgres/PostgresBase.java | 59 ++++++++ .../postgres/PostgresCustomMethodTest.java | 58 ++++++++ .../postgres/PostgresCustomNamespaceTest.java | 58 ++++++++ ...PostgresDelegatedAccountGetMethodTest.java | 25 ++++ .../PostgresDelegatedAccountSetTest.java | 25 ++++ .../postgres/PostgresDownloadTest.java | 33 +++++ .../postgres/PostgresEchoMethodTest.java | 25 ++++ .../PostgresEmailChangesMethodTest.java | 69 +++++++++ .../postgres/PostgresEmailGetMethodTest.java | 33 +++++ .../PostgresEmailQueryMethodTest.java | 25 ++++ .../postgres/PostgresEmailSetMethodTest.java | 53 +++++++ ...lSubmissionSetMethodFutureReleaseTest.java | 95 +++++++++++++ .../PostgresEmailSubmissionSetMethodTest.java | 33 +++++ .../postgres/PostgresIdentityGetTest.java | 25 ++++ .../postgres/PostgresIdentitySetTest.java | 25 ++++ .../postgres/PostgresMDNParseMethodTest.java | 33 +++++ .../postgres/PostgresMDNSendMethodTest.java | 33 +++++ .../PostgresMailboxChangesMethodTest.java | 76 ++++++++++ .../PostgresMailboxGetMethodTest.java | 31 ++++ .../PostgresMailboxQueryChangesTest.java | 25 ++++ .../PostgresMailboxQueryMethodTest.java | 25 ++++ .../PostgresMailboxSetMethodTest.java | 51 +++++++ .../postgres/PostgresProvisioningTest.java | 25 ++++ ...PostgresPushSubscriptionSetMethodTest.java | 64 +++++++++ .../PostgresQuotaChangesMethodTest.java | 25 ++++ .../postgres/PostgresQuotaGetMethodTest.java | 25 ++++ .../PostgresQuotaQueryMethodTest.java | 25 ++++ .../postgres/PostgresSessionRoutesTest.java | 25 ++++ .../postgres/PostgresThreadGetTest.java | 115 +++++++++++++++ .../rfc8621/postgres/PostgresUploadTest.java | 25 ++++ ...PostgresVacationResponseGetMethodTest.java | 25 ++++ ...PostgresVacationResponseSetMethodTest.java | 25 ++++ .../rfc8621/postgres/PostgresWebPushTest.java | 68 +++++++++ .../postgres/PostgresWebSocketTest.java | 25 ++++ .../src/test/resources/dnsservice.xml | 25 ++++ .../src/test/resources/domainlist.xml | 24 ++++ .../src/test/resources/imapserver.xml | 24 ++++ .../src/test/resources/jmap.properties | 7 + .../src/test/resources/keystore | Bin 0 -> 2245 bytes .../src/test/resources/listeners.xml | 26 ++++ .../src/test/resources/mailetcontainer.xml | 98 +++++++++++++ .../test/resources/mailrepositorystore.xml | 30 ++++ .../src/test/resources/managesieveserver.xml | 32 +++++ .../src/test/resources/pop3server.xml | 23 +++ .../src/test/resources/rabbitmq.properties | 2 + .../src/test/resources/smtpserver.xml | 53 +++++++ .../src/test/resources/usersrepository.xml | 25 ++++ 56 files changed, 2001 insertions(+), 68 deletions(-) create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/pom.xml create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresAuthenticationTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresBase.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresCustomMethodTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresCustomNamespaceTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresDelegatedAccountGetMethodTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresDelegatedAccountSetTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresDownloadTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEchoMethodTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailChangesMethodTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailGetMethodTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailQueryMethodTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailSetMethodTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailSubmissionSetMethodFutureReleaseTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailSubmissionSetMethodTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresIdentityGetTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresIdentitySetTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMDNParseMethodTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMDNSendMethodTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxChangesMethodTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxGetMethodTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxQueryChangesTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxQueryMethodTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxSetMethodTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresProvisioningTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresPushSubscriptionSetMethodTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresQuotaChangesMethodTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresQuotaGetMethodTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresQuotaQueryMethodTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresSessionRoutesTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresThreadGetTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresUploadTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresVacationResponseGetMethodTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresVacationResponseSetMethodTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresWebPushTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresWebSocketTest.java create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/dnsservice.xml create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/domainlist.xml create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/imapserver.xml create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/jmap.properties create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/keystore create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/listeners.xml create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/mailetcontainer.xml create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/mailrepositorystore.xml create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/managesieveserver.xml create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/pop3server.xml create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/rabbitmq.properties create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/smtpserver.xml create mode 100644 server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/usersrepository.xml diff --git a/Jenkinsfile b/Jenkinsfile index abee6197ddf..56d954fc2ac 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -46,6 +46,7 @@ pipeline { 'server/container/guice/postgres-common,' + 'server/container/guice/mailbox-postgres,' + 'server/apps/postgres-app,' + + 'server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests,' + 'server/protocols/webadmin-integration-test/postgres-webadmin-integration-test,' + 'mpt/impl/imap-mailbox/postgres,' + 'event-bus/postgres,' + diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index bde60c3d4b1..e21f846a1b0 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -178,6 +178,10 @@ public void beforeEach(ExtensionContext extensionContext) { @Override public void afterEach(ExtensionContext extensionContext) { resetSchema(); + + if (!rlsEnabled) { + dropAllConnections(); + } } public void restartContainer() { @@ -250,4 +254,14 @@ private List listAllTables() { .collectList() .block(); } + + private void dropAllConnections() { + postgresExecutor.connection() + .flatMapMany(connection -> connection.createStatement(String.format("SELECT pg_terminate_backend(pid) " + + "FROM pg_stat_activity " + + "WHERE datname = '%s' AND pid != pg_backend_pid();", selectedDatabase.dbName())) + .execute()) + .then() + .block(); + } } diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java index 1952bfe1815..566b91e3747 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java @@ -29,6 +29,7 @@ import org.apache.james.jmap.postgres.PostgresDataJMapAggregateModule; import org.apache.james.jmap.postgres.change.PostgresEmailChangeRepository; import org.apache.james.jmap.postgres.change.PostgresMailboxChangeRepository; +import org.apache.james.jmap.postgres.change.PostgresStateFactory; import org.apache.james.jmap.postgres.pushsubscription.PostgresPushSubscriptionRepository; import org.apache.james.jmap.postgres.upload.PostgresUploadUsageRepository; import org.apache.james.mailbox.AttachmentManager; @@ -67,7 +68,7 @@ protected void configure() { bind(RightManager.class).to(StoreRightManager.class); bind(StoreRightManager.class).in(Scopes.SINGLETON); - bind(State.Factory.class).toInstance(State.Factory.DEFAULT); + bind(State.Factory.class).to(PostgresStateFactory.class); bind(PushSubscriptionRepository.class).to(PostgresPushSubscriptionRepository.class); } diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java index 9597503463f..bde8e18b3be 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java @@ -23,6 +23,7 @@ import javax.inject.Singleton; import org.apache.james.adapter.mailbox.ACLUsernameChangeTaskStep; +import org.apache.james.adapter.mailbox.DelegationStoreAuthorizator; import org.apache.james.adapter.mailbox.MailboxUserDeletionTaskStep; import org.apache.james.adapter.mailbox.MailboxUsernameChangeTaskStep; import org.apache.james.adapter.mailbox.QuotaUsernameChangeTaskStep; @@ -122,7 +123,7 @@ protected void configure() { bind(MailboxManager.class).to(PostgresMailboxManager.class); bind(StoreMailboxManager.class).to(PostgresMailboxManager.class); bind(SessionProvider.class).to(SessionProviderImpl.class); - bind(Authorizator.class).to(UserRepositoryAuthorizator.class); + bind(Authorizator.class).to(DelegationStoreAuthorizator.class); bind(MailboxId.Factory.class).to(PostgresMailboxId.Factory.class); bind(MailboxACLResolver.class).to(UnionMailboxACLResolver.class); bind(MessageIdManager.class).to(StoreMessageIdManager.class); diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/PushSubscriptionSetMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/PushSubscriptionSetMethodContract.scala index 705ff2ea707..1902eff2fe5 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/PushSubscriptionSetMethodContract.scala +++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/PushSubscriptionSetMethodContract.scala @@ -38,6 +38,8 @@ import io.restassured.RestAssured.{`given`, requestSpecification} import io.restassured.http.ContentType.JSON import jakarta.inject.Inject import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson +import net.javacrumbs.jsonunit.core.Option.IGNORING_ARRAY_ORDER +import net.javacrumbs.jsonunit.core.internal.Options import org.apache.http.HttpStatus.SC_OK import org.apache.james.GuiceJamesServer import org.apache.james.core.Username @@ -913,6 +915,7 @@ trait PushSubscriptionSetMethodContract { .asString assertThatJson(response) + .withOptions(new Options(IGNORING_ARRAY_ORDER)) .isEqualTo( s"""{ | "sessionState": "${SESSION_STATE.value}", diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/QuotaGetMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/QuotaGetMethodContract.scala index 90cc7a112d3..2e96cbbb9f5 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/QuotaGetMethodContract.scala +++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/QuotaGetMethodContract.scala @@ -501,73 +501,75 @@ trait QuotaGetMethodContract { .build)) .getMessageId.serialize() - val response = `given` - .body( - s"""{ - | "using": [ - | "urn:ietf:params:jmap:core", - | "urn:ietf:params:jmap:quota"], - | "methodCalls": [[ - | "Quota/get", - | { - | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", - | "ids": null - | }, - | "c1"]] - |}""".stripMargin) - .when - .post - .`then` - .statusCode(SC_OK) - .contentType(JSON) - .extract - .body - .asString + awaitAtMostTenSeconds.untilAsserted(() => { + val response = `given` + .body( + s"""{ + | "using": [ + | "urn:ietf:params:jmap:core", + | "urn:ietf:params:jmap:quota"], + | "methodCalls": [[ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "ids": null + | }, + | "c1"]] + |}""".stripMargin) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString - assertThatJson(response) - .withOptions(IGNORING_ARRAY_ORDER) - .isEqualTo( - s"""{ - | "sessionState": "${SESSION_STATE.value}", - | "methodResponses": [ - | [ - | "Quota/get", - | { - | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", - | "notFound": [ ], - | "state": "3c51d50a-d766-38b7-9fa4-c9ff12de87a4", - | "list": [ - | { - | "used": 1, - | "name": "#private&bob@domain.tld@domain.tld:account:count:Mail", - | "id": "08417be420b6dd6fa77d48fb2438e0d19108cd29424844bb109b52d356fab528", - | "types": [ - | "Mail" - | ], - | "hardLimit": 100, - | "warnLimit": 90, - | "resourceType": "count", - | "scope": "account" - | }, - | { - | "used": 85, - | "name": "#private&bob@domain.tld@domain.tld:account:octets:Mail", - | "id": "eab6ce8ac5d9730a959e614854410cf39df98ff3760a623b8e540f36f5184947", - | "types": [ - | "Mail" - | ], - | "hardLimit": 900, - | "warnLimit": 810, - | "resourceType": "octets", - | "scope": "account" - | } - | ] - | }, - | "c1" - | ] - | ] - |} - |""".stripMargin) + assertThatJson(response) + .withOptions(IGNORING_ARRAY_ORDER) + .isEqualTo( + s"""{ + | "sessionState": "${SESSION_STATE.value}", + | "methodResponses": [ + | [ + | "Quota/get", + | { + | "accountId": "29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6", + | "notFound": [ ], + | "state": "3c51d50a-d766-38b7-9fa4-c9ff12de87a4", + | "list": [ + | { + | "used": 1, + | "name": "#private&bob@domain.tld@domain.tld:account:count:Mail", + | "id": "08417be420b6dd6fa77d48fb2438e0d19108cd29424844bb109b52d356fab528", + | "types": [ + | "Mail" + | ], + | "hardLimit": 100, + | "warnLimit": 90, + | "resourceType": "count", + | "scope": "account" + | }, + | { + | "used": 85, + | "name": "#private&bob@domain.tld@domain.tld:account:octets:Mail", + | "id": "eab6ce8ac5d9730a959e614854410cf39df98ff3760a623b8e540f36f5184947", + | "types": [ + | "Mail" + | ], + | "hardLimit": 900, + | "warnLimit": 810, + | "resourceType": "octets", + | "scope": "account" + | } + | ] + | }, + | "c1" + | ] + | ] + |} + |""".stripMargin) + }) } diff --git a/server/protocols/jmap-rfc-8621-integration-tests/pom.xml b/server/protocols/jmap-rfc-8621-integration-tests/pom.xml index 8eed57415a7..e1e2432213d 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/pom.xml +++ b/server/protocols/jmap-rfc-8621-integration-tests/pom.xml @@ -34,6 +34,7 @@ distributed-jmap-rfc-8621-integration-tests jmap-rfc-8621-integration-tests-common memory-jmap-rfc-8621-integration-tests + postgres-jmap-rfc-8621-integration-tests diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/pom.xml b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/pom.xml new file mode 100644 index 00000000000..741f1160410 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/pom.xml @@ -0,0 +1,97 @@ + + + + + 4.0.0 + + org.apache.james + jmap-rfc-8621-integration-tests + 3.9.0-SNAPSHOT + + postgres-jmap-rfc-8621-integration-tests + Apache James :: Server :: JMAP RFC-8621 :: Postgres Integration Testing + JMAP RFC-8621 integration test for postgres product + + + + ${james.groupId} + apache-james-backends-opensearch + test-jar + test + + + ${james.groupId} + apache-james-backends-postgres + test-jar + test + + + ${james.groupId} + apache-james-backends-rabbitmq + test-jar + test + + + ${james.groupId} + james-server-guice-common + test-jar + test + + + ${james.groupId} + james-server-guice-jmap + test-jar + test + + + ${james.groupId} + james-server-guice-opensearch + ${project.version} + test-jar + test + + + ${james.groupId} + james-server-postgres-app + test + + + ${james.groupId} + james-server-postgres-app + test-jar + test + + + ${project.groupId} + james-server-testing + test + + + ${project.groupId} + jmap-rfc-8621-integration-tests-common + test + + + org.testcontainers + postgresql + test + + + diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresAuthenticationTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresAuthenticationTest.java new file mode 100644 index 00000000000..57e4f56dcac --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresAuthenticationTest.java @@ -0,0 +1,57 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.PostgresJamesConfiguration; +import org.apache.james.PostgresJamesServerMain; +import org.apache.james.SearchConfiguration; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.jmap.rfc8621.contract.AuthenticationContract; +import org.apache.james.modules.RabbitMQExtension; +import org.apache.james.modules.TestJMAPServerModule; +import org.apache.james.modules.blobstore.BlobStoreConfiguration; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresAuthenticationTest implements AuthenticationContract { + @RegisterExtension + static JamesServerExtension testExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.scanning()) + .usersRepository(DEFAULT) + .eventBusImpl(PostgresJamesConfiguration.EventBusImpl.RABBITMQ) + .blobStore(BlobStoreConfiguration.builder() + .postgres() + .disableCache() + .deduplication() + .noCryptoConfig()) + .build()) + .extension(PostgresExtension.empty()) + .extension(new RabbitMQExtension()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(new TestJMAPServerModule())) + .lifeCycle(JamesServerExtension.Lifecycle.PER_ENCLOSING_CLASS) + .build(); +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresBase.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresBase.java new file mode 100644 index 00000000000..3c33d39221d --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresBase.java @@ -0,0 +1,59 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.PostgresJamesConfiguration; +import org.apache.james.PostgresJamesServerMain; +import org.apache.james.SearchConfiguration; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.jmap.rfc8621.contract.IdentityProbeModule; +import org.apache.james.jmap.rfc8621.contract.probe.DelegationProbeModule; +import org.apache.james.modules.RabbitMQExtension; +import org.apache.james.modules.TestJMAPServerModule; +import org.apache.james.modules.blobstore.BlobStoreConfiguration; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresBase { + @RegisterExtension + static JamesServerExtension testExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.scanning()) + .usersRepository(DEFAULT) + .eventBusImpl(PostgresJamesConfiguration.EventBusImpl.RABBITMQ) + .blobStore(BlobStoreConfiguration.builder() + .postgres() + .disableCache() + .deduplication() + .noCryptoConfig()) + .build()) + .extension(PostgresExtension.empty()) + .extension(new RabbitMQExtension()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(new TestJMAPServerModule()) + .overrideWith(new DelegationProbeModule()) + .overrideWith(new IdentityProbeModule())) + .build(); +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresCustomMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresCustomMethodTest.java new file mode 100644 index 00000000000..37f55fe9e12 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresCustomMethodTest.java @@ -0,0 +1,58 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.PostgresJamesConfiguration; +import org.apache.james.PostgresJamesServerMain; +import org.apache.james.SearchConfiguration; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.jmap.rfc8621.contract.CustomMethodContract; +import org.apache.james.jmap.rfc8621.contract.CustomMethodModule; +import org.apache.james.modules.RabbitMQExtension; +import org.apache.james.modules.TestJMAPServerModule; +import org.apache.james.modules.blobstore.BlobStoreConfiguration; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresCustomMethodTest implements CustomMethodContract { + @RegisterExtension + static JamesServerExtension testExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.scanning()) + .usersRepository(DEFAULT) + .eventBusImpl(PostgresJamesConfiguration.EventBusImpl.RABBITMQ) + .blobStore(BlobStoreConfiguration.builder() + .postgres() + .disableCache() + .deduplication() + .noCryptoConfig()) + .build()) + .extension(PostgresExtension.empty()) + .extension(new RabbitMQExtension()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(new TestJMAPServerModule()) + .overrideWith(new CustomMethodModule())) + .build(); +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresCustomNamespaceTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresCustomNamespaceTest.java new file mode 100644 index 00000000000..f6bef51a269 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresCustomNamespaceTest.java @@ -0,0 +1,58 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.PostgresJamesConfiguration; +import org.apache.james.PostgresJamesServerMain; +import org.apache.james.SearchConfiguration; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.jmap.rfc8621.contract.CustomNamespaceContract; +import org.apache.james.jmap.rfc8621.contract.CustomNamespaceModule; +import org.apache.james.modules.RabbitMQExtension; +import org.apache.james.modules.TestJMAPServerModule; +import org.apache.james.modules.blobstore.BlobStoreConfiguration; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresCustomNamespaceTest implements CustomNamespaceContract { + @RegisterExtension + static JamesServerExtension testExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.scanning()) + .usersRepository(DEFAULT) + .eventBusImpl(PostgresJamesConfiguration.EventBusImpl.RABBITMQ) + .blobStore(BlobStoreConfiguration.builder() + .postgres() + .disableCache() + .deduplication() + .noCryptoConfig()) + .build()) + .extension(PostgresExtension.empty()) + .extension(new RabbitMQExtension()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(new TestJMAPServerModule()) + .overrideWith(new CustomNamespaceModule())) + .build(); +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresDelegatedAccountGetMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresDelegatedAccountGetMethodTest.java new file mode 100644 index 00000000000..b95cb50b1d4 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresDelegatedAccountGetMethodTest.java @@ -0,0 +1,25 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.jmap.rfc8621.contract.DelegatedAccountGetMethodContract; + +public class PostgresDelegatedAccountGetMethodTest extends PostgresBase implements DelegatedAccountGetMethodContract { +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresDelegatedAccountSetTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresDelegatedAccountSetTest.java new file mode 100644 index 00000000000..82b0505a47e --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresDelegatedAccountSetTest.java @@ -0,0 +1,25 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.jmap.rfc8621.contract.DelegatedAccountSetContract; + +public class PostgresDelegatedAccountSetTest extends PostgresBase implements DelegatedAccountSetContract { +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresDownloadTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresDownloadTest.java new file mode 100644 index 00000000000..d26b104bc02 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresDownloadTest.java @@ -0,0 +1,33 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.jmap.rfc8621.contract.DownloadContract; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.postgres.PostgresMessageId; + +public class PostgresDownloadTest extends PostgresBase implements DownloadContract { + public static final PostgresMessageId.Factory MESSAGE_ID_FACTORY = new PostgresMessageId.Factory(); + + @Override + public MessageId randomMessageId() { + return MESSAGE_ID_FACTORY.generate(); + } +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEchoMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEchoMethodTest.java new file mode 100644 index 00000000000..83bef32ee2a --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEchoMethodTest.java @@ -0,0 +1,25 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.jmap.rfc8621.contract.EchoMethodContract; + +public class PostgresEchoMethodTest extends PostgresBase implements EchoMethodContract { +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailChangesMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailChangesMethodTest.java new file mode 100644 index 00000000000..0bc5bdae280 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailChangesMethodTest.java @@ -0,0 +1,69 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.PostgresJamesConfiguration; +import org.apache.james.PostgresJamesServerMain; +import org.apache.james.SearchConfiguration; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.jmap.api.change.Limit; +import org.apache.james.jmap.api.change.State; +import org.apache.james.jmap.postgres.change.PostgresEmailChangeRepository; +import org.apache.james.jmap.postgres.change.PostgresStateFactory; +import org.apache.james.jmap.rfc8621.contract.EmailChangesMethodContract; +import org.apache.james.modules.RabbitMQExtension; +import org.apache.james.modules.TestJMAPServerModule; +import org.apache.james.modules.blobstore.BlobStoreConfiguration; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.google.inject.name.Names; + +public class PostgresEmailChangesMethodTest implements EmailChangesMethodContract { + @RegisterExtension + static JamesServerExtension testExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.scanning()) + .usersRepository(DEFAULT) + .eventBusImpl(PostgresJamesConfiguration.EventBusImpl.RABBITMQ) + .blobStore(BlobStoreConfiguration.builder() + .postgres() + .disableCache() + .deduplication() + .noCryptoConfig()) + .build()) + .extension(PostgresExtension.empty()) + .extension(new RabbitMQExtension()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(new TestJMAPServerModule()) + .overrideWith(binder -> binder.bind(Limit.class).annotatedWith(Names.named(PostgresEmailChangeRepository.LIMIT_NAME)).toInstance(Limit.of(5))) + .overrideWith(binder -> binder.bind(Limit.class).annotatedWith(Names.named(PostgresEmailChangeRepository.LIMIT_NAME)).toInstance(Limit.of(5)))) + .build(); + + @Override + public State.Factory stateFactory() { + return new PostgresStateFactory(); + } +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailGetMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailGetMethodTest.java new file mode 100644 index 00000000000..43e5c293fc1 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailGetMethodTest.java @@ -0,0 +1,33 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.jmap.rfc8621.contract.EmailGetMethodContract; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.postgres.PostgresMessageId; + +public class PostgresEmailGetMethodTest extends PostgresBase implements EmailGetMethodContract { + public static final PostgresMessageId.Factory MESSAGE_ID_FACTORY = new PostgresMessageId.Factory(); + + @Override + public MessageId randomMessageId() { + return MESSAGE_ID_FACTORY.generate(); + } +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailQueryMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailQueryMethodTest.java new file mode 100644 index 00000000000..814292041d5 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailQueryMethodTest.java @@ -0,0 +1,25 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.jmap.rfc8621.contract.EmailQueryMethodContract; + +public class PostgresEmailQueryMethodTest extends PostgresBase implements EmailQueryMethodContract { +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailSetMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailSetMethodTest.java new file mode 100644 index 00000000000..f7b87a67ce1 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailSetMethodTest.java @@ -0,0 +1,53 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.GuiceJamesServer; +import org.apache.james.jmap.rfc8621.contract.EmailSetMethodContract; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +public class PostgresEmailSetMethodTest extends PostgresBase implements EmailSetMethodContract { + public static final PostgresMessageId.Factory MESSAGE_ID_FACTORY = new PostgresMessageId.Factory(); + + @Override + public MessageId randomMessageId() { + return MESSAGE_ID_FACTORY.generate(); + } + + @Override + public String invalidMessageIdMessage(String invalid) { + return String.format("Invalid UUID string: %s", invalid); + } + + @Override + @Test + @Disabled("Distributed event bus is asynchronous, we cannot expect the newState to be returned immediately after Email/set call") + public void newStateShouldBeUpToDate(GuiceJamesServer server) { + } + + @Override + @Test + @Disabled("Distributed event bus is asynchronous, we cannot expect the newState to be returned immediately after Email/set call") + public void oldStateShouldIncludeSetChanges(GuiceJamesServer server) { + } +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailSubmissionSetMethodFutureReleaseTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailSubmissionSetMethodFutureReleaseTest.java new file mode 100644 index 00000000000..4d189374bc9 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailSubmissionSetMethodFutureReleaseTest.java @@ -0,0 +1,95 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.apache.james.ClockExtension; +import org.apache.james.GuiceJamesServer; +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.PostgresJamesConfiguration; +import org.apache.james.PostgresJamesServerMain; +import org.apache.james.SearchConfiguration; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.jmap.rfc8621.contract.EmailSubmissionSetMethodFutureReleaseContract; +import org.apache.james.jmap.rfc8621.contract.probe.DelegationProbeModule; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.modules.RabbitMQExtension; +import org.apache.james.modules.TestJMAPServerModule; +import org.apache.james.modules.blobstore.BlobStoreConfiguration; +import org.apache.james.utils.UpdatableTickingClock; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.google.inject.name.Names; + +public class PostgresEmailSubmissionSetMethodFutureReleaseTest implements EmailSubmissionSetMethodFutureReleaseContract { + public static final PostgresMessageId.Factory MESSAGE_ID_FACTORY = new PostgresMessageId.Factory(); + + @RegisterExtension + static JamesServerExtension testExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.scanning()) + .usersRepository(DEFAULT) + .eventBusImpl(PostgresJamesConfiguration.EventBusImpl.RABBITMQ) + .blobStore(BlobStoreConfiguration.builder() + .postgres() + .disableCache() + .deduplication() + .noCryptoConfig()) + .build()) + .extension(PostgresExtension.empty()) + .extension(new RabbitMQExtension()) + .extension(new ClockExtension()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(new TestJMAPServerModule()) + .overrideWith(new DelegationProbeModule())) + .overrideServerModule(binder -> binder.bind(Boolean.class).annotatedWith(Names.named("supportsDelaySends")).toInstance(true)) + .build(); + + @Override + public MessageId randomMessageId() { + return MESSAGE_ID_FACTORY.generate(); + } + + @Disabled("Not work for postgres test") + @Override + public void emailSubmissionSetCreateShouldDeliverEmailWhenHoldForExpired(GuiceJamesServer server, UpdatableTickingClock updatableTickingClock){ + } + + @Disabled("Not work for postgres test") + @Override + public void emailSubmissionSetCreateShouldDeliverEmailWhenHoldUntilExpired(GuiceJamesServer server, UpdatableTickingClock updatableTickingClock){ + } + + @Disabled("Not work for postgres test") + @Override + public void emailSubmissionSetCreateShouldDelayEmailWithHoldFor(GuiceJamesServer server, UpdatableTickingClock updatableTickingClock){ + } + + @Disabled("Not work for postgres test") + @Override + public void emailSubmissionSetCreateShouldDelayEmailWithHoldUntil(GuiceJamesServer server, UpdatableTickingClock updatableTickingClock){ + } +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailSubmissionSetMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailSubmissionSetMethodTest.java new file mode 100644 index 00000000000..536a5928d3f --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailSubmissionSetMethodTest.java @@ -0,0 +1,33 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.jmap.rfc8621.contract.EmailSubmissionSetMethodContract; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.postgres.PostgresMessageId; + +public class PostgresEmailSubmissionSetMethodTest extends PostgresBase implements EmailSubmissionSetMethodContract { + public static final PostgresMessageId.Factory MESSAGE_ID_FACTORY = new PostgresMessageId.Factory(); + + @Override + public MessageId randomMessageId() { + return MESSAGE_ID_FACTORY.generate(); + } +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresIdentityGetTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresIdentityGetTest.java new file mode 100644 index 00000000000..6bbddd16a9c --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresIdentityGetTest.java @@ -0,0 +1,25 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.jmap.rfc8621.contract.IdentityGetContract; + +public class PostgresIdentityGetTest extends PostgresBase implements IdentityGetContract { +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresIdentitySetTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresIdentitySetTest.java new file mode 100644 index 00000000000..b00cd3e2438 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresIdentitySetTest.java @@ -0,0 +1,25 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.jmap.rfc8621.contract.IdentitySetContract; + +public class PostgresIdentitySetTest extends PostgresBase implements IdentitySetContract { +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMDNParseMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMDNParseMethodTest.java new file mode 100644 index 00000000000..135c9073507 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMDNParseMethodTest.java @@ -0,0 +1,33 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.jmap.rfc8621.contract.MDNParseMethodContract; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.postgres.PostgresMessageId; + +public class PostgresMDNParseMethodTest extends PostgresBase implements MDNParseMethodContract { + public static final PostgresMessageId.Factory MESSAGE_ID_FACTORY = new PostgresMessageId.Factory(); + + @Override + public MessageId randomMessageId() { + return MESSAGE_ID_FACTORY.generate(); + } +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMDNSendMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMDNSendMethodTest.java new file mode 100644 index 00000000000..1c57e5682d4 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMDNSendMethodTest.java @@ -0,0 +1,33 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.jmap.rfc8621.contract.MDNSendMethodContract; +import org.apache.james.mailbox.model.MessageId; +import org.apache.james.mailbox.postgres.PostgresMessageId; + +public class PostgresMDNSendMethodTest extends PostgresBase implements MDNSendMethodContract { + public static final PostgresMessageId.Factory MESSAGE_ID_FACTORY = new PostgresMessageId.Factory(); + + @Override + public MessageId randomMessageId() { + return MESSAGE_ID_FACTORY.generate(); + } +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxChangesMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxChangesMethodTest.java new file mode 100644 index 00000000000..e2b013b15e4 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxChangesMethodTest.java @@ -0,0 +1,76 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.PostgresJamesConfiguration; +import org.apache.james.PostgresJamesServerMain; +import org.apache.james.SearchConfiguration; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.jmap.api.change.Limit; +import org.apache.james.jmap.api.change.State; +import org.apache.james.jmap.postgres.change.PostgresMailboxChangeRepository; +import org.apache.james.jmap.postgres.change.PostgresStateFactory; +import org.apache.james.jmap.rfc8621.contract.MailboxChangesMethodContract; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.modules.RabbitMQExtension; +import org.apache.james.modules.TestJMAPServerModule; +import org.apache.james.modules.blobstore.BlobStoreConfiguration; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.google.inject.name.Names; + +public class PostgresMailboxChangesMethodTest implements MailboxChangesMethodContract { + @RegisterExtension + static JamesServerExtension testExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.scanning()) + .usersRepository(DEFAULT) + .eventBusImpl(PostgresJamesConfiguration.EventBusImpl.RABBITMQ) + .blobStore(BlobStoreConfiguration.builder() + .postgres() + .disableCache() + .deduplication() + .noCryptoConfig()) + .build()) + .extension(PostgresExtension.empty()) + .extension(new RabbitMQExtension()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(new TestJMAPServerModule()) + .overrideWith(binder -> binder.bind(Limit.class).annotatedWith(Names.named(PostgresMailboxChangeRepository.LIMIT_NAME)).toInstance(Limit.of(5))) + .overrideWith(binder -> binder.bind(Limit.class).annotatedWith(Names.named(PostgresMailboxChangeRepository.LIMIT_NAME)).toInstance(Limit.of(5)))) + .build(); + + @Override + public State.Factory stateFactory() { + return new PostgresStateFactory(); + } + + @Override + public MailboxId generateMailboxId() { + return PostgresMailboxId.generate(); + } +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxGetMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxGetMethodTest.java new file mode 100644 index 00000000000..8632344dc44 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxGetMethodTest.java @@ -0,0 +1,31 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.jmap.rfc8621.contract.MailboxGetMethodContract; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.postgres.PostgresMailboxId; + +public class PostgresMailboxGetMethodTest extends PostgresBase implements MailboxGetMethodContract { + @Override + public MailboxId randomMailboxId() { + return PostgresMailboxId.generate(); + } +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxQueryChangesTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxQueryChangesTest.java new file mode 100644 index 00000000000..47a3abdc567 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxQueryChangesTest.java @@ -0,0 +1,25 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.jmap.rfc8621.contract.MailboxQueryChangesContract; + +public class PostgresMailboxQueryChangesTest extends PostgresBase implements MailboxQueryChangesContract { +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxQueryMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxQueryMethodTest.java new file mode 100644 index 00000000000..f64a44f89c3 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxQueryMethodTest.java @@ -0,0 +1,25 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.jmap.rfc8621.contract.MailboxQueryMethodContract; + +public class PostgresMailboxQueryMethodTest extends PostgresBase implements MailboxQueryMethodContract { +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxSetMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxSetMethodTest.java new file mode 100644 index 00000000000..8346421fb1e --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxSetMethodTest.java @@ -0,0 +1,51 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.GuiceJamesServer; +import org.apache.james.jmap.rfc8621.contract.MailboxSetMethodContract; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +public class PostgresMailboxSetMethodTest extends PostgresBase implements MailboxSetMethodContract { + @Override + public MailboxId randomMailboxId() { + return PostgresMailboxId.generate(); + } + + @Override + public String errorInvalidMailboxIdMessage(String value) { + return String.format("%s is not a mailboxId: Invalid UUID string: %s", value, value); + } + + @Override + @Test + @Disabled("Distributed event bus is asynchronous, we cannot expect the newState to be returned immediately after Mailbox/set call") + public void newStateShouldBeUpToDate(GuiceJamesServer server) { + } + + @Override + @Test + @Disabled("Distributed event bus is asynchronous, we cannot expect the newState to be returned immediately after Mailbox/set call") + public void oldStateShouldIncludeSetChanges(GuiceJamesServer server) { + } +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresProvisioningTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresProvisioningTest.java new file mode 100644 index 00000000000..83877ba90e3 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresProvisioningTest.java @@ -0,0 +1,25 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.jmap.rfc8621.contract.ProvisioningContract; + +public class PostgresProvisioningTest extends PostgresBase implements ProvisioningContract { +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresPushSubscriptionSetMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresPushSubscriptionSetMethodTest.java new file mode 100644 index 00000000000..93696a33db0 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresPushSubscriptionSetMethodTest.java @@ -0,0 +1,64 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.PostgresJamesConfiguration; +import org.apache.james.PostgresJamesServerMain; +import org.apache.james.SearchConfiguration; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.jmap.pushsubscription.PushClientConfiguration; +import org.apache.james.jmap.rfc8621.contract.PushServerExtension; +import org.apache.james.jmap.rfc8621.contract.PushSubscriptionProbeModule; +import org.apache.james.jmap.rfc8621.contract.PushSubscriptionSetMethodContract; +import org.apache.james.modules.RabbitMQExtension; +import org.apache.james.modules.TestJMAPServerModule; +import org.apache.james.modules.blobstore.BlobStoreConfiguration; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresPushSubscriptionSetMethodTest implements PushSubscriptionSetMethodContract { + @RegisterExtension + static JamesServerExtension testExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.scanning()) + .usersRepository(DEFAULT) + .eventBusImpl(PostgresJamesConfiguration.EventBusImpl.RABBITMQ) + .blobStore(BlobStoreConfiguration.builder() + .postgres() + .disableCache() + .deduplication() + .noCryptoConfig()) + .build()) + .extension(PostgresExtension.empty()) + .extension(new RabbitMQExtension()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(new TestJMAPServerModule()) + .overrideWith(new PushSubscriptionProbeModule()) + .overrideWith(binder -> binder.bind(PushClientConfiguration.class).toInstance(PushClientConfiguration.UNSAFE_DEFAULT()))) + .build(); + + @RegisterExtension + static PushServerExtension pushServerExtension = new PushServerExtension(); +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresQuotaChangesMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresQuotaChangesMethodTest.java new file mode 100644 index 00000000000..f83c7619274 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresQuotaChangesMethodTest.java @@ -0,0 +1,25 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.jmap.rfc8621.contract.QuotaChangesMethodContract; + +public class PostgresQuotaChangesMethodTest extends PostgresBase implements QuotaChangesMethodContract { +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresQuotaGetMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresQuotaGetMethodTest.java new file mode 100644 index 00000000000..a64d8e683ca --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresQuotaGetMethodTest.java @@ -0,0 +1,25 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.jmap.rfc8621.contract.QuotaGetMethodContract; + +public class PostgresQuotaGetMethodTest extends PostgresBase implements QuotaGetMethodContract { +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresQuotaQueryMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresQuotaQueryMethodTest.java new file mode 100644 index 00000000000..558709ab5e5 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresQuotaQueryMethodTest.java @@ -0,0 +1,25 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.jmap.rfc8621.contract.QuotaQueryMethodContract; + +public class PostgresQuotaQueryMethodTest extends PostgresBase implements QuotaQueryMethodContract { +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresSessionRoutesTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresSessionRoutesTest.java new file mode 100644 index 00000000000..9957ff3cf59 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresSessionRoutesTest.java @@ -0,0 +1,25 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.jmap.rfc8621.contract.SessionRoutesContract; + +public class PostgresSessionRoutesTest extends PostgresBase implements SessionRoutesContract { +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresThreadGetTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresThreadGetTest.java new file mode 100644 index 00000000000..aa015772094 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresThreadGetTest.java @@ -0,0 +1,115 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Durations.ONE_HUNDRED_MILLISECONDS; + +import java.io.IOException; +import java.util.List; + +import org.apache.james.DockerOpenSearchExtension; +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.PostgresJamesConfiguration; +import org.apache.james.PostgresJamesServerMain; +import org.apache.james.SearchConfiguration; +import org.apache.james.backends.opensearch.ReactorOpenSearchClient; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.jmap.rfc8621.contract.ThreadGetContract; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.SearchQuery; +import org.apache.james.mailbox.opensearch.MailboxIndexCreationUtil; +import org.apache.james.mailbox.opensearch.MailboxOpenSearchConstants; +import org.apache.james.mailbox.opensearch.query.CriterionConverter; +import org.apache.james.mailbox.opensearch.query.QueryConverter; +import org.apache.james.modules.RabbitMQExtension; +import org.apache.james.modules.TestJMAPServerModule; +import org.apache.james.modules.blobstore.BlobStoreConfiguration; +import org.awaitility.Awaitility; +import org.awaitility.Durations; +import org.awaitility.core.ConditionFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.opensearch.client.opensearch._types.query_dsl.Query; +import org.opensearch.client.opensearch.core.SearchRequest; + +public class PostgresThreadGetTest extends PostgresBase implements ThreadGetContract { + private static final ConditionFactory CALMLY_AWAIT = Awaitility + .with().pollInterval(ONE_HUNDRED_MILLISECONDS) + .and().pollDelay(ONE_HUNDRED_MILLISECONDS) + .await(); + + private final QueryConverter queryConverter = new QueryConverter(new CriterionConverter()); + private ReactorOpenSearchClient client; + + @RegisterExtension + org.apache.james.backends.opensearch.DockerOpenSearchExtension openSearch = new org.apache.james.backends.opensearch.DockerOpenSearchExtension(); + + @RegisterExtension + static JamesServerExtension testExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.openSearch()) + .usersRepository(DEFAULT) + .eventBusImpl(PostgresJamesConfiguration.EventBusImpl.RABBITMQ) + .blobStore(BlobStoreConfiguration.builder() + .postgres() + .disableCache() + .deduplication() + .noCryptoConfig()) + .build()) + .extension(PostgresExtension.empty()) + .extension(new RabbitMQExtension()) + .extension(new DockerOpenSearchExtension()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(new TestJMAPServerModule())) + .build(); + + @AfterEach + void tearDown() throws IOException { + client.close(); + } + + @Override + public void awaitMessageCount(List mailboxIds, SearchQuery query, long messageCount) { + awaitForOpenSearch(queryConverter.from(mailboxIds, query), messageCount); + } + + @Override + public void initOpenSearchClient() { + client = MailboxIndexCreationUtil.prepareDefaultClient( + openSearch.getDockerOpenSearch().clientProvider().get(), + openSearch.getDockerOpenSearch().configuration()); + } + + private void awaitForOpenSearch(Query query, long totalHits) { + CALMLY_AWAIT.atMost(Durations.TEN_SECONDS) + .untilAsserted(() -> assertThat(client.search( + new SearchRequest.Builder() + .index(MailboxOpenSearchConstants.DEFAULT_MAILBOX_INDEX.getValue()) + .query(query) + .build()) + .block() + .hits().total().value()).isEqualTo(totalHits)); + } +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresUploadTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresUploadTest.java new file mode 100644 index 00000000000..b280238f956 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresUploadTest.java @@ -0,0 +1,25 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.jmap.rfc8621.contract.UploadContract; + +public class PostgresUploadTest extends PostgresBase implements UploadContract { +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresVacationResponseGetMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresVacationResponseGetMethodTest.java new file mode 100644 index 00000000000..98aa5ade206 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresVacationResponseGetMethodTest.java @@ -0,0 +1,25 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.jmap.rfc8621.contract.VacationResponseGetMethodContract; + +public class PostgresVacationResponseGetMethodTest extends PostgresBase implements VacationResponseGetMethodContract { +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresVacationResponseSetMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresVacationResponseSetMethodTest.java new file mode 100644 index 00000000000..4ecf2ab5793 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresVacationResponseSetMethodTest.java @@ -0,0 +1,25 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.jmap.rfc8621.contract.VacationResponseSetMethodContract; + +public class PostgresVacationResponseSetMethodTest extends PostgresBase implements VacationResponseSetMethodContract { +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresWebPushTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresWebPushTest.java new file mode 100644 index 00000000000..1849d7994df --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresWebPushTest.java @@ -0,0 +1,68 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.apache.james.ClockExtension; +import org.apache.james.DockerOpenSearchExtension; +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.PostgresJamesConfiguration; +import org.apache.james.PostgresJamesServerMain; +import org.apache.james.SearchConfiguration; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.jmap.pushsubscription.PushClientConfiguration; +import org.apache.james.jmap.rfc8621.contract.PushServerExtension; +import org.apache.james.jmap.rfc8621.contract.PushSubscriptionProbeModule; +import org.apache.james.jmap.rfc8621.contract.WebPushContract; +import org.apache.james.modules.RabbitMQExtension; +import org.apache.james.modules.TestJMAPServerModule; +import org.apache.james.modules.blobstore.BlobStoreConfiguration; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresWebPushTest implements WebPushContract { + @RegisterExtension + static JamesServerExtension testExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.openSearch()) + .usersRepository(DEFAULT) + .eventBusImpl(PostgresJamesConfiguration.EventBusImpl.RABBITMQ) + .blobStore(BlobStoreConfiguration.builder() + .postgres() + .disableCache() + .deduplication() + .noCryptoConfig()) + .build()) + .extension(PostgresExtension.empty()) + .extension(new RabbitMQExtension()) + .extension(new DockerOpenSearchExtension()) + .extension(new ClockExtension()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(new TestJMAPServerModule()) + .overrideWith(new PushSubscriptionProbeModule()) + .overrideWith(binder -> binder.bind(PushClientConfiguration.class).toInstance(PushClientConfiguration.UNSAFE_DEFAULT()))) + .build(); + + @RegisterExtension + static PushServerExtension pushServerExtension = new PushServerExtension(); +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresWebSocketTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresWebSocketTest.java new file mode 100644 index 00000000000..c16d808925c --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresWebSocketTest.java @@ -0,0 +1,25 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.rfc8621.postgres; + +import org.apache.james.jmap.rfc8621.contract.WebSocketContract; + +public class PostgresWebSocketTest extends PostgresBase implements WebSocketContract { +} diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/dnsservice.xml b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/dnsservice.xml new file mode 100644 index 00000000000..6e4fbd2efb5 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/dnsservice.xml @@ -0,0 +1,25 @@ + + + + + true + false + 50000 + diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/domainlist.xml b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/domainlist.xml new file mode 100644 index 00000000000..fe17431a1ea --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/domainlist.xml @@ -0,0 +1,24 @@ + + + + + false + false + diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/imapserver.xml b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/imapserver.xml new file mode 100644 index 00000000000..ead2b342f34 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/imapserver.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/jmap.properties b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/jmap.properties new file mode 100644 index 00000000000..519703e204c --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/jmap.properties @@ -0,0 +1,7 @@ +# Configuration urlPrefix for JMAP routes. +url.prefix=http://domain.com +websocket.url.prefix=ws://domain.com +upload.max.size=20M +webpush.maxTimeoutSeconds=10 +webpush.maxConnections=10 +dynamic.jmap.prefix.resolution.enabled=true \ No newline at end of file diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/keystore b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/keystore new file mode 100644 index 0000000000000000000000000000000000000000..536a6c792b0740ef4273327bf4a61ffc2d6491d8 GIT binary patch literal 2245 zcmchY={pn*7sh8LBQ%y#WJwLmHX~!-n&e4IWZ$x7nXzWyM!ymF9%GH0|)`01HpknC;&o)EW|hTnC0KzVn%TKNE#dU1v||+1tZxX zS_9GsgkCLFCv|_)LvA!S*k!K2h)$={;+p9hHH7Nb0p>KwaVg~IFb3Sc1wDRw9$A){s zjWgyn8QQ_DwD67^UN~?lj{Brp?9aL{)#!V+F@3yd+SXoy#ls2T};RV4e2y4MYI1_L5*8Y+3@jZ}Jq=k;pjN{&W6V&8CnMam*;{LK8_ zVM=cij+9`Yn?R}TQ&+mUIg*K2CR|gqXqw>>3OJI|3T0Q6?~|~GQ+Cq*Ub{W= z#tEY5JH3B7<^Ay^isK!NQlyqlK>%jK4bn-JJ1I_tg1E53mrrAfv?W-!v5v*W1PD^o zxAg%m|LiTHI$`?t4_QyHAX{D{qH>>39tRp>KI;&`pMqjM%_S@a>jO>` z6pB-cdX{xVxy#YMXTrC-^vxG;KHTzHJl8ZO(ySb{-z~l#bcPwmZz!xT*qai`@=~g7 zm%`Wwk)!3E8#0=esd0RL9=xO}l_gdqO`CGH7ked&sARd)5kT$wm= z(V}s9O156MBTz(2khxa8_$Q`dZatu&qt;^pD<4J1$qXsr6Vb23Hu=&yB~!VNc_Jq7 z>VHqD5r3dce|yB1wtClTIY>%O@DHRB{=}X}6o%-w9had83mD84mrS?s_A(A^%{Ybf zRT$$U8`bB!I?xkRBP`95KfExp?{qx}b$oLcb-j z058_v&mR{oY2ohUgL4l=i3{_fF(`FqRg~I!WempdH=@zXD*wg*_c%nL)ISY5{1;#% zkPm<&0%0H`5C}-{<*=1KBbO?SE#xkKMXvqKHKh)AwKZ^R?x7Gq zEJ*}Q`i!-;D;`bn<_(PMs?Z!Azhb;wGdEjk+VigAO}tt$&0gSSAkd^Qu!YeAVl>_P zq$(ep;B$ZZRcA%4lYiy6#UI5)x3Z~7q5Zti`7%_(oi!vm`e!I-%8fY0(DZ6xzl)3s zC8vu)lBpgh%sJWw?xJ&^Lf|}E;FK>dP{OL^>8>odoE0JSm(A1w7;@mTwWsWTaS38liiOoY7+EQJp|1|ONst!#A z0&q=oUM&(2S+u)9)NE3)LgN5Iy~&PWa%6*-3MUjfcyByu7b)f3tpKXQeTd-2|17(3qjJ zuCdt!7~*+Jj-k$)2}|B;vFe5_aZzP>x+f-|h}*dnJi&WkeY1Xb&&jLmqkgpE0spgY zybxo}kn!S$8P;k(zWJ(t|K7IXP**)mv%t;DM3PJALygR(3trmZ)bjb(P7m4wUZX6{ zTa^)O + + + + + org.apache.james.jmap.event.PopulateEmailQueryViewListener + true + + \ No newline at end of file diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/mailetcontainer.xml b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/mailetcontainer.xml new file mode 100644 index 00000000000..f429a43156b --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/mailetcontainer.xml @@ -0,0 +1,98 @@ + + + + + + + + postmaster + + + + 2 + postgres://var/mail/error/ + + + + + + transport + + + + + + ignore + + + + + + ignore + + + + + + + + + + + + + + + + + bcc + + + error + + + ignore + + + ignore + + + ignore + + + + + outgoing + 5000, 100000, 500000 + 3 + 0 + 10 + true + error + + + + error + + + + + + diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/mailrepositorystore.xml b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/mailrepositorystore.xml new file mode 100644 index 00000000000..573ec24ad3e --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/mailrepositorystore.xml @@ -0,0 +1,30 @@ + + + + + + + + + postgres + + + + diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/managesieveserver.xml b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/managesieveserver.xml new file mode 100644 index 00000000000..f136a432b8a --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/managesieveserver.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/pop3server.xml b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/pop3server.xml new file mode 100644 index 00000000000..bec385ae306 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/pop3server.xml @@ -0,0 +1,23 @@ + + + + + + diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/rabbitmq.properties b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/rabbitmq.properties new file mode 100644 index 00000000000..25d0dd6a976 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/rabbitmq.properties @@ -0,0 +1,2 @@ +uri=amqp://james:james@rabbitmq_host:5672 +management.uri=http://james:james@rabbitmq_host:15672/api/ \ No newline at end of file diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/smtpserver.xml b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/smtpserver.xml new file mode 100644 index 00000000000..21dc0a9af9c --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/smtpserver.xml @@ -0,0 +1,53 @@ + + + + + + + smtpserver-global + 0.0.0.0:0 + 200 + + file://conf/keystore + james72laBalle + org.bouncycastle.jce.provider.BouncyCastleProvider + SunX509 + + 360 + 0 + 0 + false + + never + false + true + + 0 + true + Apache JAMES awesome SMTP Server + + + + + false + + + + diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/usersrepository.xml b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/usersrepository.xml new file mode 100644 index 00000000000..f8c8a258722 --- /dev/null +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/resources/usersrepository.xml @@ -0,0 +1,25 @@ + + + + + + true + SHA-1 + From 2c7a172104b0d538e39c39f8f61842a199d14056 Mon Sep 17 00:00:00 2001 From: hung phan Date: Wed, 21 Feb 2024 09:50:29 +0700 Subject: [PATCH 236/341] JAMES-2586 Disable some tests in Integration tests JMAP postgres --- .../postgres/PostgresAuthenticationTest.java | 3 +++ .../postgres/PostgresEmailGetMethodTest.java | 3 +++ .../postgres/PostgresEmailQueryMethodTest.java | 3 +++ .../postgres/PostgresMailboxSetMethodTest.java | 14 ++++++++++++++ .../PostgresPushSubscriptionSetMethodTest.java | 17 +++++++++++++++++ .../rfc8621/postgres/PostgresThreadGetTest.java | 3 +++ 6 files changed, 43 insertions(+) diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresAuthenticationTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresAuthenticationTest.java index 57e4f56dcac..8d1922e72a0 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresAuthenticationTest.java +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresAuthenticationTest.java @@ -31,8 +31,11 @@ import org.apache.james.modules.RabbitMQExtension; import org.apache.james.modules.TestJMAPServerModule; import org.apache.james.modules.blobstore.BlobStoreConfiguration; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.extension.RegisterExtension; +@Disabled +// TODO Need to fix public class PostgresAuthenticationTest implements AuthenticationContract { @RegisterExtension static JamesServerExtension testExtension = new JamesServerBuilder(tmpDir -> diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailGetMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailGetMethodTest.java index 43e5c293fc1..96da1f79eaa 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailGetMethodTest.java +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailGetMethodTest.java @@ -22,7 +22,10 @@ import org.apache.james.jmap.rfc8621.contract.EmailGetMethodContract; import org.apache.james.mailbox.model.MessageId; import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.junit.jupiter.api.Disabled; +@Disabled +// TODO Need to fix public class PostgresEmailGetMethodTest extends PostgresBase implements EmailGetMethodContract { public static final PostgresMessageId.Factory MESSAGE_ID_FACTORY = new PostgresMessageId.Factory(); diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailQueryMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailQueryMethodTest.java index 814292041d5..3d4c3d336c5 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailQueryMethodTest.java +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailQueryMethodTest.java @@ -20,6 +20,9 @@ package org.apache.james.jmap.rfc8621.postgres; import org.apache.james.jmap.rfc8621.contract.EmailQueryMethodContract; +import org.junit.jupiter.api.Disabled; +@Disabled +// TODO Need to fix public class PostgresEmailQueryMethodTest extends PostgresBase implements EmailQueryMethodContract { } diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxSetMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxSetMethodTest.java index 8346421fb1e..20d62878ee6 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxSetMethodTest.java +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxSetMethodTest.java @@ -37,6 +37,20 @@ public String errorInvalidMailboxIdMessage(String value) { return String.format("%s is not a mailboxId: Invalid UUID string: %s", value, value); } + @Override + @Test + @Disabled + // TODO Need to fix + public void webSocketShouldPushNewMessageWhenChangeSubscriptionOfMailbox(GuiceJamesServer server) { + } + + @Override + @Test + @Disabled + // TODO Need to fix + public void updateShouldRenameMailboxesWithManyChildren(GuiceJamesServer server) { + } + @Override @Test @Disabled("Distributed event bus is asynchronous, we cannot expect the newState to be returned immediately after Mailbox/set call") diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresPushSubscriptionSetMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresPushSubscriptionSetMethodTest.java index 93696a33db0..a0aa0a28186 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresPushSubscriptionSetMethodTest.java +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresPushSubscriptionSetMethodTest.java @@ -21,6 +21,7 @@ import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; +import org.apache.james.GuiceJamesServer; import org.apache.james.JamesServerBuilder; import org.apache.james.JamesServerExtension; import org.apache.james.PostgresJamesConfiguration; @@ -34,6 +35,8 @@ import org.apache.james.modules.RabbitMQExtension; import org.apache.james.modules.TestJMAPServerModule; import org.apache.james.modules.blobstore.BlobStoreConfiguration; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; public class PostgresPushSubscriptionSetMethodTest implements PushSubscriptionSetMethodContract { @@ -61,4 +64,18 @@ public class PostgresPushSubscriptionSetMethodTest implements PushSubscriptionSe @RegisterExtension static PushServerExtension pushServerExtension = new PushServerExtension(); + + @Override + @Test + @Disabled + // TODO Need to fix + public void getShouldReturnAllRecords(GuiceJamesServer server) { + } + + @Override + @Test + @Disabled + // TODO Need to fix + public void getByIdShouldReturnRecords(GuiceJamesServer server) { + } } diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresThreadGetTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresThreadGetTest.java index aa015772094..8625fd48106 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresThreadGetTest.java +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresThreadGetTest.java @@ -48,10 +48,13 @@ import org.awaitility.Durations; import org.awaitility.core.ConditionFactory; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.extension.RegisterExtension; import org.opensearch.client.opensearch._types.query_dsl.Query; import org.opensearch.client.opensearch.core.SearchRequest; +@Disabled +// TODO Need to fix public class PostgresThreadGetTest extends PostgresBase implements ThreadGetContract { private static final ConditionFactory CALMLY_AWAIT = Awaitility .with().pollInterval(ONE_HUNDRED_MILLISECONDS) From 971af25943efd00ec27c61687c9af95b1e7bfb26 Mon Sep 17 00:00:00 2001 From: hung phan Date: Fri, 1 Mar 2024 14:09:46 +0700 Subject: [PATCH 237/341] JAMES-2586 Fix PostgresAuthenticationTest --- .../src/test/java/org/apache/james/JamesServerExtension.java | 2 +- .../jmap/rfc8621/postgres/PostgresAuthenticationTest.java | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/server/container/guice/common/src/test/java/org/apache/james/JamesServerExtension.java b/server/container/guice/common/src/test/java/org/apache/james/JamesServerExtension.java index 85ff5ae53bf..b96ff32bd8b 100644 --- a/server/container/guice/common/src/test/java/org/apache/james/JamesServerExtension.java +++ b/server/container/guice/common/src/test/java/org/apache/james/JamesServerExtension.java @@ -214,4 +214,4 @@ private File createTmpDir() { public void await() { awaitCondition.await(); } -} +} \ No newline at end of file diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresAuthenticationTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresAuthenticationTest.java index 8d1922e72a0..57e4f56dcac 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresAuthenticationTest.java +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresAuthenticationTest.java @@ -31,11 +31,8 @@ import org.apache.james.modules.RabbitMQExtension; import org.apache.james.modules.TestJMAPServerModule; import org.apache.james.modules.blobstore.BlobStoreConfiguration; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.extension.RegisterExtension; -@Disabled -// TODO Need to fix public class PostgresAuthenticationTest implements AuthenticationContract { @RegisterExtension static JamesServerExtension testExtension = new JamesServerBuilder(tmpDir -> From fba96b3ce2d05ae3fc2340c750aeb9979360c119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20H=E1=BB=93ng=20Qu=C3=A2n?= <55171818+quantranhong1999@users.noreply.github.com> Date: Mon, 11 Mar 2024 03:23:14 +0700 Subject: [PATCH 238/341] JAMES 2586 PostgresPushSubscriptionRepository: rely on Postgres unique constraint for deviceClientId (#2094) Avoid 2 round trips (checking duplicate deviceClientId + INSERT subscription) when saving a subscription. --- .../PostgresPushSubscriptionDAO.java | 17 +++++------ .../PostgresPushSubscriptionModule.java | 8 ++++-- .../PostgresPushSubscriptionRepository.java | 28 ++++++++++--------- 3 files changed, 29 insertions(+), 24 deletions(-) diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionDAO.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionDAO.java index 5d59e08fe20..91b06c248bb 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionDAO.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionDAO.java @@ -21,6 +21,8 @@ import static org.apache.james.backends.postgres.PostgresCommons.IN_CLAUSE_MAX_SIZE; import static org.apache.james.backends.postgres.PostgresCommons.OFFSET_DATE_TIME_ZONED_DATE_TIME_FUNCTION; +import static org.apache.james.backends.postgres.utils.PostgresUtils.UNIQUE_CONSTRAINT_VIOLATION_PREDICATE; +import static org.apache.james.jmap.postgres.pushsubscription.PostgresPushSubscriptionModule.PushSubscriptionTable.PRIMARY_KEY_CONSTRAINT; import java.time.ZonedDateTime; import java.util.Arrays; @@ -28,11 +30,13 @@ import java.util.Optional; import java.util.Set; import java.util.function.Function; +import java.util.function.Predicate; import java.util.stream.Collectors; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.Username; import org.apache.james.jmap.api.change.TypeStateFactory; +import org.apache.james.jmap.api.model.DeviceClientIdInvalidException; import org.apache.james.jmap.api.model.PushSubscription; import org.apache.james.jmap.api.model.PushSubscriptionExpiredTime; import org.apache.james.jmap.api.model.PushSubscriptionId; @@ -51,6 +55,8 @@ import scala.jdk.javaapi.OptionConverters; public class PostgresPushSubscriptionDAO { + private static final Predicate IS_PRIMARY_KEY_UNIQUE_CONSTRAINT = throwable -> throwable.getMessage().contains(PRIMARY_KEY_CONSTRAINT); + private final PostgresExecutor postgresExecutor; private final TypeStateFactory typeStateFactory; @@ -71,7 +77,9 @@ public Mono save(Username username, PushSubscription pushSubscription) { .set(PushSubscriptionTable.VERIFICATION_CODE, pushSubscription.verificationCode()) .set(PushSubscriptionTable.VALIDATED, pushSubscription.validated()) .set(PushSubscriptionTable.ENCRYPT_PUBLIC_KEY, OptionConverters.toJava(pushSubscription.keys().map(PushSubscriptionKeys::p256dh)).orElse(null)) - .set(PushSubscriptionTable.ENCRYPT_AUTH_SECRET, OptionConverters.toJava(pushSubscription.keys().map(PushSubscriptionKeys::auth)).orElse(null)))); + .set(PushSubscriptionTable.ENCRYPT_AUTH_SECRET, OptionConverters.toJava(pushSubscription.keys().map(PushSubscriptionKeys::auth)).orElse(null)))) + .onErrorMap(UNIQUE_CONSTRAINT_VIOLATION_PREDICATE.and(IS_PRIMARY_KEY_UNIQUE_CONSTRAINT), + e -> new DeviceClientIdInvalidException(pushSubscription.deviceClientId(), "deviceClientId must be unique")); } public Flux listByUsername(Username username) { @@ -134,13 +142,6 @@ public Mono updateExpireTime(Username username, PushSubscriptionI .map(record -> OFFSET_DATE_TIME_ZONED_DATE_TIME_FUNCTION.apply(record.get(PushSubscriptionTable.EXPIRES))); } - public Mono existDeviceClientId(Username username, String deviceClientId) { - return postgresExecutor.executeExists(dslContext -> dslContext.selectOne() - .from(PushSubscriptionTable.TABLE_NAME) - .where(PushSubscriptionTable.USER.eq(username.asString())) - .and(PushSubscriptionTable.DEVICE_CLIENT_ID.eq(deviceClientId))); - } - private PushSubscription recordAsPushSubscription(Record record) { try { return new PushSubscription(new PushSubscriptionId(record.get(PushSubscriptionTable.ID)), diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionModule.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionModule.java index 4a16bed563f..ebe3c552ee8 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionModule.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionModule.java @@ -36,13 +36,14 @@ public interface PostgresPushSubscriptionModule { interface PushSubscriptionTable { Table TABLE_NAME = DSL.table("push_subscription"); + + String PRIMARY_KEY_CONSTRAINT = "push_subscription_primary_key_constraint"; + Field USER = DSL.field("username", SQLDataType.VARCHAR.notNull()); Field DEVICE_CLIENT_ID = DSL.field("device_client_id", SQLDataType.VARCHAR.notNull()); - Field ID = DSL.field("id", SQLDataType.UUID.notNull()); Field EXPIRES = DSL.field("expires", PostgresCommons.DataTypes.TIMESTAMP_WITH_TIMEZONE); Field TYPES = DSL.field("types", PostgresCommons.DataTypes.STRING_ARRAY.notNull()); - Field URL = DSL.field("url", SQLDataType.VARCHAR.notNull()); Field VERIFICATION_CODE = DSL.field("verification_code", SQLDataType.VARCHAR); Field ENCRYPT_PUBLIC_KEY = DSL.field("encrypt_public_key", SQLDataType.VARCHAR); @@ -61,7 +62,8 @@ interface PushSubscriptionTable { .column(ENCRYPT_PUBLIC_KEY) .column(ENCRYPT_AUTH_SECRET) .column(VALIDATED) - .primaryKey(USER, DEVICE_CLIENT_ID))) + .constraint(DSL.constraint(PRIMARY_KEY_CONSTRAINT) + .primaryKey(USER, DEVICE_CLIENT_ID)))) .supportsRowLevelSecurity() .build(); diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionRepository.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionRepository.java index c2370e43ad9..4f81c8237d1 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionRepository.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionRepository.java @@ -34,7 +34,6 @@ import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.Username; import org.apache.james.jmap.api.change.TypeStateFactory; -import org.apache.james.jmap.api.model.DeviceClientIdInvalidException; import org.apache.james.jmap.api.model.ExpireTimeInvalidException; import org.apache.james.jmap.api.model.InvalidPushSubscriptionKeys; import org.apache.james.jmap.api.model.PushSubscription; @@ -64,26 +63,29 @@ public PostgresPushSubscriptionRepository(Clock clock, TypeStateFactory typeStat @Override public Mono save(Username username, PushSubscriptionCreationRequest request) { - PushSubscription pushSubscription = PushSubscription.from(request, - evaluateExpiresTime(OptionConverters.toJava(request.expires().map(PushSubscriptionExpiredTime::value)), clock)); - PostgresPushSubscriptionDAO pushSubscriptionDAO = getDAO(username); - return pushSubscriptionDAO.existDeviceClientId(username, request.deviceClientId()) - .handle((isDuplicated, sink) -> { + + return validateCreationRequest(request) + .then(Mono.defer(() -> { + PushSubscription pushSubscription = PushSubscription.from(request, + evaluateExpiresTime(OptionConverters.toJava(request.expires().map(PushSubscriptionExpiredTime::value)), clock)); + + return pushSubscriptionDAO.save(username, pushSubscription) + .thenReturn(pushSubscription); + })); + } + + private Mono validateCreationRequest(PushSubscriptionCreationRequest request) { + return Mono.just(request) + .handle((creationRequest, sink) -> { if (isInThePast(request.expires(), clock)) { sink.error(new ExpireTimeInvalidException(request.expires().get().value(), "expires must be greater than now")); return; } - if (isDuplicated) { - sink.error(new DeviceClientIdInvalidException(request.deviceClientId(), "deviceClientId must be unique")); - return; - } if (isInvalidPushSubscriptionKey(request.keys())) { sink.error(new InvalidPushSubscriptionKeys(request.keys().get())); } - }) - .then(Mono.defer(() -> pushSubscriptionDAO.save(username, pushSubscription)) - .thenReturn(pushSubscription)); + }); } @Override From 0b5d23714c9162815d100a7c9b92a6d8b1aa0713 Mon Sep 17 00:00:00 2001 From: vttran Date: Mon, 11 Mar 2024 13:40:57 +0700 Subject: [PATCH 239/341] JAMES-2586 [Postgres] FIXUP when query with IN - should pre-check collection size (#2103) --- .../mail/dao/PostgresMailboxMessageDAO.java | 15 +++++++++++++++ .../postgres/mail/dao/PostgresThreadDAO.java | 3 +++ .../james/blob/postgres/PostgresBlobStoreDAO.java | 3 +++ .../identity/PostgresCustomIdentityDAO.java | 3 +++ .../PostgresPushSubscriptionDAO.java | 3 +++ 5 files changed, 27 insertions(+) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java index e3018fc014f..544154b5bb0 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java @@ -217,6 +217,9 @@ public Mono deleteByMailboxIdAndMessageUid(PostgresMailboxId ma } public Flux deleteByMailboxIdAndMessageUids(PostgresMailboxId mailboxId, List uids) { + if (uids.isEmpty()) { + return Flux.empty(); + } Function, Flux> deletePublisherFunction = uidsToDelete -> postgresExecutor.executeDeleteAndReturnList(dslContext -> dslContext.deleteFrom(TABLE_NAME) .where(MAILBOX_ID.eq(mailboxId.asUuid())) .and(MESSAGE_UID.in(uidsToDelete.stream().map(MessageUid::asLong).toArray(Long[]::new))) @@ -239,6 +242,9 @@ public Flux deleteByMailboxId(PostgresMailboxId mailboxId) { } public Mono deleteByMessageIdAndMailboxIds(PostgresMessageId messageId, Collection mailboxIds) { + if (mailboxIds.isEmpty()) { + return Mono.empty(); + } return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(TABLE_NAME) .where(MESSAGE_ID.eq(messageId.asUuid())) .and(MAILBOX_ID.in(mailboxIds.stream().map(PostgresMailboxId::asUuid).collect(ImmutableList.toImmutableList()))))); @@ -318,6 +324,9 @@ public Flux> findMessagesByMailboxIdA } public Flux findMessagesByMailboxIdAndUIDs(PostgresMailboxId mailboxId, List uids) { + if (uids.isEmpty()) { + return Flux.empty(); + } PostgresMailboxMessageFetchStrategy fetchStrategy = PostgresMailboxMessageFetchStrategy.METADATA; Function, Flux> queryPublisherFunction = uidsToFetch -> postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(fetchStrategy.fetchFields()) @@ -507,6 +516,9 @@ public Mono listDistinctUserFlags(PostgresMailboxId mailboxId) { } public Flux resetRecentFlag(PostgresMailboxId mailboxId, List uids, ModSeq newModSeq) { + if (uids.isEmpty()) { + return Flux.empty(); + } Function, Flux> queryPublisherFunction = uidsMatching -> postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.update(TABLE_NAME) .set(IS_RECENT, false) .set(MOD_SEQ, newModSeq.asLong()) @@ -554,6 +566,9 @@ public Flux findMailboxes(PostgresMessageId messageId) { } public Flux> findMessagesByMessageIds(Collection messageIds, MessageMapper.FetchType fetchType) { + if (messageIds.isEmpty()) { + return Flux.empty(); + } PostgresMailboxMessageFetchStrategy fetchStrategy = FETCH_TYPE_TO_FETCH_STRATEGY.apply(fetchType); return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(fetchStrategy.fetchFields()) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresThreadDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresThreadDAO.java index 4557086528b..318f78dc930 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresThreadDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresThreadDAO.java @@ -83,6 +83,9 @@ public Mono insertSome(Username username, Set hashMimeMessageIds, } public Flux, ThreadId>> findThreads(Username username, Set hashMimeMessageIds) { + if (hashMimeMessageIds.isEmpty()) { + return Flux.empty(); + } Function, Flux, ThreadId>>> function = hashMimeMessageIdSubSet -> postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(THREAD_ID, HASH_BASE_SUBJECT) .from(TABLE_NAME) diff --git a/server/blob/blob-postgres/src/main/java/org/apache/james/blob/postgres/PostgresBlobStoreDAO.java b/server/blob/blob-postgres/src/main/java/org/apache/james/blob/postgres/PostgresBlobStoreDAO.java index dbbd67abaf6..2c44b6f78a4 100644 --- a/server/blob/blob-postgres/src/main/java/org/apache/james/blob/postgres/PostgresBlobStoreDAO.java +++ b/server/blob/blob-postgres/src/main/java/org/apache/james/blob/postgres/PostgresBlobStoreDAO.java @@ -128,6 +128,9 @@ public Mono delete(BucketName bucketName, BlobId blobId) { @Override public Mono delete(BucketName bucketName, Collection blobIds) { + if (blobIds.isEmpty()) { + return Mono.empty(); + } return postgresExecutor.executeVoid(dsl -> Mono.from(dsl.deleteFrom(TABLE_NAME) .where(BUCKET_NAME.eq(bucketName.asString())) .and(BLOB_ID.in(blobIds.stream().map(BlobId::asString).collect(ImmutableList.toImmutableList()))))); diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/identity/PostgresCustomIdentityDAO.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/identity/PostgresCustomIdentityDAO.java index 9f3cd1b2c04..b1b7fed98eb 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/identity/PostgresCustomIdentityDAO.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/identity/PostgresCustomIdentityDAO.java @@ -167,6 +167,9 @@ private Mono upsertReturnMono(Username user, Identity identity) { @Override public Publisher delete(Username username, Seq ids) { + if (ids.isEmpty()) { + return Mono.empty(); + } return executorFactory.create(username.getDomainPart()) .executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(TABLE_NAME) .where(USERNAME.eq(username.asString())) diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionDAO.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionDAO.java index 91b06c248bb..94430ab0033 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionDAO.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionDAO.java @@ -89,6 +89,9 @@ public Flux listByUsername(Username username) { } public Flux getByUsernameAndIds(Username username, Collection ids) { + if (ids.isEmpty()) { + return Flux.empty(); + } Function, Flux> queryPublisherFunction = idsMatching -> postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.selectFrom(PushSubscriptionTable.TABLE_NAME) .where(PushSubscriptionTable.USER.eq(username.asString())) .and(PushSubscriptionTable.ID.in(idsMatching.stream().map(PushSubscriptionId::value).collect(Collectors.toList()))))) From 258bc1c30ec1c1227315fc6dfaea080779cb83ef Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Thu, 7 Mar 2024 16:29:41 +0700 Subject: [PATCH 240/341] [Build] Use tmpfs for Postgres db test container --- .../org/apache/james/backends/postgres/PostgresFixture.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresFixture.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresFixture.java index 897943a75cb..5bc662b294b 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresFixture.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresFixture.java @@ -19,6 +19,7 @@ package org.apache.james.backends.postgres; +import static java.util.Collections.singletonMap; import static org.apache.james.backends.postgres.PostgresFixture.Database.DEFAULT_DATABASE; import static org.testcontainers.containers.PostgreSQLContainer.POSTGRESQL_PORT; @@ -94,5 +95,6 @@ public String schema() { .withDatabaseName(DEFAULT_DATABASE.dbName()) .withUsername(DEFAULT_DATABASE.dbUser()) .withPassword(DEFAULT_DATABASE.dbPassword()) - .withCreateContainerCmdModifier(cmd -> cmd.withName("james-postgres-test-" + UUID.randomUUID())); + .withCreateContainerCmdModifier(cmd -> cmd.withName("james-postgres-test-" + UUID.randomUUID())) + .withTmpFs(singletonMap("/var/lib/postgresql/data", "rw")); } From 256a118708068f42d378851af18aa1aaccfa3f66 Mon Sep 17 00:00:00 2001 From: hung phan Date: Sun, 25 Feb 2024 15:10:58 +0700 Subject: [PATCH 241/341] JAMES-2586 Fix PostgresPushSubscriptionSetMethodTest, PostgresThreadGetTest --- .../postgres/mail/dao/PostgresThreadDAO.java | 2 +- .../PushSubscriptionSetMethodContract.scala | 2 + ...PostgresPushSubscriptionSetMethodTest.java | 14 ------ .../postgres/PostgresThreadGetTest.java | 48 ------------------- 4 files changed, 3 insertions(+), 63 deletions(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresThreadDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresThreadDAO.java index 318f78dc930..3f5da754b1b 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresThreadDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresThreadDAO.java @@ -102,7 +102,7 @@ public Flux, ThreadId>> findThreads(Username username, Se } public Flux findMessageIds(ThreadId threadId, Username username) { - return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(MESSAGE_ID) + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.selectDistinct(MESSAGE_ID) .from(TABLE_NAME) .where(USERNAME.eq(username.asString())) .and(THREAD_ID.eq(PostgresMessageId.class.cast(threadId.getBaseMessageId()).asUuid())) diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/PushSubscriptionSetMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/PushSubscriptionSetMethodContract.scala index 1902eff2fe5..b63b94557a4 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/PushSubscriptionSetMethodContract.scala +++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/PushSubscriptionSetMethodContract.scala @@ -612,6 +612,7 @@ trait PushSubscriptionSetMethodContract { .asString assertThatJson(response) + .withOptions(new Options(IGNORING_ARRAY_ORDER)) .isEqualTo( s"""{ | "sessionState": "${SESSION_STATE.value}", @@ -773,6 +774,7 @@ trait PushSubscriptionSetMethodContract { .asString assertThatJson(response) + .withOptions(new Options(IGNORING_ARRAY_ORDER)) .isEqualTo( s"""{ | "sessionState": "${SESSION_STATE.value}", diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresPushSubscriptionSetMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresPushSubscriptionSetMethodTest.java index a0aa0a28186..1c743380d53 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresPushSubscriptionSetMethodTest.java +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresPushSubscriptionSetMethodTest.java @@ -64,18 +64,4 @@ public class PostgresPushSubscriptionSetMethodTest implements PushSubscriptionSe @RegisterExtension static PushServerExtension pushServerExtension = new PushServerExtension(); - - @Override - @Test - @Disabled - // TODO Need to fix - public void getShouldReturnAllRecords(GuiceJamesServer server) { - } - - @Override - @Test - @Disabled - // TODO Need to fix - public void getByIdShouldReturnRecords(GuiceJamesServer server) { - } } diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresThreadGetTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresThreadGetTest.java index 8625fd48106..10d3c11f717 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresThreadGetTest.java +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresThreadGetTest.java @@ -20,10 +20,7 @@ package org.apache.james.jmap.rfc8621.postgres; import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; -import static org.assertj.core.api.Assertions.assertThat; -import static org.awaitility.Durations.ONE_HUNDRED_MILLISECONDS; -import java.io.IOException; import java.util.List; import org.apache.james.DockerOpenSearchExtension; @@ -32,41 +29,16 @@ import org.apache.james.PostgresJamesConfiguration; import org.apache.james.PostgresJamesServerMain; import org.apache.james.SearchConfiguration; -import org.apache.james.backends.opensearch.ReactorOpenSearchClient; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.jmap.rfc8621.contract.ThreadGetContract; import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.SearchQuery; -import org.apache.james.mailbox.opensearch.MailboxIndexCreationUtil; -import org.apache.james.mailbox.opensearch.MailboxOpenSearchConstants; -import org.apache.james.mailbox.opensearch.query.CriterionConverter; -import org.apache.james.mailbox.opensearch.query.QueryConverter; import org.apache.james.modules.RabbitMQExtension; import org.apache.james.modules.TestJMAPServerModule; import org.apache.james.modules.blobstore.BlobStoreConfiguration; -import org.awaitility.Awaitility; -import org.awaitility.Durations; -import org.awaitility.core.ConditionFactory; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.extension.RegisterExtension; -import org.opensearch.client.opensearch._types.query_dsl.Query; -import org.opensearch.client.opensearch.core.SearchRequest; -@Disabled -// TODO Need to fix public class PostgresThreadGetTest extends PostgresBase implements ThreadGetContract { - private static final ConditionFactory CALMLY_AWAIT = Awaitility - .with().pollInterval(ONE_HUNDRED_MILLISECONDS) - .and().pollDelay(ONE_HUNDRED_MILLISECONDS) - .await(); - - private final QueryConverter queryConverter = new QueryConverter(new CriterionConverter()); - private ReactorOpenSearchClient client; - - @RegisterExtension - org.apache.james.backends.opensearch.DockerOpenSearchExtension openSearch = new org.apache.james.backends.opensearch.DockerOpenSearchExtension(); - @RegisterExtension static JamesServerExtension testExtension = new JamesServerBuilder(tmpDir -> PostgresJamesConfiguration.builder() @@ -88,31 +60,11 @@ public class PostgresThreadGetTest extends PostgresBase implements ThreadGetCont .overrideWith(new TestJMAPServerModule())) .build(); - @AfterEach - void tearDown() throws IOException { - client.close(); - } - @Override public void awaitMessageCount(List mailboxIds, SearchQuery query, long messageCount) { - awaitForOpenSearch(queryConverter.from(mailboxIds, query), messageCount); } @Override public void initOpenSearchClient() { - client = MailboxIndexCreationUtil.prepareDefaultClient( - openSearch.getDockerOpenSearch().clientProvider().get(), - openSearch.getDockerOpenSearch().configuration()); - } - - private void awaitForOpenSearch(Query query, long totalHits) { - CALMLY_AWAIT.atMost(Durations.TEN_SECONDS) - .untilAsserted(() -> assertThat(client.search( - new SearchRequest.Builder() - .index(MailboxOpenSearchConstants.DEFAULT_MAILBOX_INDEX.getValue()) - .query(query) - .build()) - .block() - .hits().total().value()).isEqualTo(totalHits)); } } From cb469ac77b5d42f9a6686c04be2d93c92f695d71 Mon Sep 17 00:00:00 2001 From: hung phan Date: Mon, 11 Mar 2024 11:57:48 +0700 Subject: [PATCH 242/341] JAMES-2586 Replace drop by truncate in PostgresMessageFastViewProjection --- .../postgres/projections/PostgresMessageFastViewProjection.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresMessageFastViewProjection.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresMessageFastViewProjection.java index 8e122be5281..255020333fe 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresMessageFastViewProjection.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresMessageFastViewProjection.java @@ -100,6 +100,6 @@ public Publisher delete(MessageId messageId) { @Override public Publisher clear() { - return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.dropTableIfExists(TABLE_NAME))); + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.truncate(TABLE_NAME))); } } From e8227c026fd517e2dfe580c1f385063b7478a07f Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Thu, 14 Mar 2024 11:00:35 +0700 Subject: [PATCH 243/341] JAMES 2586 Increase timeout to 1 hour for postgres-jmap-integration-test module --- .../postgres-jmap-rfc-8621-integration-tests/pom.xml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/pom.xml b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/pom.xml index 741f1160410..aa94b5a5df6 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/pom.xml +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/pom.xml @@ -94,4 +94,16 @@ test + + + + + org.apache.maven.plugins + maven-surefire-plugin + + 3600 + + + + From 898ba5530dd008c51eade4091e1cdef76d8cc837 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Thu, 14 Mar 2024 11:01:21 +0700 Subject: [PATCH 244/341] JAMES 2586 Try forkCount=2 to see if the tests are faster It defaults to 1. --- .../postgres-jmap-rfc-8621-integration-tests/pom.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/pom.xml b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/pom.xml index aa94b5a5df6..95a55007679 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/pom.xml +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/pom.xml @@ -102,6 +102,8 @@ maven-surefire-plugin 3600 + true + 2 From 57d5afef511020fc86b20959749987684ca92004 Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Fri, 15 Mar 2024 09:11:11 +0700 Subject: [PATCH 245/341] Add sslMode to require in PostgresqlConnectionConfiguration (#2109) --- .../postgres/PostgresConfiguration.java | 33 ++++++++++++++++--- .../postgres/PostgresConfigurationTest.java | 16 ++++++--- .../sample-configuration/postgres.properties | 4 +++ .../modules/data/PostgresCommonModule.java | 2 ++ 4 files changed, 46 insertions(+), 9 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java index 82683044ff7..88f91d3d234 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java @@ -26,6 +26,8 @@ import com.google.common.base.Preconditions; +import io.r2dbc.postgresql.client.SSLMode; + public class PostgresConfiguration { public static final String DATABASE_NAME = "database.name"; public static final String DATABASE_NAME_DEFAULT_VALUE = "postgres"; @@ -40,6 +42,8 @@ public class PostgresConfiguration { public static final String NON_RLS_USERNAME = "database.non-rls.username"; public static final String NON_RLS_PASSWORD = "database.non-rls.password"; public static final String RLS_ENABLED = "row.level.security.enabled"; + public static final String SSL_MODE = "ssl.mode"; + public static final String SSL_MODE_DEFAULT_VALUE = "allow"; public static class Credential { private final String username; @@ -70,6 +74,7 @@ public static class Builder { private Optional nonRLSUser = Optional.empty(); private Optional nonRLSPassword = Optional.empty(); private Optional rowLevelSecurityEnabled = Optional.empty(); + private Optional sslMode = Optional.empty(); public Builder databaseName(String databaseName) { this.databaseName = Optional.of(databaseName); @@ -161,6 +166,16 @@ public Builder rowLevelSecurityEnabled() { return this; } + public Builder sslMode(Optional sslMode) { + this.sslMode = sslMode; + return this; + } + + public Builder sslMode(String sslMode) { + this.sslMode = Optional.of(sslMode); + return this; + } + public PostgresConfiguration build() { Preconditions.checkArgument(username.isPresent() && !username.get().isBlank(), "You need to specify username"); Preconditions.checkArgument(password.isPresent() && !password.get().isBlank(), "You need to specify password"); @@ -176,7 +191,8 @@ public PostgresConfiguration build() { databaseSchema.orElse(DATABASE_SCHEMA_DEFAULT_VALUE), new Credential(username.get(), password.get()), new Credential(nonRLSUser.orElse(username.get()), nonRLSPassword.orElse(password.get())), - rowLevelSecurityEnabled.orElse(false)); + rowLevelSecurityEnabled.orElse(false), + SSLMode.fromValue(sslMode.orElse(SSL_MODE_DEFAULT_VALUE))); } } @@ -195,6 +211,7 @@ public static PostgresConfiguration from(Configuration propertiesConfiguration) .nonRLSUser(Optional.ofNullable(propertiesConfiguration.getString(NON_RLS_USERNAME))) .nonRLSPassword(Optional.ofNullable(propertiesConfiguration.getString(NON_RLS_PASSWORD))) .rowLevelSecurityEnabled(propertiesConfiguration.getBoolean(RLS_ENABLED, false)) + .sslMode(Optional.ofNullable(propertiesConfiguration.getString(SSL_MODE))) .build(); } @@ -205,9 +222,11 @@ public static PostgresConfiguration from(Configuration propertiesConfiguration) private final Credential credential; private final Credential nonRLSCredential; private final boolean rowLevelSecurityEnabled; + private final SSLMode sslMode; private PostgresConfiguration(String host, int port, String databaseName, String databaseSchema, - Credential credential, Credential nonRLSCredential, boolean rowLevelSecurityEnabled) { + Credential credential, Credential nonRLSCredential, boolean rowLevelSecurityEnabled, + SSLMode sslMode) { this.host = host; this.port = port; this.databaseName = databaseName; @@ -215,6 +234,7 @@ private PostgresConfiguration(String host, int port, String databaseName, String this.credential = credential; this.nonRLSCredential = nonRLSCredential; this.rowLevelSecurityEnabled = rowLevelSecurityEnabled; + this.sslMode = sslMode; } public String getHost() { @@ -245,9 +265,13 @@ public boolean rowLevelSecurityEnabled() { return rowLevelSecurityEnabled; } + public SSLMode getSslMode() { + return sslMode; + } + @Override public final int hashCode() { - return Objects.hash(host, port, databaseName, databaseSchema, credential, nonRLSCredential, rowLevelSecurityEnabled); + return Objects.hash(host, port, databaseName, databaseSchema, credential, nonRLSCredential, rowLevelSecurityEnabled, sslMode); } @Override @@ -261,7 +285,8 @@ public final boolean equals(Object o) { && Objects.equals(this.credential, that.credential) && Objects.equals(this.nonRLSCredential, that.nonRLSCredential) && Objects.equals(this.databaseName, that.databaseName) - && Objects.equals(this.databaseSchema, that.databaseSchema); + && Objects.equals(this.databaseSchema, that.databaseSchema) + && Objects.equals(this.sslMode, that.sslMode); } return false; } diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java index b47f66abe44..2c9c8b3c0d5 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java @@ -24,6 +24,8 @@ import org.junit.jupiter.api.Test; +import io.r2dbc.postgresql.client.SSLMode; + class PostgresConfigurationTest { @Test @@ -38,6 +40,7 @@ void shouldReturnCorrespondingProperties() { .nonRLSUser("nonrlsjames") .nonRLSPassword("2") .rowLevelSecurityEnabled() + .sslMode("require") .build(); assertThat(configuration.getHost()).isEqualTo("1.1.1.1"); @@ -49,6 +52,7 @@ void shouldReturnCorrespondingProperties() { assertThat(configuration.getNonRLSCredential().getUsername()).isEqualTo("nonrlsjames"); assertThat(configuration.getNonRLSCredential().getPassword()).isEqualTo("2"); assertThat(configuration.rowLevelSecurityEnabled()).isEqualTo(true); + assertThat(configuration.getSslMode()).isEqualTo(SSLMode.REQUIRE); } @Test @@ -65,6 +69,7 @@ void shouldUseDefaultValues() { assertThat(configuration.getNonRLSCredential().getUsername()).isEqualTo("james"); assertThat(configuration.getNonRLSCredential().getPassword()).isEqualTo("1"); assertThat(configuration.rowLevelSecurityEnabled()).isEqualTo(false); + assertThat(configuration.getSslMode()).isEqualTo(SSLMode.ALLOW); } @Test @@ -108,12 +113,13 @@ void shouldThrowWhenMissingNonRLSPasswordAndRLSIsEnabled() { } @Test - void rowLevelSecurityShouldBeDisabledByDefault() { - PostgresConfiguration configuration = PostgresConfiguration.builder() + void shouldThrowWhenInvalidSslMode() { + assertThatThrownBy(() -> PostgresConfiguration.builder() .username("james") .password("1") - .build(); - - assertThat(configuration.rowLevelSecurityEnabled()).isFalse(); + .sslMode("invalid") + .build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid ssl mode value: invalid"); } } diff --git a/server/apps/postgres-app/sample-configuration/postgres.properties b/server/apps/postgres-app/sample-configuration/postgres.properties index c0bcf88cf06..36512aa7574 100644 --- a/server/apps/postgres-app/sample-configuration/postgres.properties +++ b/server/apps/postgres-app/sample-configuration/postgres.properties @@ -24,3 +24,7 @@ row.level.security.enabled=false # String. It is required when row.level.security.enabled is true. Database password of non-rls user. #database.non-rls.password=secret1 + +# String. Optional, defaults to allow. SSLMode required to connect to the Postgresql db server. +# Check https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-PROTECTION for a list of supported SSLModes. +ssl.mode=allow \ No newline at end of file diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index 3715e59efce..bc03e224eeb 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -104,6 +104,7 @@ ConnectionFactory postgresqlConnectionFactory(PostgresConfiguration postgresConf .password(postgresConfiguration.getCredential().getPassword()) .database(postgresConfiguration.getDatabaseName()) .schema(postgresConfiguration.getDatabaseSchema()) + .sslMode(postgresConfiguration.getSslMode()) .build()); } @@ -118,6 +119,7 @@ ConnectionFactory postgresqlConnectionFactoryRLSBypass(PostgresConfiguration pos .password(postgresConfiguration.getNonRLSCredential().getPassword()) .database(postgresConfiguration.getDatabaseName()) .schema(postgresConfiguration.getDatabaseSchema()) + .sslMode(postgresConfiguration.getSslMode()) .build()); } From d842a449fa8288b842092e3ea56136b7d2760af3 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Thu, 14 Mar 2024 15:53:12 +0700 Subject: [PATCH 246/341] JAMES-2586 Reduce repeat count for some JMAP integration tests These are the tests took a lot of time to fully repeat. cf: https://ge.apache.org/s/dqudut5akzxr6/tests/goal/org.apache.james:postgres-jmap-rfc-8621-integration-tests:surefire:test@default-test/details/org.apache.james.jmap.rfc8621.postgres.PostgresUploadTest?top-execution=1 cf: https://ge.apache.org/s/dqudut5akzxr6/tests/goal/org.apache.james:postgres-jmap-rfc-8621-integration-tests:surefire:test@default-test/details/org.apache.james.jmap.rfc8621.postgres.PostgresMailboxSetMethodTest?top-execution=1 Likely overkill to repeat that much, we can reduce the repeat count to save some test runtime... --- .../james/jmap/rfc8621/contract/MailboxSetMethodContract.scala | 2 +- .../org/apache/james/jmap/rfc8621/contract/UploadContract.scala | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxSetMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxSetMethodContract.scala index 5e9a9c3455d..3ebacb4d02b 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxSetMethodContract.scala +++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxSetMethodContract.scala @@ -6313,7 +6313,7 @@ trait MailboxSetMethodContract { |}""".stripMargin) } - @RepeatedTest(100) + @RepeatedTest(20) def concurrencyChecksUponParentIdUpdate(server: GuiceJamesServer): Unit = { val mailboxId1: MailboxId = server.getProbe(classOf[MailboxProbeImpl]) .createMailbox(MailboxPath.forUser(BOB, "mailbox1")) diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/UploadContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/UploadContract.scala index 87b63790378..ef9047858f5 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/UploadContract.scala +++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/UploadContract.scala @@ -58,7 +58,7 @@ trait UploadContract { .build } - @RepeatedTest(50) + @RepeatedTest(20) def shouldUploadFileAndAllowToDownloadIt(): Unit = { val uploadResponse: String = `given` .basePath("") From d0bc58cca972fe0319558358ef4cd3b8a0e1ef27 Mon Sep 17 00:00:00 2001 From: hung phan Date: Thu, 14 Mar 2024 16:14:25 +0700 Subject: [PATCH 247/341] JAMES-2586 Add PostgresAttachmentMapper to PostgresMessageIdMapper --- .../PostgresMailboxSessionMapperFactory.java | 6 +-- .../mail/PostgresAttachmentMapper.java | 14 ++--- .../mail/PostgresAttachmentModule.java | 2 +- .../mail/PostgresMessageIdMapper.java | 52 ++++++++++++++++--- .../mail/dao/PostgresAttachmentDAO.java | 18 ++++++- .../postgres/DeleteMessageListenerTest.java | 1 - .../DeleteMessageListenerWithRLSTest.java | 1 - .../PostgresMailboxManagerAttachmentTest.java | 4 +- .../postgres/mail/PostgresMapperProvider.java | 9 ++-- ...ostgresMessageBlobReferenceSourceTest.java | 4 +- .../mail/PostgresMessageMapperTest.java | 1 + ...ubscriptionMapperRowLevelSecurityTest.java | 2 +- 12 files changed, 83 insertions(+), 31 deletions(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java index 8b9c50118cf..f2a76091205 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java @@ -43,7 +43,6 @@ import org.apache.james.mailbox.postgres.user.PostgresSubscriptionMapper; import org.apache.james.mailbox.store.MailboxSessionMapperFactory; import org.apache.james.mailbox.store.mail.AnnotationMapper; -import org.apache.james.mailbox.store.mail.AttachmentMapper; import org.apache.james.mailbox.store.mail.AttachmentMapperFactory; import org.apache.james.mailbox.store.mail.MailboxMapper; import org.apache.james.mailbox.store.mail.MessageIdMapper; @@ -92,6 +91,7 @@ public MessageIdMapper createMessageIdMapper(MailboxSession session) { new PostgresMessageDAO(executorFactory.create(session.getUser().getDomainPart()), blobIdFactory), new PostgresMailboxMessageDAO(executorFactory.create(session.getUser().getDomainPart())), getModSeqProvider(session), + getAttachmentMapper(session), blobStore, blobIdFactory, clock); @@ -118,13 +118,13 @@ public PostgresModSeqProvider getModSeqProvider(MailboxSession session) { } @Override - public AttachmentMapper createAttachmentMapper(MailboxSession session) { + public PostgresAttachmentMapper createAttachmentMapper(MailboxSession session) { PostgresAttachmentDAO postgresAttachmentDAO = new PostgresAttachmentDAO(executorFactory.create(session.getUser().getDomainPart()), blobIdFactory); return new PostgresAttachmentMapper(postgresAttachmentDAO, blobStore); } @Override - public AttachmentMapper getAttachmentMapper(MailboxSession session) { + public PostgresAttachmentMapper getAttachmentMapper(MailboxSession session) { return createAttachmentMapper(session); } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentMapper.java index e1d187f2361..f1d00421c25 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentMapper.java @@ -20,7 +20,6 @@ package org.apache.james.mailbox.postgres.mail; import static org.apache.james.blob.api.BlobStore.StoragePolicy.LOW_COST; -import static org.apache.james.util.ReactorUtils.DEFAULT_CONCURRENCY; import java.io.InputStream; import java.util.Collection; @@ -39,7 +38,6 @@ import com.github.fge.lambdas.Throwing; import com.google.common.base.Preconditions; -import com.google.common.collect.ImmutableList; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -84,13 +82,15 @@ public Mono getAttachmentReactive(AttachmentId attachmentId) .switchIfEmpty(Mono.error(() -> new AttachmentNotFoundException(attachmentId.getId()))); } + public Flux getAttachmentsReactive(Collection attachmentIds) { + Preconditions.checkArgument(attachmentIds != null); + return postgresAttachmentDAO.getAttachments(attachmentIds); + } + @Override public List getAttachments(Collection attachmentIds) { - Preconditions.checkArgument(attachmentIds != null); - return Flux.fromIterable(attachmentIds) - .flatMap(id -> postgresAttachmentDAO.getAttachment(id) - .map(Pair::getLeft), DEFAULT_CONCURRENCY) - .collect(ImmutableList.toImmutableList()) + return getAttachmentsReactive(attachmentIds) + .collectList() .block(); } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentModule.java index 2bc4e0b16b2..4b3fb59510d 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentModule.java @@ -35,7 +35,7 @@ public interface PostgresAttachmentModule { interface PostgresAttachmentTable { Table TABLE_NAME = DSL.table("attachment"); - Field ID = DSL.field("id", SQLDataType.UUID.notNull()); + Field ID = DSL.field("id", SQLDataType.VARCHAR.notNull()); Field BLOB_ID = DSL.field("blob_id", SQLDataType.VARCHAR); Field TYPE = DSL.field("type", SQLDataType.VARCHAR); Field MESSAGE_ID = DSL.field("message_id", SQLDataType.UUID); diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapper.java index b3233f83453..6e9d12fce79 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapper.java @@ -21,6 +21,7 @@ import static org.apache.james.blob.api.BlobStore.StoragePolicy.LOW_COST; import static org.apache.james.blob.api.BlobStore.StoragePolicy.SIZE_BASED; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.ATTACHMENT_METADATA; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.BODY_BLOB_ID; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.HEADER_CONTENT; @@ -30,6 +31,7 @@ import java.util.Collection; import java.util.Date; import java.util.List; +import java.util.Map; import java.util.function.Function; import javax.mail.Flags; @@ -43,11 +45,14 @@ import org.apache.james.mailbox.ModSeq; import org.apache.james.mailbox.exception.MailboxException; import org.apache.james.mailbox.exception.MailboxNotFoundException; +import org.apache.james.mailbox.model.AttachmentId; +import org.apache.james.mailbox.model.AttachmentMetadata; import org.apache.james.mailbox.model.ComposedMessageIdWithMetaData; import org.apache.james.mailbox.model.Content; import org.apache.james.mailbox.model.HeaderAndBodyByteContent; import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.model.MessageAttachmentMetadata; import org.apache.james.mailbox.model.MessageId; import org.apache.james.mailbox.model.UpdatedFlags; import org.apache.james.mailbox.postgres.PostgresMailboxId; @@ -69,6 +74,7 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableListMultimap; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.Multimap; import com.google.common.io.ByteSource; @@ -99,18 +105,24 @@ public long size() { private final PostgresMessageDAO messageDAO; private final PostgresMailboxMessageDAO mailboxMessageDAO; private final PostgresModSeqProvider modSeqProvider; + private final PostgresAttachmentMapper attachmentMapper; private final BlobStore blobStore; private final BlobId.Factory blobIdFactory; private final Clock clock; - public PostgresMessageIdMapper(PostgresMailboxDAO mailboxDAO, PostgresMessageDAO messageDAO, - PostgresMailboxMessageDAO mailboxMessageDAO, PostgresModSeqProvider modSeqProvider, - BlobStore blobStore, BlobId.Factory blobIdFactory, + public PostgresMessageIdMapper(PostgresMailboxDAO mailboxDAO, + PostgresMessageDAO messageDAO, + PostgresMailboxMessageDAO mailboxMessageDAO, + PostgresModSeqProvider modSeqProvider, + PostgresAttachmentMapper attachmentMapper, + BlobStore blobStore, + BlobId.Factory blobIdFactory, Clock clock) { this.mailboxDAO = mailboxDAO; this.messageDAO = messageDAO; this.mailboxMessageDAO = mailboxMessageDAO; this.modSeqProvider = modSeqProvider; + this.attachmentMapper = attachmentMapper; this.blobStore = blobStore; this.blobIdFactory = blobIdFactory; this.clock = clock; @@ -130,18 +142,44 @@ public Publisher findMetadata(MessageId messageId @Override public Flux findReactive(Collection messageIds, MessageMapper.FetchType fetchType) { - return mailboxMessageDAO.findMessagesByMessageIds(messageIds.stream().map(PostgresMessageId.class::cast).collect(ImmutableList.toImmutableList()), - fetchType) + return mailboxMessageDAO.findMessagesByMessageIds(messageIds.stream().map(PostgresMessageId.class::cast).collect(ImmutableList.toImmutableList()), fetchType) .flatMap(messageBuilderAndRecord -> { SimpleMailboxMessage.Builder messageBuilder = messageBuilderAndRecord.getLeft(); + Record record = messageBuilderAndRecord.getRight(); if (fetchType == MessageMapper.FetchType.FULL) { - return retrieveFullContent(messageBuilderAndRecord.getRight()) - .map(headerAndBodyContent -> messageBuilder.content(headerAndBodyContent).build()); + return retrieveFullMessage(messageBuilder, record); } return Mono.just(messageBuilder.build()); }, ReactorUtils.DEFAULT_CONCURRENCY); } + private Mono retrieveFullMessage(SimpleMailboxMessage.Builder messageBuilder, Record record) { + return retrieveFullContent(record).flatMap(headerAndBodyContent -> getAttachments(toMap(record.get(ATTACHMENT_METADATA))) + .map(messageAttachmentMetadataList -> messageBuilder.content(headerAndBodyContent).addAttachments(messageAttachmentMetadataList).build())); + } + + private Map toMap(List attachmentRepresentations) { + return attachmentRepresentations.stream().collect(ImmutableMap.toImmutableMap(MessageRepresentation.AttachmentRepresentation::getAttachmentId, obj -> obj)); + } + + private Mono> getAttachments(Map mapAttachmentIdToAttachmentRepresentation) { + return attachmentMapper.getAttachmentsReactive(mapAttachmentIdToAttachmentRepresentation.values() + .stream() + .map(MessageRepresentation.AttachmentRepresentation::getAttachmentId) + .collect(ImmutableList.toImmutableList())) + .map(attachmentMetadata -> constructMessageAttachment(attachmentMetadata, mapAttachmentIdToAttachmentRepresentation.get(attachmentMetadata.getAttachmentId()))) + .collectList(); + } + + private MessageAttachmentMetadata constructMessageAttachment(AttachmentMetadata attachment, MessageRepresentation.AttachmentRepresentation messageAttachmentRepresentation) { + return MessageAttachmentMetadata.builder() + .attachment(attachment) + .name(messageAttachmentRepresentation.getName().orElse(null)) + .cid(messageAttachmentRepresentation.getCid()) + .isInline(messageAttachmentRepresentation.isInline()) + .build(); + } + @Override public List findMailboxes(MessageId messageId) { return mailboxMessageDAO.findMailboxes(PostgresMessageId.class.cast(messageId)) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresAttachmentDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresAttachmentDAO.java index 15f7f3ec62a..7d60ff51c58 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresAttachmentDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresAttachmentDAO.java @@ -19,6 +19,7 @@ package org.apache.james.mailbox.postgres.mail.dao; +import java.util.Collection; import java.util.Optional; import javax.inject.Inject; @@ -33,6 +34,8 @@ import org.apache.james.mailbox.postgres.PostgresMessageId; import org.apache.james.mailbox.postgres.mail.PostgresAttachmentModule.PostgresAttachmentTable; +import com.google.common.collect.ImmutableList; + import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -69,7 +72,7 @@ public Mono> getAttachment(AttachmentId attachm PostgresAttachmentTable.MESSAGE_ID, PostgresAttachmentTable.SIZE) .from(PostgresAttachmentTable.TABLE_NAME) - .where(PostgresAttachmentTable.ID.eq(attachmentId.asUUID())))) + .where(PostgresAttachmentTable.ID.eq(attachmentId.getId())))) .map(row -> Pair.of( AttachmentMetadata.builder() .attachmentId(attachmentId) @@ -80,9 +83,20 @@ public Mono> getAttachment(AttachmentId attachm blobIdFactory.from(row.get(PostgresAttachmentTable.BLOB_ID)))); } + public Flux getAttachments(Collection attachmentIds) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.selectFrom(PostgresAttachmentTable.TABLE_NAME) + .where(PostgresAttachmentTable.ID.in(attachmentIds.stream().map(AttachmentId::getId).collect(ImmutableList.toImmutableList()))))) + .map(row -> AttachmentMetadata.builder() + .attachmentId(AttachmentId.from(row.get(PostgresAttachmentTable.ID))) + .type(row.get(PostgresAttachmentTable.TYPE)) + .messageId(PostgresMessageId.Factory.of(row.get(PostgresAttachmentTable.MESSAGE_ID))) + .size(row.get(PostgresAttachmentTable.SIZE)) + .build()); + } + public Mono storeAttachment(AttachmentMetadata attachment, BlobId blobId) { return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(PostgresAttachmentTable.TABLE_NAME) - .set(PostgresAttachmentTable.ID, attachment.getAttachmentId().asUUID()) + .set(PostgresAttachmentTable.ID, attachment.getAttachmentId().getId()) .set(PostgresAttachmentTable.BLOB_ID, blobId.asString()) .set(PostgresAttachmentTable.TYPE, attachment.getType().asString()) .set(PostgresAttachmentTable.MESSAGE_ID, ((PostgresMessageId) attachment.getMessageId()).asUuid()) diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java index 7a2c846aaad..8407302b7aa 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java @@ -43,7 +43,6 @@ import org.apache.james.mailbox.store.StoreMailboxAnnotationManager; import org.apache.james.mailbox.store.StoreRightManager; import org.apache.james.mailbox.store.extractor.DefaultTextExtractor; -import org.apache.james.mailbox.store.mail.NaiveThreadIdGuessingAlgorithm; import org.apache.james.mailbox.store.mail.model.impl.MessageParser; import org.apache.james.mailbox.store.quota.QuotaComponents; import org.apache.james.mailbox.store.search.MessageSearchIndex; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java index c2ad850a806..8f92ab1990c 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java @@ -47,7 +47,6 @@ import org.apache.james.mailbox.store.StoreMailboxAnnotationManager; import org.apache.james.mailbox.store.StoreRightManager; import org.apache.james.mailbox.store.extractor.DefaultTextExtractor; -import org.apache.james.mailbox.store.mail.NaiveThreadIdGuessingAlgorithm; import org.apache.james.mailbox.store.mail.model.impl.MessageParser; import org.apache.james.mailbox.store.quota.QuotaComponents; import org.apache.james.mailbox.store.search.MessageSearchIndex; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerAttachmentTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerAttachmentTest.java index c4e11687be5..1ce1613e010 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerAttachmentTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerAttachmentTest.java @@ -92,8 +92,8 @@ void beforeAll() throws Exception { SessionProviderImpl sessionProvider = new SessionProviderImpl(null, null); QuotaComponents quotaComponents = QuotaComponents.disabled(sessionProvider, mapperFactory); - MessageIdManager messageIdManager = new StoreMessageIdManager(storeRightManager, mapperFactory - , eventBus, new NoQuotaManager(), mock(QuotaRootResolver.class), PreDeletionHooks.NO_PRE_DELETION_HOOK); + MessageIdManager messageIdManager = new StoreMessageIdManager(storeRightManager, mapperFactory, + eventBus, new NoQuotaManager(), mock(QuotaRootResolver.class), PreDeletionHooks.NO_PRE_DELETION_HOOK); StoreAttachmentManager storeAttachmentManager = new StoreAttachmentManager(mapperFactory, messageIdManager); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMapperProvider.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMapperProvider.java index ebd3a51cf0a..06cc36cca80 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMapperProvider.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMapperProvider.java @@ -37,6 +37,7 @@ import org.apache.james.mailbox.model.MessageId; import org.apache.james.mailbox.postgres.PostgresMailboxId; import org.apache.james.mailbox.postgres.PostgresMessageId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresAttachmentDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxMessageDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; @@ -46,12 +47,9 @@ import org.apache.james.mailbox.store.mail.MessageMapper; import org.apache.james.mailbox.store.mail.UidProvider; import org.apache.james.mailbox.store.mail.model.MapperProvider; -import org.apache.james.mailbox.store.mail.model.MessageUidProvider; import org.apache.james.server.blob.deduplication.DeDuplicationBlobStore; import org.apache.james.utils.UpdatableTickingClock; -import org.testcontainers.utility.ThrowingFunction; -import com.github.fge.lambdas.Throwing; import com.google.common.collect.ImmutableList; public class PostgresMapperProvider implements MapperProvider { @@ -106,7 +104,10 @@ public MessageIdMapper createMessageIdMapper() { new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), blobIdFactory), new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()), new PostgresModSeqProvider(mailboxDAO), - blobStore, blobIdFactory, updatableTickingClock); + new PostgresAttachmentMapper(new PostgresAttachmentDAO(postgresExtension.getPostgresExecutor(), blobIdFactory), blobStore), + blobStore, + blobIdFactory, + updatableTickingClock); } @Override diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageBlobReferenceSourceTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageBlobReferenceSourceTest.java index 37b5a911172..56de642dd54 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageBlobReferenceSourceTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageBlobReferenceSourceTest.java @@ -75,8 +75,8 @@ void blobReferencesShouldReturnAllBlobs() { SimpleMailboxMessage message = createMessage(messageId1, ThreadId.fromBaseMessageId(messageId1), CONTENT, BODY_START, new PropertyBuilder()); MessageId messageId2 = PostgresMessageId.Factory.of(UUID.randomUUID()); MailboxMessage message2 = createMessage(messageId2, ThreadId.fromBaseMessageId(messageId2), CONTENT_2, BODY_START, new PropertyBuilder()); - postgresMessageDAO.insert(message, "1") .block(); - postgresMessageDAO.insert(message2, "2") .block(); + postgresMessageDAO.insert(message, "1").block(); + postgresMessageDAO.insert(message2, "2").block(); assertThat(blobReferenceSource.listReferencedBlobs().collectList().block()) .hasSize(2); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperTest.java index 55a6864e881..f41d5561075 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperTest.java @@ -32,6 +32,7 @@ public class PostgresMessageMapperTest extends MessageMapperTest { static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresMailboxAggregateModule.MODULE); private PostgresMapperProvider postgresMapperProvider; + @Override protected MapperProvider createMapperProvider() { postgresMapperProvider = new PostgresMapperProvider(postgresExtension); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java index 553d605612b..acd0bb2cef5 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java @@ -22,8 +22,8 @@ import static org.assertj.core.api.Assertions.assertThat; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.Username; import org.apache.james.mailbox.MailboxSession; import org.apache.james.mailbox.MailboxSessionUtil; From e36a3d536f7872373fb3926a775c8aae5fbf4c83 Mon Sep 17 00:00:00 2001 From: hung phan Date: Thu, 14 Mar 2024 16:15:15 +0700 Subject: [PATCH 248/341] JAMES-2586 Bind PostgresMessageFastViewProjection --- .../apache/james/modules/data/PostgresDataJmapModule.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java index 63afa8f77a9..b2ffdd3bfeb 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java @@ -34,11 +34,11 @@ import org.apache.james.jmap.api.pushsubscription.PushDeleteUserDataTaskStep; import org.apache.james.jmap.api.upload.UploadRepository; import org.apache.james.jmap.memory.access.MemoryAccessTokenRepository; -import org.apache.james.jmap.memory.projections.MemoryMessageFastViewProjection; import org.apache.james.jmap.postgres.filtering.PostgresFilteringProjection; import org.apache.james.jmap.postgres.identity.PostgresCustomIdentityDAO; import org.apache.james.jmap.postgres.projections.PostgresEmailQueryView; import org.apache.james.jmap.postgres.projections.PostgresEmailQueryViewManager; +import org.apache.james.jmap.postgres.projections.PostgresMessageFastViewProjection; import org.apache.james.jmap.postgres.upload.PostgresUploadRepository; import org.apache.james.mailbox.store.extractor.DefaultTextExtractor; import org.apache.james.user.api.DeleteUserDataTaskStep; @@ -67,8 +67,8 @@ protected void configure() { bind(DefaultTextExtractor.class).in(Scopes.SINGLETON); - bind(MemoryMessageFastViewProjection.class).in(Scopes.SINGLETON); - bind(MessageFastViewProjection.class).to(MemoryMessageFastViewProjection.class); + bind(PostgresMessageFastViewProjection.class).in(Scopes.SINGLETON); + bind(MessageFastViewProjection.class).to(PostgresMessageFastViewProjection.class); bind(PostgresEmailQueryView.class).in(Scopes.SINGLETON); bind(EmailQueryView.class).to(PostgresEmailQueryView.class); From b6ae00f2015f2ea31d3a17c3bdc3ee4d9324a2a5 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 6 Mar 2024 10:55:47 +0700 Subject: [PATCH 249/341] JAMES-2586 Optimize AttachmentLoader - get the list replaced to get each by each --- .../postgres/mail/AttachmentLoader.java | 24 +++++-- .../mail/PostgresMessageIdMapper.java | 64 ++++++------------- .../mail/dao/PostgresAttachmentDAO.java | 3 + 3 files changed, 43 insertions(+), 48 deletions(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/AttachmentLoader.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/AttachmentLoader.java index 3e2b2b7f118..874d463c2c2 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/AttachmentLoader.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/AttachmentLoader.java @@ -21,14 +21,21 @@ import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.ATTACHMENT_METADATA; +import java.util.List; +import java.util.Map; + import org.apache.commons.lang3.tuple.Pair; +import org.apache.james.mailbox.model.AttachmentId; import org.apache.james.mailbox.model.AttachmentMetadata; import org.apache.james.mailbox.model.MessageAttachmentMetadata; +import org.apache.james.mailbox.postgres.mail.dto.AttachmentsDTO; import org.apache.james.mailbox.store.mail.MessageMapper; import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; import org.apache.james.util.ReactorUtils; import org.jooq.Record; +import com.google.common.collect.ImmutableMap; + import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -45,10 +52,8 @@ public Flux> addAttachmentToMessage(F return findMessagePublisher.flatMap(pair -> { if (fetchType == MessageMapper.FetchType.FULL || fetchType == MessageMapper.FetchType.ATTACHMENTS_METADATA) { return Mono.fromCallable(() -> pair.getRight().get(ATTACHMENT_METADATA)) - .flatMapMany(Flux::fromIterable) - .flatMapSequential(attachmentRepresentation -> attachmentMapper.getAttachmentReactive(attachmentRepresentation.getAttachmentId()) - .map(attachment -> constructMessageAttachment(attachment, attachmentRepresentation))) - .collectList() + .map(e -> toMap((AttachmentsDTO) e)) + .flatMap(this::getAttachments) .map(messageAttachmentMetadata -> { pair.getLeft().addAttachments(messageAttachmentMetadata); return pair; @@ -59,6 +64,17 @@ public Flux> addAttachmentToMessage(F }, ReactorUtils.DEFAULT_CONCURRENCY); } + private Map toMap(AttachmentsDTO attachmentRepresentations) { + return attachmentRepresentations.stream().collect(ImmutableMap.toImmutableMap(MessageRepresentation.AttachmentRepresentation::getAttachmentId, obj -> obj)); + } + + private Mono> getAttachments(Map mapAttachmentIdToAttachmentRepresentation) { + return Mono.fromCallable(mapAttachmentIdToAttachmentRepresentation::keySet) + .flatMapMany(attachmentMapper::getAttachmentsReactive) + .map(attachmentMetadata -> constructMessageAttachment(attachmentMetadata, mapAttachmentIdToAttachmentRepresentation.get(attachmentMetadata.getAttachmentId()))) + .collectList(); + } + private MessageAttachmentMetadata constructMessageAttachment(AttachmentMetadata attachment, MessageRepresentation.AttachmentRepresentation messageAttachmentRepresentation) { return MessageAttachmentMetadata.builder() .attachment(attachment) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapper.java index 6e9d12fce79..695c066da04 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapper.java @@ -21,7 +21,6 @@ import static org.apache.james.blob.api.BlobStore.StoragePolicy.LOW_COST; import static org.apache.james.blob.api.BlobStore.StoragePolicy.SIZE_BASED; -import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.ATTACHMENT_METADATA; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.BODY_BLOB_ID; import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.HEADER_CONTENT; @@ -29,9 +28,9 @@ import java.io.InputStream; import java.time.Clock; import java.util.Collection; +import java.util.Comparator; import java.util.Date; import java.util.List; -import java.util.Map; import java.util.function.Function; import javax.mail.Flags; @@ -45,14 +44,11 @@ import org.apache.james.mailbox.ModSeq; import org.apache.james.mailbox.exception.MailboxException; import org.apache.james.mailbox.exception.MailboxNotFoundException; -import org.apache.james.mailbox.model.AttachmentId; -import org.apache.james.mailbox.model.AttachmentMetadata; import org.apache.james.mailbox.model.ComposedMessageIdWithMetaData; import org.apache.james.mailbox.model.Content; import org.apache.james.mailbox.model.HeaderAndBodyByteContent; import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MailboxId; -import org.apache.james.mailbox.model.MessageAttachmentMetadata; import org.apache.james.mailbox.model.MessageId; import org.apache.james.mailbox.model.UpdatedFlags; import org.apache.james.mailbox.postgres.PostgresMailboxId; @@ -74,7 +70,6 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableListMultimap; -import com.google.common.collect.ImmutableMap; import com.google.common.collect.Multimap; import com.google.common.io.ByteSource; @@ -105,10 +100,10 @@ public long size() { private final PostgresMessageDAO messageDAO; private final PostgresMailboxMessageDAO mailboxMessageDAO; private final PostgresModSeqProvider modSeqProvider; - private final PostgresAttachmentMapper attachmentMapper; private final BlobStore blobStore; private final BlobId.Factory blobIdFactory; private final Clock clock; + private final AttachmentLoader attachmentLoader; public PostgresMessageIdMapper(PostgresMailboxDAO mailboxDAO, PostgresMessageDAO messageDAO, @@ -122,10 +117,10 @@ public PostgresMessageIdMapper(PostgresMailboxDAO mailboxDAO, this.messageDAO = messageDAO; this.mailboxMessageDAO = mailboxMessageDAO; this.modSeqProvider = modSeqProvider; - this.attachmentMapper = attachmentMapper; this.blobStore = blobStore; this.blobIdFactory = blobIdFactory; this.clock = clock; + this.attachmentLoader = new AttachmentLoader(attachmentMapper);; } @Override @@ -142,42 +137,23 @@ public Publisher findMetadata(MessageId messageId @Override public Flux findReactive(Collection messageIds, MessageMapper.FetchType fetchType) { - return mailboxMessageDAO.findMessagesByMessageIds(messageIds.stream().map(PostgresMessageId.class::cast).collect(ImmutableList.toImmutableList()), fetchType) - .flatMap(messageBuilderAndRecord -> { - SimpleMailboxMessage.Builder messageBuilder = messageBuilderAndRecord.getLeft(); - Record record = messageBuilderAndRecord.getRight(); - if (fetchType == MessageMapper.FetchType.FULL) { - return retrieveFullMessage(messageBuilder, record); - } - return Mono.just(messageBuilder.build()); - }, ReactorUtils.DEFAULT_CONCURRENCY); - } - - private Mono retrieveFullMessage(SimpleMailboxMessage.Builder messageBuilder, Record record) { - return retrieveFullContent(record).flatMap(headerAndBodyContent -> getAttachments(toMap(record.get(ATTACHMENT_METADATA))) - .map(messageAttachmentMetadataList -> messageBuilder.content(headerAndBodyContent).addAttachments(messageAttachmentMetadataList).build())); - } - - private Map toMap(List attachmentRepresentations) { - return attachmentRepresentations.stream().collect(ImmutableMap.toImmutableMap(MessageRepresentation.AttachmentRepresentation::getAttachmentId, obj -> obj)); - } - - private Mono> getAttachments(Map mapAttachmentIdToAttachmentRepresentation) { - return attachmentMapper.getAttachmentsReactive(mapAttachmentIdToAttachmentRepresentation.values() - .stream() - .map(MessageRepresentation.AttachmentRepresentation::getAttachmentId) - .collect(ImmutableList.toImmutableList())) - .map(attachmentMetadata -> constructMessageAttachment(attachmentMetadata, mapAttachmentIdToAttachmentRepresentation.get(attachmentMetadata.getAttachmentId()))) - .collectList(); - } - - private MessageAttachmentMetadata constructMessageAttachment(AttachmentMetadata attachment, MessageRepresentation.AttachmentRepresentation messageAttachmentRepresentation) { - return MessageAttachmentMetadata.builder() - .attachment(attachment) - .name(messageAttachmentRepresentation.getName().orElse(null)) - .cid(messageAttachmentRepresentation.getCid()) - .isInline(messageAttachmentRepresentation.isInline()) - .build(); + Flux> fetchMessageWithoutFullContentPublisher = mailboxMessageDAO.findMessagesByMessageIds(messageIds.stream().map(PostgresMessageId.class::cast).collect(ImmutableList.toImmutableList()), fetchType); + Flux> fetchMessagePublisher = attachmentLoader.addAttachmentToMessage(fetchMessageWithoutFullContentPublisher, fetchType); + + if (fetchType == MessageMapper.FetchType.FULL) { + return fetchMessagePublisher + .flatMap(messageBuilderAndRecord -> { + SimpleMailboxMessage.Builder messageBuilder = messageBuilderAndRecord.getLeft(); + return retrieveFullContent(messageBuilderAndRecord.getRight()) + .map(headerAndBodyContent -> messageBuilder.content(headerAndBodyContent).build()); + }, ReactorUtils.DEFAULT_CONCURRENCY) + .sort(Comparator.comparing(MailboxMessage::getUid)) + .map(message -> message); + } else { + return fetchMessagePublisher + .map(messageBuilderAndBlobId -> messageBuilderAndBlobId.getLeft() + .build()); + } } @Override diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresAttachmentDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresAttachmentDAO.java index 7d60ff51c58..8649a1329c6 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresAttachmentDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresAttachmentDAO.java @@ -84,6 +84,9 @@ public Mono> getAttachment(AttachmentId attachm } public Flux getAttachments(Collection attachmentIds) { + if (attachmentIds.isEmpty()) { + return Flux.empty(); + } return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.selectFrom(PostgresAttachmentTable.TABLE_NAME) .where(PostgresAttachmentTable.ID.in(attachmentIds.stream().map(AttachmentId::getId).collect(ImmutableList.toImmutableList()))))) .map(row -> AttachmentMetadata.builder() From 94a9bb8a5fa4d63a8c6474b2174f8c0040fe47fe Mon Sep 17 00:00:00 2001 From: hung phan Date: Thu, 14 Mar 2024 16:38:13 +0700 Subject: [PATCH 250/341] JAMES-2586 Fix PostgresEmailGetMethodTest --- .../jmap/rfc8621/postgres/PostgresEmailGetMethodTest.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailGetMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailGetMethodTest.java index 96da1f79eaa..43e5c293fc1 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailGetMethodTest.java +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailGetMethodTest.java @@ -22,10 +22,7 @@ import org.apache.james.jmap.rfc8621.contract.EmailGetMethodContract; import org.apache.james.mailbox.model.MessageId; import org.apache.james.mailbox.postgres.PostgresMessageId; -import org.junit.jupiter.api.Disabled; -@Disabled -// TODO Need to fix public class PostgresEmailGetMethodTest extends PostgresBase implements EmailGetMethodContract { public static final PostgresMessageId.Factory MESSAGE_ID_FACTORY = new PostgresMessageId.Factory(); From 41c1bc45cd751e03724f6ab69e0055e06eeaa289 Mon Sep 17 00:00:00 2001 From: hung phan Date: Thu, 14 Mar 2024 16:39:02 +0700 Subject: [PATCH 251/341] JAMES-2586 Fix PostgresEmailQueryMethodTest --- .../contract/EmailQueryMethodContract.scala | 34 ++++++----- .../PostgresEmailQueryMethodTest.java | 61 ++++++++++++++++++- 2 files changed, 76 insertions(+), 19 deletions(-) diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailQueryMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailQueryMethodContract.scala index e7a01c144d4..f7ed0d23aa5 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailQueryMethodContract.scala +++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailQueryMethodContract.scala @@ -54,7 +54,7 @@ import org.apache.james.util.ClassLoaderUtils import org.apache.james.utils.DataProbeImpl import org.awaitility.Awaitility import org.awaitility.Durations.ONE_HUNDRED_MILLISECONDS -import org.junit.jupiter.api.{BeforeEach, Test} +import org.junit.jupiter.api.{BeforeEach, RepeatedTest, Test} import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.{Arguments, MethodSource, ValueSource} import org.threeten.extra.Seconds @@ -7033,22 +7033,24 @@ trait EmailQueryMethodContract { | "c1"]] |}""".stripMargin - val response = `given` - .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) - .body(request) - .when - .post - .`then` - .statusCode(SC_OK) - .contentType(JSON) - .extract - .body - .asString + awaitAtMostTenSeconds.untilAsserted { () => + val response = `given` + .header(ACCEPT.toString, ACCEPT_RFC8621_VERSION_HEADER) + .body(request) + .when + .post + .`then` + .statusCode(SC_OK) + .contentType(JSON) + .extract + .body + .asString - assertThatJson(response) - .withOptions(IGNORING_ARRAY_ORDER) - .inPath("$.methodResponses[0][1].ids") - .isEqualTo(s"""["${messageId1.serialize}","${messageId2.serialize}"]""") + assertThatJson(response) + .withOptions(IGNORING_ARRAY_ORDER) + .inPath("$.methodResponses[0][1].ids") + .isEqualTo(s"""["${messageId1.serialize}","${messageId2.serialize}"]""") + } } @Test diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailQueryMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailQueryMethodTest.java index 3d4c3d336c5..9ca92ff0d6a 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailQueryMethodTest.java +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailQueryMethodTest.java @@ -19,10 +19,65 @@ package org.apache.james.jmap.rfc8621.postgres; +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; + +import org.apache.james.DockerOpenSearchExtension; +import org.apache.james.GuiceJamesServer; +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.PostgresJamesConfiguration; +import org.apache.james.PostgresJamesServerMain; +import org.apache.james.SearchConfiguration; +import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.jmap.rfc8621.contract.EmailQueryMethodContract; +import org.apache.james.jmap.rfc8621.contract.IdentityProbeModule; +import org.apache.james.jmap.rfc8621.contract.probe.DelegationProbeModule; +import org.apache.james.modules.RabbitMQExtension; +import org.apache.james.modules.TestJMAPServerModule; +import org.apache.james.modules.blobstore.BlobStoreConfiguration; import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresEmailQueryMethodTest implements EmailQueryMethodContract { + @RegisterExtension + static JamesServerExtension testExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.openSearch()) + .usersRepository(DEFAULT) + .eventBusImpl(PostgresJamesConfiguration.EventBusImpl.RABBITMQ) + .blobStore(BlobStoreConfiguration.builder() + .postgres() + .disableCache() + .deduplication() + .noCryptoConfig()) + .build()) + .extension(PostgresExtension.empty()) + .extension(new RabbitMQExtension()) + .extension(new DockerOpenSearchExtension()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(new TestJMAPServerModule()) + .overrideWith(new DelegationProbeModule()) + .overrideWith(new IdentityProbeModule())) + .build(); + + @Override + @Test + @Disabled("Flaky test. TODO stabilize it.") + public void listMailsShouldBeSortedWhenUsingTo(GuiceJamesServer server) { + } + + @Override + @Test + @Disabled("Flaky test. TODO stabilize it.") + public void listMailsShouldBeSortedWhenUsingFrom(GuiceJamesServer server) { + } -@Disabled -// TODO Need to fix -public class PostgresEmailQueryMethodTest extends PostgresBase implements EmailQueryMethodContract { + @Override + @Test + @Disabled("Flaky test. TODO stabilize it.") + public void inMailboxOtherThanShouldBeRejectedWhenInOperator(GuiceJamesServer server) { + } } From e33fba576c3c3305985942d8e41e84f55fa6caf9 Mon Sep 17 00:00:00 2001 From: hung phan Date: Thu, 14 Mar 2024 16:39:31 +0700 Subject: [PATCH 252/341] JAMES-2586 Fix PostgresMailboxSetMethodTest --- .../contract/MailboxSetMethodContract.scala | 1 + .../postgres/PostgresMailboxSetMethodTest.java | 14 -------------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxSetMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxSetMethodContract.scala index 3ebacb4d02b..363a2d4de13 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxSetMethodContract.scala +++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxSetMethodContract.scala @@ -8168,6 +8168,7 @@ trait MailboxSetMethodContract { | }, "c1"]] |}""".stripMargin)) + ws.receive().asPayload List(ws.receive().asPayload) }) .send(backend) diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxSetMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxSetMethodTest.java index 20d62878ee6..8346421fb1e 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxSetMethodTest.java +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresMailboxSetMethodTest.java @@ -37,20 +37,6 @@ public String errorInvalidMailboxIdMessage(String value) { return String.format("%s is not a mailboxId: Invalid UUID string: %s", value, value); } - @Override - @Test - @Disabled - // TODO Need to fix - public void webSocketShouldPushNewMessageWhenChangeSubscriptionOfMailbox(GuiceJamesServer server) { - } - - @Override - @Test - @Disabled - // TODO Need to fix - public void updateShouldRenameMailboxesWithManyChildren(GuiceJamesServer server) { - } - @Override @Test @Disabled("Distributed event bus is asynchronous, we cannot expect the newState to be returned immediately after Mailbox/set call") From 0399a723780eb9f9fbc9b59c867e63f9fc326209 Mon Sep 17 00:00:00 2001 From: hung phan Date: Thu, 14 Mar 2024 16:40:24 +0700 Subject: [PATCH 253/341] JAMES-2586 remove redundant import in PostgresPushSubscriptionSetMethodTest --- .../postgres/PostgresPushSubscriptionSetMethodTest.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresPushSubscriptionSetMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresPushSubscriptionSetMethodTest.java index 1c743380d53..93696a33db0 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresPushSubscriptionSetMethodTest.java +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresPushSubscriptionSetMethodTest.java @@ -21,7 +21,6 @@ import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; -import org.apache.james.GuiceJamesServer; import org.apache.james.JamesServerBuilder; import org.apache.james.JamesServerExtension; import org.apache.james.PostgresJamesConfiguration; @@ -35,8 +34,6 @@ import org.apache.james.modules.RabbitMQExtension; import org.apache.james.modules.TestJMAPServerModule; import org.apache.james.modules.blobstore.BlobStoreConfiguration; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; public class PostgresPushSubscriptionSetMethodTest implements PushSubscriptionSetMethodContract { From 4bfbe838f1673bbd731ccc5a35ca4862ebd3f188 Mon Sep 17 00:00:00 2001 From: hung phan Date: Thu, 14 Mar 2024 16:41:08 +0700 Subject: [PATCH 254/341] JAMES-2586 Remove opensearch in PostgresWebPushTest --- .../james/jmap/rfc8621/postgres/PostgresWebPushTest.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresWebPushTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresWebPushTest.java index 1849d7994df..919bb3fecd2 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresWebPushTest.java +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresWebPushTest.java @@ -22,7 +22,6 @@ import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; import org.apache.james.ClockExtension; -import org.apache.james.DockerOpenSearchExtension; import org.apache.james.JamesServerBuilder; import org.apache.james.JamesServerExtension; import org.apache.james.PostgresJamesConfiguration; @@ -44,7 +43,7 @@ public class PostgresWebPushTest implements WebPushContract { PostgresJamesConfiguration.builder() .workingDirectory(tmpDir) .configurationFromClasspath() - .searchConfiguration(SearchConfiguration.openSearch()) + .searchConfiguration(SearchConfiguration.scanning()) .usersRepository(DEFAULT) .eventBusImpl(PostgresJamesConfiguration.EventBusImpl.RABBITMQ) .blobStore(BlobStoreConfiguration.builder() @@ -55,7 +54,6 @@ public class PostgresWebPushTest implements WebPushContract { .build()) .extension(PostgresExtension.empty()) .extension(new RabbitMQExtension()) - .extension(new DockerOpenSearchExtension()) .extension(new ClockExtension()) .server(configuration -> PostgresJamesServerMain.createServer(configuration) .overrideWith(new TestJMAPServerModule()) From a03f857bc5b2a8c883acafd8cddf35bdf42b0764 Mon Sep 17 00:00:00 2001 From: vttran Date: Thu, 29 Feb 2024 10:14:36 +0700 Subject: [PATCH 255/341] JAMES-3925 - JMAP Upload - Method delete of Upload Repository should return Boolean value when applied --- .../upload/CassandraUploadRepositoryTest.java | 12 ++++++++++++ .../jmap/api/upload/UploadRepositoryContract.scala | 13 +++++++++++++ 2 files changed, 25 insertions(+) diff --git a/server/data/data-jmap-cassandra/src/test/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepositoryTest.java b/server/data/data-jmap-cassandra/src/test/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepositoryTest.java index 285ad9a0908..88e7bde6327 100644 --- a/server/data/data-jmap-cassandra/src/test/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepositoryTest.java +++ b/server/data/data-jmap-cassandra/src/test/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepositoryTest.java @@ -76,4 +76,16 @@ public void deleteShouldReturnFalseWhenRowDoesNotExist() { public UpdatableTickingClock clock() { return clock; } + + @Disabled("Delete method always return true (to avoid LWT)") + @Override + public void deleteShouldReturnTrueWhenRowExists() { + UploadRepositoryContract.super.deleteShouldReturnTrueWhenRowExists(); + } + + @Disabled("Delete method always return true (to avoid LWT)") + @Override + public void deleteShouldReturnFalseWhenRowDoesNotExist() { + UploadRepositoryContract.super.deleteShouldReturnFalseWhenRowDoesNotExist(); + } } \ No newline at end of file diff --git a/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/upload/UploadRepositoryContract.scala b/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/upload/UploadRepositoryContract.scala index 68554a1664a..cce5999873d 100644 --- a/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/upload/UploadRepositoryContract.scala +++ b/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/upload/UploadRepositoryContract.scala @@ -218,4 +218,17 @@ .isNotNull } + @Test + def deleteShouldReturnTrueWhenRowExists(): Unit = { + val uploadId: UploadId = SMono.fromPublisher(testee.upload(data(), CONTENT_TYPE, USER)).block().uploadId + + assertThat(SMono.fromPublisher(testee.delete(uploadId, USER)).block()).isTrue + } + + @Test + def deleteShouldReturnFalseWhenRowDoesNotExist(): Unit = { + val uploadIdOfAlice: UploadId = SMono.fromPublisher(testee.upload(data(), CONTENT_TYPE, Username.of("Alice"))).block().uploadId + assertThat(SMono.fromPublisher(testee.delete(uploadIdOfAlice, Username.of("Bob"))).block()).isFalse + } + } From 6bfe15ed5d9b1192162e34cb0897551330f662b8 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Tue, 19 Mar 2024 07:37:36 +0700 Subject: [PATCH 256/341] JAMES-2586 - JMAP Upload - Fix unstable UploadService test - Method delete of Upload Repository should return Boolean value when applied - optimize resetSpace method - single call replace to twice call (get & update) - The auto cleanup upload when exceed do not ensure the concurrent -> we need sleep time after concurrent upload. --- .../quota/PostgresQuotaCurrentValueDAO.java | 15 ++++++ .../postgres/upload/PostgresUploadDAO.java | 8 ++-- .../upload/PostgresUploadRepository.java | 2 +- .../upload/PostgresUploadUsageRepository.java | 7 ++- .../upload/PostgresUploadServiceTest.java | 7 --- .../PostgresUploadUsageRepositoryTest.java | 47 +++++++++++++++++++ .../api/upload/UploadServiceContract.scala | 5 ++ 7 files changed, 76 insertions(+), 15 deletions(-) create mode 100644 server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadUsageRepositoryTest.java diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java index 472f594f960..7f6f4de0a36 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java @@ -67,6 +67,21 @@ public Mono updateCurrentValue(QuotaCurrentValue.Key quotaKey, long amount .map(record -> record.get(CURRENT_VALUE)); } + public Mono upsert(QuotaCurrentValue.Key quotaKey, long newCurrentValue) { + return update(quotaKey, newCurrentValue) + .switchIfEmpty(Mono.defer(() -> insert(quotaKey, newCurrentValue, IS_INCREASE))); + } + + public Mono update(QuotaCurrentValue.Key quotaKey, long newCurrentValue) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.update(TABLE_NAME) + .set(CURRENT_VALUE, newCurrentValue) + .where(IDENTIFIER.eq(quotaKey.getIdentifier()), + COMPONENT.eq(quotaKey.getQuotaComponent().getValue()), + TYPE.eq(quotaKey.getQuotaType().getValue())) + .returning(CURRENT_VALUE))) + .map(record -> record.get(CURRENT_VALUE)); + } + private Field getCurrentValueOperator(boolean isIncrease, long amount) { if (isIncrease) { return CURRENT_VALUE.plus(amount); diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadDAO.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadDAO.java index b6f43f5c303..1c493702a11 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadDAO.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadDAO.java @@ -94,10 +94,12 @@ public Mono get(UploadId uploadId, Username user) { .map(this::uploadMetaDataFromRow); } - public Mono delete(UploadId uploadId, Username user) { - return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.deleteFrom(PostgresUploadTable.TABLE_NAME) + public Mono delete(UploadId uploadId, Username user) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.deleteFrom(PostgresUploadTable.TABLE_NAME) .where(PostgresUploadTable.ID.eq(uploadId.getId())) - .and(PostgresUploadTable.USER_NAME.eq(user.asString())))); + .and(PostgresUploadTable.USER_NAME.eq(user.asString())) + .returning(PostgresUploadTable.ID))) + .hasElement(); } public Flux> listByUploadDateBefore(LocalDateTime before) { diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java index 233fda50281..1a21d0181b4 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java @@ -85,7 +85,7 @@ public Mono retrieve(UploadId id, Username user) { } @Override - public Mono delete(UploadId id, Username user) { + public Mono delete(UploadId id, Username user) { return uploadDAOFactory.create(user.getDomainPart()).delete(id, user); } diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadUsageRepository.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadUsageRepository.java index a3d02cb5070..5f0f600fe8b 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadUsageRepository.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadUsageRepository.java @@ -62,9 +62,8 @@ public Mono getSpaceUsage(Username username) { } @Override - public Mono resetSpace(Username username, QuotaSizeUsage usage) { - return getSpaceUsage(username) - .switchIfEmpty(Mono.just(QuotaSizeUsage.ZERO)) - .flatMap(quotaSizeUsage -> decreaseSpace(username, QuotaSizeUsage.size(quotaSizeUsage.asLong() - usage.asLong()))); + public Mono resetSpace(Username username, QuotaSizeUsage newUsage) { + return quotaCurrentValueDAO.upsert(QuotaCurrentValue.Key.of(QuotaComponent.JMAP_UPLOADS, username.asString(), QuotaType.SIZE), newUsage.asLong()) + .then(); } } diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadServiceTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadServiceTest.java index e2f7fde4590..2884acd333e 100644 --- a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadServiceTest.java +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadServiceTest.java @@ -36,8 +36,6 @@ import org.apache.james.jmap.api.upload.UploadUsageRepository; import org.apache.james.server.blob.deduplication.DeDuplicationBlobStore; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; public class PostgresUploadServiceTest implements UploadServiceContract { @@ -76,10 +74,5 @@ public UploadService testee() { return testee; } - @Override - @Test - @Disabled("Flaky test. TODO stabilize it.") - public void uploadShouldUpdateCurrentStoredUsageUponCleaningUploadSpace() { - } } diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadUsageRepositoryTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadUsageRepositoryTest.java new file mode 100644 index 00000000000..1064a42b182 --- /dev/null +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadUsageRepositoryTest.java @@ -0,0 +1,47 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.jmap.postgres.upload; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.quota.PostgresQuotaCurrentValueDAO; +import org.apache.james.backends.postgres.quota.PostgresQuotaModule; +import org.apache.james.jmap.api.upload.UploadUsageRepository; +import org.apache.james.jmap.api.upload.UploadUsageRepositoryContract; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PostgresUploadUsageRepositoryTest implements UploadUsageRepositoryContract { + + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity( + PostgresModule.aggregateModules(PostgresUploadModule.MODULE, PostgresQuotaModule.MODULE)); + + private PostgresUploadUsageRepository uploadUsageRepository; + @BeforeEach + public void setup() { + uploadUsageRepository = new PostgresUploadUsageRepository(new PostgresQuotaCurrentValueDAO(postgresExtension.getPostgresExecutor())); + resetCounterToZero(); + } + @Override + public UploadUsageRepository uploadUsageRepository() { + return uploadUsageRepository; + } +} diff --git a/server/data/data-jmap/src/test/scala/org/apache/james/jmap/api/upload/UploadServiceContract.scala b/server/data/data-jmap/src/test/scala/org/apache/james/jmap/api/upload/UploadServiceContract.scala index 82ab4495742..fcf17059b20 100644 --- a/server/data/data-jmap/src/test/scala/org/apache/james/jmap/api/upload/UploadServiceContract.scala +++ b/server/data/data-jmap/src/test/scala/org/apache/james/jmap/api/upload/UploadServiceContract.scala @@ -108,6 +108,7 @@ trait UploadServiceContract { .block()) // Exceed 100 bytes limit + Thread.sleep(500) SMono.fromPublisher(testee.upload(asInputStream(TEN_BYTES_DATA_STRING), CONTENT_TYPE, BOB)) .block() @@ -126,6 +127,7 @@ trait UploadServiceContract { .block()) // Exceed 100 bytes limit + Thread.sleep(500) SMono.fromPublisher(testee.upload(asInputStream(TEN_BYTES_DATA_STRING), CONTENT_TYPE, BOB)) .block() @@ -146,6 +148,7 @@ trait UploadServiceContract { .block() // Exceed 100 bytes limit + Thread.sleep(500) SMono.fromPublisher(testee.upload(asInputStream(TEN_BYTES_DATA_STRING), CONTENT_TYPE, BOB)) .block() @@ -173,6 +176,7 @@ trait UploadServiceContract { .block()) // Exceed 100 bytes limit + Thread.sleep(500) SMono.fromPublisher(testee.upload(asInputStream(TEN_BYTES_DATA_STRING), CONTENT_TYPE, BOB)) .block() @@ -192,6 +196,7 @@ trait UploadServiceContract { SMono(uploadUsageRepository.resetSpace(BOB, QuotaSizeUsage.size(105L))).block() // Exceed 100 bytes limit + Thread.sleep(500) SMono.fromPublisher(testee.upload(asInputStream(TEN_BYTES_DATA_STRING), CONTENT_TYPE, BOB)).block() // The current stored usage should be eventually consistent From 30cf9a565542b7e9e2c5bd3674d296db17ace185 Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Wed, 20 Mar 2024 14:31:53 +0700 Subject: [PATCH 257/341] JAMES-2586 Fix Postgres build after rebase on master --- mailbox/postgres/pom.xml | 10 ++++++++-- .../postgres/PostgresMessageManager.java | 2 +- .../postgres/mail/PostgresMessageIdMapper.java | 2 +- .../postgres/mail/PostgresMessageMapper.java | 2 +- .../mail/dao/PostgresMailboxMessageDAO.java | 3 ++- .../dao/PostgresMailboxMessageDAOUtils.java | 2 +- .../postgres/search/DeletedSearchOverride.java | 3 ++- .../search/DeletedWithRangeSearchOverride.java | 3 ++- .../NotDeletedWithRangeSearchOverride.java | 3 ++- .../postgres/search/UnseenSearchOverride.java | 3 ++- .../PostgresMessageBlobReferenceSourceTest.java | 2 +- ...stgresMessageMapperRowLevelSecurityTest.java | 2 +- .../postgres/search/AllSearchOverrideTest.java | 2 +- .../search/DeletedSearchOverrideTest.java | 6 +++--- .../DeletedWithRangeSearchOverrideTest.java | 6 +++--- .../NotDeletedWithRangeSearchOverrideTest.java | 4 ++-- .../postgres/search/SearchOverrideFixture.java | 2 +- .../postgres/search/UidSearchOverrideTest.java | 2 +- .../search/UnseenSearchOverrideTest.java | 4 ++-- .../upload/CassandraUploadRepositoryTest.java | 12 ------------ .../identity/PostgresCustomIdentityDAO.java | 4 ++-- .../api/upload/UploadRepositoryContract.scala | 15 +-------------- server/data/data-postgres/pom.xml | 7 ++++++- .../postgres/PostgresMailRepository.java | 3 ++- .../PostgresMailRepositoryContentDAO.java | 5 +++-- ...esMailRepositoryBlobReferenceSourceTest.java | 2 +- .../james/rrt/postgres/PostgresStepdefs.java | 4 ++-- .../james/rrt/postgres/RewriteTablesTest.java | 17 +++++++++-------- ...gresWebAdminServerBlobGCIntegrationTest.java | 4 ++-- 29 files changed, 65 insertions(+), 71 deletions(-) diff --git a/mailbox/postgres/pom.xml b/mailbox/postgres/pom.xml index 925acd4052e..628e995e3de 100644 --- a/mailbox/postgres/pom.xml +++ b/mailbox/postgres/pom.xml @@ -110,6 +110,12 @@ event-bus-in-vm test + + ${james.groupId} + james-json + test-jar + test + ${james.groupId} james-server-data-postgres @@ -150,8 +156,8 @@ ${uuid-creator.version} - com.sun.mail - javax.mail + org.eclipse.angus + jakarta.mail org.jasypt diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMessageManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMessageManager.java index 282017bb864..ad2621b4aaf 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMessageManager.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMessageManager.java @@ -24,7 +24,7 @@ import java.util.List; import java.util.Optional; -import javax.mail.Flags; +import jakarta.mail.Flags; import org.apache.james.events.EventBus; import org.apache.james.mailbox.MailboxPathLocker; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapper.java index 695c066da04..e9df32ae4a8 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapper.java @@ -33,7 +33,7 @@ import java.util.List; import java.util.function.Function; -import javax.mail.Flags; +import jakarta.mail.Flags; import org.apache.commons.lang3.tuple.Pair; import org.apache.james.backends.postgres.utils.PostgresUtils; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java index 9fc948a2bf9..324c244a38f 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java @@ -35,7 +35,7 @@ import java.util.Optional; import java.util.function.Function; -import javax.mail.Flags; +import jakarta.mail.Flags; import org.apache.commons.lang3.tuple.Pair; import org.apache.james.backends.postgres.utils.PostgresExecutor; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java index 544154b5bb0..b1a403012da 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java @@ -57,7 +57,8 @@ import javax.inject.Inject; import javax.inject.Singleton; -import javax.mail.Flags; + +import jakarta.mail.Flags; import org.apache.commons.lang3.tuple.Pair; import org.apache.james.backends.postgres.utils.PostgresExecutor; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAOUtils.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAOUtils.java index 65404964a4b..0649ddb686f 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAOUtils.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAOUtils.java @@ -53,7 +53,7 @@ import java.util.Optional; import java.util.function.Function; -import javax.mail.Flags; +import jakarta.mail.Flags; import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.ModSeq; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverride.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverride.java index 87a4d68ddbf..e5354c2887e 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverride.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverride.java @@ -20,7 +20,8 @@ package org.apache.james.mailbox.postgres.search; import javax.inject.Inject; -import javax.mail.Flags; + +import jakarta.mail.Flags; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.mailbox.MailboxSession; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverride.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverride.java index 853abc695d3..ac18e038ed1 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverride.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverride.java @@ -20,7 +20,8 @@ package org.apache.james.mailbox.postgres.search; import javax.inject.Inject; -import javax.mail.Flags; + +import jakarta.mail.Flags; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.mailbox.MailboxSession; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverride.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverride.java index d604e3681cb..18cd8259b93 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverride.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverride.java @@ -20,7 +20,8 @@ package org.apache.james.mailbox.postgres.search; import javax.inject.Inject; -import javax.mail.Flags; + +import jakarta.mail.Flags; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.mailbox.MailboxSession; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverride.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverride.java index d269439d846..ede176dfd70 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverride.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverride.java @@ -23,7 +23,8 @@ import java.util.Optional; import javax.inject.Inject; -import javax.mail.Flags; + +import jakarta.mail.Flags; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.mailbox.MailboxSession; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageBlobReferenceSourceTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageBlobReferenceSourceTest.java index 56de642dd54..0fe245c667c 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageBlobReferenceSourceTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageBlobReferenceSourceTest.java @@ -25,7 +25,7 @@ import java.util.Date; import java.util.UUID; -import javax.mail.Flags; +import jakarta.mail.Flags; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.blob.api.HashBlobId; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java index 87ba69c637d..43601491e0b 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java @@ -24,7 +24,7 @@ import java.time.Instant; import java.util.Date; -import javax.mail.Flags; +import jakarta.mail.Flags; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/AllSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/AllSearchOverrideTest.java index 04fdbfbd3ac..ed9aafdce97 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/AllSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/AllSearchOverrideTest.java @@ -24,7 +24,7 @@ import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.MAILBOX_SESSION; import static org.assertj.core.api.Assertions.assertThat; -import javax.mail.Flags; +import jakarta.mail.Flags; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.blob.api.HashBlobId; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverrideTest.java index 82cb2f17ab9..42471bf5c2a 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverrideTest.java @@ -19,14 +19,14 @@ package org.apache.james.mailbox.postgres.search; -import static javax.mail.Flags.Flag.DELETED; -import static javax.mail.Flags.Flag.SEEN; +import static jakarta.mail.Flags.Flag.DELETED; +import static jakarta.mail.Flags.Flag.SEEN; import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.BLOB_ID; import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.MAILBOX; import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.MAILBOX_SESSION; import static org.assertj.core.api.Assertions.assertThat; -import javax.mail.Flags; +import jakarta.mail.Flags; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.blob.api.HashBlobId; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverrideTest.java index a7dc79eee12..7f7b05307a1 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverrideTest.java @@ -19,14 +19,14 @@ package org.apache.james.mailbox.postgres.search; -import static javax.mail.Flags.Flag.DELETED; -import static javax.mail.Flags.Flag.SEEN; +import static jakarta.mail.Flags.Flag.DELETED; +import static jakarta.mail.Flags.Flag.SEEN; import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.BLOB_ID; import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.MAILBOX; import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.MAILBOX_SESSION; import static org.assertj.core.api.Assertions.assertThat; -import javax.mail.Flags; +import jakarta.mail.Flags; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.blob.api.HashBlobId; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverrideTest.java index 7c8fdab2463..351f79da52e 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverrideTest.java @@ -19,13 +19,13 @@ package org.apache.james.mailbox.postgres.search; -import static javax.mail.Flags.Flag.DELETED; +import static jakarta.mail.Flags.Flag.DELETED; import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.BLOB_ID; import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.MAILBOX; import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.MAILBOX_SESSION; import static org.assertj.core.api.Assertions.assertThat; -import javax.mail.Flags; +import jakarta.mail.Flags; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.blob.api.HashBlobId; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/SearchOverrideFixture.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/SearchOverrideFixture.java index b64043d7b35..41f8e957dfd 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/SearchOverrideFixture.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/SearchOverrideFixture.java @@ -23,7 +23,7 @@ import java.nio.charset.StandardCharsets; import java.util.Date; -import javax.mail.Flags; +import jakarta.mail.Flags; import org.apache.james.core.Username; import org.apache.james.mailbox.MailboxSession; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UidSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UidSearchOverrideTest.java index 45237068a88..10bd7190b90 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UidSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UidSearchOverrideTest.java @@ -24,7 +24,7 @@ import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.MAILBOX_SESSION; import static org.assertj.core.api.Assertions.assertThat; -import javax.mail.Flags; +import jakarta.mail.Flags; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.blob.api.HashBlobId; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverrideTest.java index b6d64116264..7b78e7253c1 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverrideTest.java @@ -19,13 +19,13 @@ package org.apache.james.mailbox.postgres.search; -import static javax.mail.Flags.Flag.SEEN; +import static jakarta.mail.Flags.Flag.SEEN; import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.BLOB_ID; import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.MAILBOX; import static org.apache.james.mailbox.postgres.search.SearchOverrideFixture.MAILBOX_SESSION; import static org.assertj.core.api.Assertions.assertThat; -import javax.mail.Flags; +import jakarta.mail.Flags; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.blob.api.HashBlobId; diff --git a/server/data/data-jmap-cassandra/src/test/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepositoryTest.java b/server/data/data-jmap-cassandra/src/test/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepositoryTest.java index 88e7bde6327..285ad9a0908 100644 --- a/server/data/data-jmap-cassandra/src/test/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepositoryTest.java +++ b/server/data/data-jmap-cassandra/src/test/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepositoryTest.java @@ -76,16 +76,4 @@ public void deleteShouldReturnFalseWhenRowDoesNotExist() { public UpdatableTickingClock clock() { return clock; } - - @Disabled("Delete method always return true (to avoid LWT)") - @Override - public void deleteShouldReturnTrueWhenRowExists() { - UploadRepositoryContract.super.deleteShouldReturnTrueWhenRowExists(); - } - - @Disabled("Delete method always return true (to avoid LWT)") - @Override - public void deleteShouldReturnFalseWhenRowDoesNotExist() { - UploadRepositoryContract.super.deleteShouldReturnFalseWhenRowDoesNotExist(); - } } \ No newline at end of file diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/identity/PostgresCustomIdentityDAO.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/identity/PostgresCustomIdentityDAO.java index b1b7fed98eb..be24e724d23 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/identity/PostgresCustomIdentityDAO.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/identity/PostgresCustomIdentityDAO.java @@ -62,7 +62,7 @@ import reactor.core.publisher.Mono; import reactor.core.scala.publisher.SMono; import scala.Option; -import scala.collection.immutable.Seq; +import scala.collection.immutable.Set; import scala.jdk.javaapi.CollectionConverters; import scala.jdk.javaapi.OptionConverters; import scala.runtime.BoxedUnit; @@ -166,7 +166,7 @@ private Mono upsertReturnMono(Username user, Identity identity) { } @Override - public Publisher delete(Username username, Seq ids) { + public Publisher delete(Username username, Set ids) { if (ids.isEmpty()) { return Mono.empty(); } diff --git a/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/upload/UploadRepositoryContract.scala b/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/upload/UploadRepositoryContract.scala index cce5999873d..f5444089681 100644 --- a/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/upload/UploadRepositoryContract.scala +++ b/server/data/data-jmap/src/test/java/org/apache/james/jmap/api/upload/UploadRepositoryContract.scala @@ -205,6 +205,7 @@ assertThat(SMono.fromPublisher(testee.delete(uploadIdOfAlice, Username.of("Bob"))).block()).isFalse } + @Test def deleteByUploadDateBeforeShouldRemoveExpiredUploads(): Unit = { val uploadId1: UploadId = SMono.fromPublisher(testee.upload(data(), CONTENT_TYPE, USER)).block().uploadId clock.setInstant(clock.instant().plus(8, java.time.temporal.ChronoUnit.DAYS)) @@ -217,18 +218,4 @@ assertThat(SMono.fromPublisher(testee.retrieve(uploadId2, USER)).block()) .isNotNull } - - @Test - def deleteShouldReturnTrueWhenRowExists(): Unit = { - val uploadId: UploadId = SMono.fromPublisher(testee.upload(data(), CONTENT_TYPE, USER)).block().uploadId - - assertThat(SMono.fromPublisher(testee.delete(uploadId, USER)).block()).isTrue - } - - @Test - def deleteShouldReturnFalseWhenRowDoesNotExist(): Unit = { - val uploadIdOfAlice: UploadId = SMono.fromPublisher(testee.upload(data(), CONTENT_TYPE, Username.of("Alice"))).block().uploadId - assertThat(SMono.fromPublisher(testee.delete(uploadIdOfAlice, Username.of("Bob"))).block()).isFalse - } - } diff --git a/server/data/data-postgres/pom.xml b/server/data/data-postgres/pom.xml index d12ba78633d..be376372532 100644 --- a/server/data/data-postgres/pom.xml +++ b/server/data/data-postgres/pom.xml @@ -111,7 +111,7 @@ io.cucumber - cucumber-junit + cucumber-junit-platform-engine test @@ -123,6 +123,11 @@ org.apache.commons commons-configuration2 + + org.junit.platform + junit-platform-suite + test + org.mockito mockito-core diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepository.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepository.java index 1f9da8f4c74..0be66454099 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepository.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepository.java @@ -23,7 +23,8 @@ import java.util.Iterator; import javax.inject.Inject; -import javax.mail.MessagingException; + +import jakarta.mail.MessagingException; import org.apache.james.mailrepository.api.MailKey; import org.apache.james.mailrepository.api.MailRepository; diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryContentDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryContentDAO.java index 2a52d4cb600..91232051c30 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryContentDAO.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryContentDAO.java @@ -49,8 +49,9 @@ import java.util.stream.Stream; import javax.inject.Inject; -import javax.mail.MessagingException; -import javax.mail.internet.MimeMessage; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.Pair; diff --git a/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryBlobReferenceSourceTest.java b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryBlobReferenceSourceTest.java index 93b6fa513af..7d33edb9a54 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryBlobReferenceSourceTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryBlobReferenceSourceTest.java @@ -21,7 +21,7 @@ import static org.assertj.core.api.Assertions.assertThat; -import javax.mail.MessagingException; +import jakarta.mail.MessagingException; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.backends.postgres.PostgresModule; diff --git a/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/PostgresStepdefs.java b/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/PostgresStepdefs.java index dc89ddf929e..f3da4c21bd6 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/PostgresStepdefs.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/PostgresStepdefs.java @@ -31,8 +31,8 @@ import com.github.fge.lambdas.Throwing; -import cucumber.api.java.After; -import cucumber.api.java.Before; +import io.cucumber.java.After; +import io.cucumber.java.Before; public class PostgresStepdefs { static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresModule.aggregateModules(PostgresRecipientRewriteTableModule.MODULE, PostgresUserModule.MODULE)); diff --git a/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/RewriteTablesTest.java b/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/RewriteTablesTest.java index 4d0077187cc..ee1e00e3f56 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/RewriteTablesTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/RewriteTablesTest.java @@ -18,15 +18,16 @@ ****************************************************************/ package org.apache.james.rrt.postgres; -import org.junit.runner.RunWith; +import static io.cucumber.core.options.Constants.GLUE_PROPERTY_NAME; -import cucumber.api.CucumberOptions; -import cucumber.api.junit.Cucumber; +import org.junit.platform.suite.api.ConfigurationParameter; +import org.junit.platform.suite.api.IncludeEngines; +import org.junit.platform.suite.api.SelectClasspathResource; +import org.junit.platform.suite.api.Suite; -@RunWith(Cucumber.class) -@CucumberOptions( - features = { "classpath:cucumber/" }, - glue = { "org.apache.james.rrt.lib", "org.apache.james.rrt.postgres" } - ) +@Suite +@IncludeEngines("cucumber") +@SelectClasspathResource("cucumber") +@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "org.apache.james.rrt.lib,org.apache.james.rrt.postgres") public class RewriteTablesTest { } diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresWebAdminServerBlobGCIntegrationTest.java b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresWebAdminServerBlobGCIntegrationTest.java index 7346805bbe5..94c7de66f5e 100644 --- a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresWebAdminServerBlobGCIntegrationTest.java +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresWebAdminServerBlobGCIntegrationTest.java @@ -28,8 +28,8 @@ import java.time.ZonedDateTime; import java.util.Date; -import javax.mail.Flags; -import javax.mail.util.SharedByteArrayInputStream; +import jakarta.mail.Flags; +import jakarta.mail.util.SharedByteArrayInputStream; import org.apache.james.GuiceJamesServer; import org.apache.james.GuiceModuleTestExtension; From 09d6a9516549d1007637e6429cd6946c02a83475 Mon Sep 17 00:00:00 2001 From: vttran Date: Thu, 21 Mar 2024 08:36:35 +0700 Subject: [PATCH 258/341] JAMES-2586 JMAP Upload - fix precision of uploadDate field --- .../postgres/upload/PostgresUploadDAO.java | 23 ++++++++++++------- .../upload/PostgresUploadRepository.java | 3 +-- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadDAO.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadDAO.java index 1c493702a11..fc24129e108 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadDAO.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadDAO.java @@ -71,14 +71,21 @@ public PostgresUploadDAO(@Named(PostgresExecutor.NON_RLS_INJECT) PostgresExecuto this.blobIdFactory = blobIdFactory; } - public Mono insert(UploadMetaData upload, Username user) { - return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(PostgresUploadTable.TABLE_NAME) - .set(PostgresUploadTable.ID, upload.uploadId().getId()) - .set(PostgresUploadTable.CONTENT_TYPE, upload.contentType().asString()) - .set(PostgresUploadTable.SIZE, upload.sizeAsLong()) - .set(PostgresUploadTable.BLOB_ID, upload.blobId().asString()) - .set(PostgresUploadTable.USER_NAME, user.asString()) - .set(PostgresUploadTable.UPLOAD_DATE, INSTANT_TO_LOCAL_DATE_TIME.apply(upload.uploadDate())))); + public Mono insert(UploadMetaData upload, Username user) { + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.insertInto(PostgresUploadTable.TABLE_NAME) + .set(PostgresUploadTable.ID, upload.uploadId().getId()) + .set(PostgresUploadTable.CONTENT_TYPE, upload.contentType().asString()) + .set(PostgresUploadTable.SIZE, upload.sizeAsLong()) + .set(PostgresUploadTable.BLOB_ID, upload.blobId().asString()) + .set(PostgresUploadTable.USER_NAME, user.asString()) + .set(PostgresUploadTable.UPLOAD_DATE, INSTANT_TO_LOCAL_DATE_TIME.apply(upload.uploadDate())) + .returning(PostgresUploadTable.ID, + PostgresUploadTable.CONTENT_TYPE, + PostgresUploadTable.SIZE, + PostgresUploadTable.BLOB_ID, + PostgresUploadTable.UPLOAD_DATE, + PostgresUploadTable.USER_NAME))) + .map(this::uploadMetaDataFromRow); } public Flux list(Username user) { diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java index 1a21d0181b4..88a69136480 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java @@ -72,8 +72,7 @@ public Mono upload(InputStream data, ContentType contentType, Us return Mono.fromCallable(() -> new CountingInputStream(data)) .flatMap(countingInputStream -> Mono.from(blobStore.save(UPLOAD_BUCKET, countingInputStream, LOW_COST)) .map(blobId -> UploadMetaData.from(uploadId, contentType, countingInputStream.getCount(), blobId, clock.instant())) - .flatMap(uploadMetaData -> uploadDAO.insert(uploadMetaData, user) - .thenReturn(uploadMetaData))); + .flatMap(uploadMetaData -> uploadDAO.insert(uploadMetaData, user))); } @Override From bc67127ae0fcccf998cf5bb01689da565e70b409 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Tue, 19 Mar 2024 16:14:09 +0700 Subject: [PATCH 259/341] JAMES-2586 Refactor the handle way duplicate value on constraint index to avoid noise log (Mailbox and User table) --- .../postgres/mail/PostgresMailboxModule.java | 5 ++++- .../postgres/mail/dao/PostgresMailboxDAO.java | 14 ++++++++------ .../james/user/postgres/PostgresUserModule.java | 5 ++++- .../james/user/postgres/PostgresUsersDAO.java | 12 +++++++----- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java index 1b56199ad78..68a02534134 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java @@ -27,6 +27,7 @@ import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.backends.postgres.PostgresTable; import org.jooq.Field; +import org.jooq.Name; import org.jooq.Record; import org.jooq.Table; import org.jooq.impl.DSL; @@ -47,6 +48,8 @@ interface PostgresMailboxTable { Field MAILBOX_HIGHEST_MODSEQ = DSL.field("mailbox_highest_modseq", BIGINT); Field MAILBOX_ACL = DSL.field("mailbox_acl", org.jooq.impl.DefaultDataType.getDefaultDataType("hstore").asConvertedDataType(new HstoreBinding())); + Name MAILBOX_NAME_USER_NAME_NAMESPACE_UNIQUE_CONSTRAINT = DSL.name("mailbox_mailbox_name_user_name_mailbox_namespace_key"); + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) .column(MAILBOX_ID, SQLDataType.UUID) @@ -58,7 +61,7 @@ interface PostgresMailboxTable { .column(MAILBOX_HIGHEST_MODSEQ) .column(MAILBOX_ACL) .constraint(DSL.primaryKey(MAILBOX_ID)) - .constraint(DSL.unique(MAILBOX_NAME, USER_NAME, MAILBOX_NAMESPACE)))) + .constraint(DSL.constraint(MAILBOX_NAME_USER_NAME_NAMESPACE_UNIQUE_CONSTRAINT).unique(MAILBOX_NAME, USER_NAME, MAILBOX_NAMESPACE)))) .supportsRowLevelSecurity() .build(); PostgresIndex MAILBOX_USERNAME_NAMESPACE_INDEX = PostgresIndex.name("mailbox_username_namespace_index") diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java index e4f1a6f71d6..89fbc929c31 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java @@ -19,13 +19,13 @@ package org.apache.james.mailbox.postgres.mail.dao; -import static org.apache.james.backends.postgres.utils.PostgresUtils.UNIQUE_CONSTRAINT_VIOLATION_PREDICATE; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_ACL; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_HIGHEST_MODSEQ; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_ID; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_LAST_UID; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_NAME; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_NAMESPACE; +import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_NAME_USER_NAME_NAMESPACE_UNIQUE_CONSTRAINT; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.MAILBOX_UID_VALIDITY; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.TABLE_NAME; import static org.apache.james.mailbox.postgres.mail.PostgresMailboxModule.PostgresMailboxTable.USER_NAME; @@ -117,12 +117,14 @@ public PostgresMailboxDAO(PostgresExecutor postgresExecutor) { public Mono create(MailboxPath mailboxPath, UidValidity uidValidity) { PostgresMailboxId mailboxId = PostgresMailboxId.generate(); - return postgresExecutor.executeVoid(dslContext -> + return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME, MAILBOX_ID, MAILBOX_NAME, USER_NAME, MAILBOX_NAMESPACE, MAILBOX_UID_VALIDITY) - .values(mailboxId.asUuid(), mailboxPath.getName(), mailboxPath.getUser().asString(), mailboxPath.getNamespace(), uidValidity.asLong()))) - .thenReturn(new Mailbox(mailboxPath, uidValidity, mailboxId)) - .onErrorMap(UNIQUE_CONSTRAINT_VIOLATION_PREDICATE, - e -> new MailboxExistsException(mailboxPath.getName())); + .values(mailboxId.asUuid(), mailboxPath.getName(), mailboxPath.getUser().asString(), mailboxPath.getNamespace(), uidValidity.asLong()) + .onConflictOnConstraint(MAILBOX_NAME_USER_NAME_NAMESPACE_UNIQUE_CONSTRAINT) + .doNothing() + .returning(MAILBOX_ID))) + .map(record -> new Mailbox(mailboxPath, uidValidity, PostgresMailboxId.of(record.get(MAILBOX_ID)))) + .switchIfEmpty(Mono.error(new MailboxExistsException(mailboxPath.getName()))); } public Mono rename(Mailbox mailbox) { diff --git a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUserModule.java b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUserModule.java index 8840ca22fe9..f0b67a25fd9 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUserModule.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUserModule.java @@ -22,6 +22,7 @@ import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.backends.postgres.PostgresTable; import org.jooq.Field; +import org.jooq.Name; import org.jooq.Record; import org.jooq.Table; import org.jooq.impl.DSL; @@ -37,6 +38,8 @@ interface PostgresUserTable { Field AUTHORIZED_USERS = DSL.field("authorized_users", SQLDataType.VARCHAR.getArrayDataType()); Field DELEGATED_USERS = DSL.field("delegated_users", SQLDataType.VARCHAR.getArrayDataType()); + Name USERNAME_PRIMARY_KEY = DSL.name("users_username_pk"); + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) .column(USERNAME) @@ -44,7 +47,7 @@ interface PostgresUserTable { .column(ALGORITHM) .column(AUTHORIZED_USERS) .column(DELEGATED_USERS) - .constraint(DSL.primaryKey(USERNAME)))) + .constraint(DSL.constraint(USERNAME_PRIMARY_KEY).primaryKey(USERNAME)))) .disableRowLevelSecurity() .build(); } diff --git a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java index 0b58bf0b9be..fcd0ac80b16 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java @@ -20,7 +20,6 @@ package org.apache.james.user.postgres; import static org.apache.james.backends.postgres.utils.PostgresExecutor.DEFAULT_INJECT; -import static org.apache.james.backends.postgres.utils.PostgresUtils.UNIQUE_CONSTRAINT_VIOLATION_PREDICATE; import static org.apache.james.user.postgres.PostgresUserModule.PostgresUserTable.ALGORITHM; import static org.apache.james.user.postgres.PostgresUserModule.PostgresUserTable.AUTHORIZED_USERS; import static org.apache.james.user.postgres.PostgresUserModule.PostgresUserTable.DELEGATED_USERS; @@ -28,6 +27,7 @@ import static org.apache.james.user.postgres.PostgresUserModule.PostgresUserTable.TABLE; import static org.apache.james.user.postgres.PostgresUserModule.PostgresUserTable.TABLE_NAME; import static org.apache.james.user.postgres.PostgresUserModule.PostgresUserTable.USERNAME; +import static org.apache.james.user.postgres.PostgresUserModule.PostgresUserTable.USERNAME_PRIMARY_KEY; import static org.jooq.impl.DSL.count; import java.util.Iterator; @@ -149,10 +149,12 @@ public void addUser(Username username, String password) { DefaultUser user = new DefaultUser(username, algorithm, algorithm); user.setPassword(password); - postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME, USERNAME, HASHED_PASSWORD, ALGORITHM) - .values(user.getUserName().asString(), user.getHashedPassword(), user.getHashAlgorithm().asString()))) - .onErrorMap(UNIQUE_CONSTRAINT_VIOLATION_PREDICATE, - e -> new AlreadyExistInUsersRepositoryException("User with username " + username + " already exist!")) + postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME, USERNAME, HASHED_PASSWORD, ALGORITHM) + .values(user.getUserName().asString(), user.getHashedPassword(), user.getHashAlgorithm().asString()) + .onConflictOnConstraint(USERNAME_PRIMARY_KEY) + .doNothing() + .returning(USERNAME))) + .switchIfEmpty(Mono.error(new AlreadyExistInUsersRepositoryException("User with username " + username + " already exist!"))) .block(); } From 4441e20c38c8f390dcf6764a5642b2365ceeb8b2 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Tue, 19 Mar 2024 16:14:34 +0700 Subject: [PATCH 260/341] JAMES-2586 [REFACTORING] - PostgresTableManager - fix incorrect log - Do not print log "Table {} created", "Index {} created" when it already exists and James does nothing --- .../backends/postgres/PostgresIndex.java | 3 +- .../backends/postgres/PostgresTable.java | 3 +- .../postgres/PostgresTableManager.java | 59 +++++++++++++++---- .../backends/postgres/PostgresExtension.java | 18 ++---- .../postgres/PostgresTableManagerTest.java | 10 ++-- .../postgres/PostgresVacationModule.java | 4 +- 6 files changed, 64 insertions(+), 33 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresIndex.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresIndex.java index db41be4e356..c1a41f2947e 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresIndex.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresIndex.java @@ -40,8 +40,9 @@ public interface CreateIndexFunction { public static RequireCreateIndexStep name(String indexName) { Preconditions.checkNotNull(indexName); + String strategyIndexName = indexName.toLowerCase(); - return createIndexFunction -> new PostgresIndex(indexName, dsl -> createIndexFunction.createIndex(dsl, indexName)); + return createIndexFunction -> new PostgresIndex(strategyIndexName, dsl -> createIndexFunction.createIndex(dsl, strategyIndexName)); } private final String name; diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java index 517ff411bba..db37fcdf9d8 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java @@ -80,8 +80,9 @@ public PostgresTable build() { public static RequireCreateTableStep name(String tableName) { Preconditions.checkNotNull(tableName); + String strategyName = tableName.toLowerCase(); - return createTableFunction -> supportsRowLevelSecurity -> new FinalStage(tableName, supportsRowLevelSecurity, dsl -> createTableFunction.createTable(dsl, tableName)); + return createTableFunction -> supportsRowLevelSecurity -> new FinalStage(strategyName, supportsRowLevelSecurity, dsl -> createTableFunction.createTable(dsl, strategyName)); } private final String name; diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java index 84e2bc7fe60..313bc8bc72f 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java @@ -19,11 +19,15 @@ package org.apache.james.backends.postgres; +import java.util.List; + import javax.inject.Inject; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.lifecycle.api.Startable; +import org.jooq.DSLContext; import org.jooq.exception.DataAccessException; +import org.jooq.impl.DSL; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -73,12 +77,28 @@ public Mono initializePostgresExtension() { public Mono initializeTables() { return postgresExecutor.dslContext() - .flatMap(dsl -> Flux.fromIterable(module.tables()) - .flatMap(table -> Mono.from(table.getCreateTableStepFunction().apply(dsl)) - .then(alterTableIfNeeded(table)) - .doOnSuccess(any -> LOGGER.info("Table {} created", table.getName())) - .onErrorResume(exception -> handleTableCreationException(table, exception))) - .then()); + .flatMapMany(dsl -> listExistTables() + .flatMapMany(existTables -> Flux.fromIterable(module.tables()) + .filter(table -> !existTables.contains(table.getName())) + .flatMap(table -> createAndAlterTable(table, dsl)))) + .then(); + } + + private Mono createAndAlterTable(PostgresTable table, DSLContext dsl) { + return Mono.from(table.getCreateTableStepFunction().apply(dsl)) + .then(alterTableIfNeeded(table)) + .doOnSuccess(any -> LOGGER.info("Table {} created", table.getName())) + .onErrorResume(exception -> handleTableCreationException(table, exception)); + } + + public Mono> listExistTables() { + return postgresExecutor.dslContext() + .flatMapMany(d -> Flux.from(d.select(DSL.field("tablename")) + .from("pg_tables") + .where(DSL.field("schemaname") + .eq(DSL.currentSchema())))) + .map(r -> r.get(0, String.class)) + .collectList(); } private Mono handleTableCreationException(PostgresTable table, Throwable e) { @@ -148,11 +168,28 @@ public Mono truncate() { public Mono initializeTableIndexes() { return postgresExecutor.dslContext() - .flatMap(dsl -> Flux.fromIterable(module.tableIndexes()) - .concatMap(index -> Mono.from(index.getCreateIndexStepFunction().apply(dsl)) - .doOnSuccess(any -> LOGGER.info("Index {} created", index.getName())) - .onErrorResume(e -> handleIndexCreationException(index, e))) - .then()); + .flatMapMany(dsl -> listExistIndexes() + .flatMapMany(existIndexes -> Flux.fromIterable(module.tableIndexes()) + .filter(index -> !existIndexes.contains(index.getName())) + .flatMap(index -> createTableIndex(index, dsl)))) + .then(); + } + + public Mono> listExistIndexes() { + return postgresExecutor.dslContext() + .flatMapMany(dsl -> Flux.from(dsl.select(DSL.field("indexname")) + .from("pg_indexes") + .where(DSL.field("schemaname") + .eq(DSL.currentSchema())))) + .map(r -> r.get(0, String.class)) + .collectList(); + } + + private Mono createTableIndex(PostgresIndex index, DSLContext dsl) { + return Mono.from(index.getCreateIndexStepFunction().apply(dsl)) + .doOnSuccess(any -> LOGGER.info("Index {} created", index.getName())) + .onErrorResume(e -> handleIndexCreationException(index, e)) + .then(); } private Mono handleIndexCreationException(PostgresIndex index, Throwable e) { diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index e21f846a1b0..1f6f0a200cd 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -23,9 +23,7 @@ import static org.apache.james.backends.postgres.PostgresFixture.Database.ROW_LEVEL_SECURITY_DATABASE; import java.io.IOException; -import java.net.URISyntaxException; import java.util.List; -import java.util.Optional; import java.util.stream.Collectors; import org.apache.james.GuiceModuleTestExtension; @@ -69,6 +67,7 @@ public static PostgresExtension empty() { private PostgresExecutor nonRLSPostgresExecutor; private PostgresqlConnectionFactory connectionFactory; private PostgresExecutor.Factory executorFactory; + private PostgresTableManager postgresTableManager; public void pause() { PG_CONTAINER.getDockerClient().pauseContainerCmd(PG_CONTAINER.getContainerId()) @@ -159,6 +158,8 @@ private void initPostgresSession() { } else { nonRLSPostgresExecutor = postgresExecutor; } + + this.postgresTableManager = new PostgresTableManager(postgresExecutor, postgresModule, postgresConfiguration); } @Override @@ -225,13 +226,13 @@ public PostgresExecutor.Factory getExecutorFactory() { } private void initTablesAndIndexes() { - PostgresTableManager postgresTableManager = new PostgresTableManager(postgresExecutor, postgresModule, postgresConfiguration.rowLevelSecurityEnabled()); postgresTableManager.initializeTables().block(); postgresTableManager.initializeTableIndexes().block(); } private void resetSchema() { - dropTables(listAllTables()); + List tables = postgresTableManager.listExistTables().block(); + dropTables(tables); } private void dropTables(List tables) { @@ -246,15 +247,6 @@ private void dropTables(List tables) { .block(); } - private List listAllTables() { - return postgresExecutor.connection() - .flatMapMany(connection -> connection.createStatement(String.format("SELECT tablename FROM pg_tables WHERE schemaname = '%s'", selectedDatabase.schema())) - .execute()) - .flatMap(result -> result.map((row, rowMetadata) -> row.get(0, String.class))) - .collectList() - .block(); - } - private void dropAllConnections() { postgresExecutor.connection() .flatMapMany(connection -> connection.createStatement(String.format("SELECT pg_terminate_backend(pid) " + diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java index 0068fd1566d..e1414906dc4 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java @@ -45,7 +45,7 @@ class PostgresTableManagerTest { @Test void initializeTableShouldSuccessWhenModuleHasSingleTable() { - String tableName = "tableName1"; + String tableName = "tablename1"; PostgresTable table = PostgresTable.name(tableName) .createTableStep((dsl, tbn) -> dsl.createTable(tbn) @@ -71,14 +71,14 @@ void initializeTableShouldSuccessWhenModuleHasSingleTable() { @Test void initializeTableShouldSuccessWhenModuleHasMultiTables() { - String tableName1 = "tableName1"; + String tableName1 = "tablename1"; PostgresTable table1 = PostgresTable.name(tableName1) .createTableStep((dsl, tbn) -> dsl.createTable(tbn) .column("columA", SQLDataType.UUID.notNull())).disableRowLevelSecurity() .build(); - String tableName2 = "tableName2"; + String tableName2 = "tablename2"; PostgresTable table2 = PostgresTable.name(tableName2) .createTableStep((dsl, tbn) -> dsl.createTable(tbn) .column("columB", SQLDataType.INTEGER)).disableRowLevelSecurity() @@ -99,7 +99,7 @@ void initializeTableShouldSuccessWhenModuleHasMultiTables() { @Test void initializeTableShouldNotThrowWhenTableExists() { - String tableName1 = "tableName1"; + String tableName1 = "tablename1"; PostgresTable table1 = PostgresTable.name(tableName1) .createTableStep((dsl, tbn) -> dsl.createTable(tbn) @@ -117,7 +117,7 @@ void initializeTableShouldNotThrowWhenTableExists() { @Test void initializeTableShouldNotChangeTableStructureOfExistTable() { - String tableName1 = "tableName1"; + String tableName1 = "tablename1"; PostgresTable table1 = PostgresTable.name(tableName1) .createTableStep((dsl, tbn) -> dsl.createTable(tbn) .column("columA", SQLDataType.UUID.notNull())).disableRowLevelSecurity() diff --git a/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationModule.java b/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationModule.java index f3066518228..14fb05df0a4 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationModule.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationModule.java @@ -74,11 +74,11 @@ interface PostgresVacationNotificationRegistryTable { .supportsRowLevelSecurity() .build(); - PostgresIndex ACCOUNT_ID_INDEX = PostgresIndex.name("vacation_notification_registry_accountId_index") + PostgresIndex ACCOUNT_ID_INDEX = PostgresIndex.name("vacation_notification_registry_accountid_index") .createIndexStep((dsl, indexName) -> dsl.createIndexIfNotExists(indexName) .on(TABLE_NAME, ACCOUNT_ID)); - PostgresIndex FULL_COMPOSITE_INDEX = PostgresIndex.name("vacation_notification_registry_accountId_recipientId_expiryDate_index") + PostgresIndex FULL_COMPOSITE_INDEX = PostgresIndex.name("vnr_accountid_recipientid_expirydate_index") .createIndexStep((dsl, indexName) -> dsl.createIndexIfNotExists(indexName) .on(TABLE_NAME, ACCOUNT_ID, RECIPIENT_ID, EXPIRY_DATE)); } From a2da8330ba85883b994b44a9a23490929c7f056c Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 18 Mar 2024 18:05:33 +0700 Subject: [PATCH 261/341] JAMES-2586 Avoid sorting PG messages --- .../postgres/mail/AttachmentLoader.java | 18 ++++++++---------- .../postgres/mail/PostgresMessageIdMapper.java | 9 ++++----- .../postgres/mail/PostgresMessageMapper.java | 8 +++----- 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/AttachmentLoader.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/AttachmentLoader.java index 874d463c2c2..4927867ce4f 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/AttachmentLoader.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/AttachmentLoader.java @@ -31,7 +31,6 @@ import org.apache.james.mailbox.postgres.mail.dto.AttachmentsDTO; import org.apache.james.mailbox.store.mail.MessageMapper; import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; -import org.apache.james.util.ReactorUtils; import org.jooq.Record; import com.google.common.collect.ImmutableMap; @@ -49,19 +48,18 @@ public AttachmentLoader(PostgresAttachmentMapper attachmentMapper) { public Flux> addAttachmentToMessage(Flux> findMessagePublisher, MessageMapper.FetchType fetchType) { - return findMessagePublisher.flatMap(pair -> { - if (fetchType == MessageMapper.FetchType.FULL || fetchType == MessageMapper.FetchType.ATTACHMENTS_METADATA) { - return Mono.fromCallable(() -> pair.getRight().get(ATTACHMENT_METADATA)) - .map(e -> toMap((AttachmentsDTO) e)) + if (fetchType != MessageMapper.FetchType.FULL && fetchType != MessageMapper.FetchType.ATTACHMENTS_METADATA) { + return findMessagePublisher; + } + + return findMessagePublisher.collectList() // convert to list to avoid hanging the database connection with Jooq + .flatMapMany(list -> Flux.fromIterable(list) + .flatMapSequential(pair -> Mono.fromCallable(() -> toMap(pair.getRight().get(ATTACHMENT_METADATA))) .flatMap(this::getAttachments) .map(messageAttachmentMetadata -> { pair.getLeft().addAttachments(messageAttachmentMetadata); return pair; - }).switchIfEmpty(Mono.just(pair)); - } else { - return Mono.just(pair); - } - }, ReactorUtils.DEFAULT_CONCURRENCY); + }).switchIfEmpty(Mono.just(pair)))); } private Map toMap(AttachmentsDTO attachmentRepresentations) { diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapper.java index e9df32ae4a8..01b7304eda4 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapper.java @@ -28,7 +28,6 @@ import java.io.InputStream; import java.time.Clock; import java.util.Collection; -import java.util.Comparator; import java.util.Date; import java.util.List; import java.util.function.Function; @@ -137,17 +136,17 @@ public Publisher findMetadata(MessageId messageId @Override public Flux findReactive(Collection messageIds, MessageMapper.FetchType fetchType) { - Flux> fetchMessageWithoutFullContentPublisher = mailboxMessageDAO.findMessagesByMessageIds(messageIds.stream().map(PostgresMessageId.class::cast).collect(ImmutableList.toImmutableList()), fetchType); - Flux> fetchMessagePublisher = attachmentLoader.addAttachmentToMessage(fetchMessageWithoutFullContentPublisher, fetchType); + Flux> fetchMessagePublisher = + mailboxMessageDAO.findMessagesByMessageIds(messageIds.stream().map(PostgresMessageId.class::cast).collect(ImmutableList.toImmutableList()), fetchType) + .transform(pairFlux -> attachmentLoader.addAttachmentToMessage(pairFlux, fetchType)); if (fetchType == MessageMapper.FetchType.FULL) { return fetchMessagePublisher - .flatMap(messageBuilderAndRecord -> { + .flatMapSequential(messageBuilderAndRecord -> { SimpleMailboxMessage.Builder messageBuilder = messageBuilderAndRecord.getLeft(); return retrieveFullContent(messageBuilderAndRecord.getRight()) .map(headerAndBodyContent -> messageBuilder.content(headerAndBodyContent).build()); }, ReactorUtils.DEFAULT_CONCURRENCY) - .sort(Comparator.comparing(MailboxMessage::getUid)) .map(message -> message); } else { return fetchMessagePublisher diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java index 324c244a38f..ef9bb0afe96 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java @@ -27,7 +27,6 @@ import java.io.IOException; import java.io.InputStream; import java.time.Clock; -import java.util.Comparator; import java.util.Date; import java.util.Iterator; import java.util.List; @@ -136,17 +135,16 @@ public Flux listMessagesMetadata(Mailbox mailbox, @Override public Flux findInMailboxReactive(Mailbox mailbox, MessageRange messageRange, FetchType fetchType, int limitAsInt) { - Flux> fetchMessageWithoutFullContentPublisher = fetchMessageWithoutFullContent(mailbox, messageRange, fetchType, limitAsInt); - Flux> fetchMessagePublisher = attachmentLoader.addAttachmentToMessage(fetchMessageWithoutFullContentPublisher, fetchType); + Flux> fetchMessagePublisher = fetchMessageWithoutFullContent(mailbox, messageRange, fetchType, limitAsInt) + .transform(pairFlux -> attachmentLoader.addAttachmentToMessage(pairFlux, fetchType)); if (fetchType == FetchType.FULL) { return fetchMessagePublisher - .flatMap(messageBuilderAndRecord -> { + .flatMapSequential(messageBuilderAndRecord -> { SimpleMailboxMessage.Builder messageBuilder = messageBuilderAndRecord.getLeft(); return retrieveFullContent(messageBuilderAndRecord.getRight()) .map(headerAndBodyContent -> messageBuilder.content(headerAndBodyContent).build()); }, ReactorUtils.DEFAULT_CONCURRENCY) - .sort(Comparator.comparing(MailboxMessage::getUid)) .map(message -> message); } else { return fetchMessagePublisher From c5289318a0f827eaa6676954c1e20885ea0621dc Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 18 Mar 2024 20:48:06 +0700 Subject: [PATCH 262/341] JAMES-2586 [REFACTORING] - Extract dedicated class for retrieving Postgres Message in order to remove duplicated code: - Introduce PostgresMessageRetriever (= AttachmentLoader + Retrieve byte message content) --- .../postgres/mail/AttachmentLoader.java | 84 ----------- .../mail/PostgresMessageIdMapper.java | 38 +---- .../postgres/mail/PostgresMessageMapper.java | 39 +---- .../mail/PostgresMessageRetriever.java | 142 ++++++++++++++++++ 4 files changed, 151 insertions(+), 152 deletions(-) delete mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/AttachmentLoader.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageRetriever.java diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/AttachmentLoader.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/AttachmentLoader.java deleted file mode 100644 index 4927867ce4f..00000000000 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/AttachmentLoader.java +++ /dev/null @@ -1,84 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.postgres.mail; - -import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.ATTACHMENT_METADATA; - -import java.util.List; -import java.util.Map; - -import org.apache.commons.lang3.tuple.Pair; -import org.apache.james.mailbox.model.AttachmentId; -import org.apache.james.mailbox.model.AttachmentMetadata; -import org.apache.james.mailbox.model.MessageAttachmentMetadata; -import org.apache.james.mailbox.postgres.mail.dto.AttachmentsDTO; -import org.apache.james.mailbox.store.mail.MessageMapper; -import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; -import org.jooq.Record; - -import com.google.common.collect.ImmutableMap; - -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - -public class AttachmentLoader { - - private final PostgresAttachmentMapper attachmentMapper; - - public AttachmentLoader(PostgresAttachmentMapper attachmentMapper) { - this.attachmentMapper = attachmentMapper; - } - - - public Flux> addAttachmentToMessage(Flux> findMessagePublisher, MessageMapper.FetchType fetchType) { - if (fetchType != MessageMapper.FetchType.FULL && fetchType != MessageMapper.FetchType.ATTACHMENTS_METADATA) { - return findMessagePublisher; - } - - return findMessagePublisher.collectList() // convert to list to avoid hanging the database connection with Jooq - .flatMapMany(list -> Flux.fromIterable(list) - .flatMapSequential(pair -> Mono.fromCallable(() -> toMap(pair.getRight().get(ATTACHMENT_METADATA))) - .flatMap(this::getAttachments) - .map(messageAttachmentMetadata -> { - pair.getLeft().addAttachments(messageAttachmentMetadata); - return pair; - }).switchIfEmpty(Mono.just(pair)))); - } - - private Map toMap(AttachmentsDTO attachmentRepresentations) { - return attachmentRepresentations.stream().collect(ImmutableMap.toImmutableMap(MessageRepresentation.AttachmentRepresentation::getAttachmentId, obj -> obj)); - } - - private Mono> getAttachments(Map mapAttachmentIdToAttachmentRepresentation) { - return Mono.fromCallable(mapAttachmentIdToAttachmentRepresentation::keySet) - .flatMapMany(attachmentMapper::getAttachmentsReactive) - .map(attachmentMetadata -> constructMessageAttachment(attachmentMetadata, mapAttachmentIdToAttachmentRepresentation.get(attachmentMetadata.getAttachmentId()))) - .collectList(); - } - - private MessageAttachmentMetadata constructMessageAttachment(AttachmentMetadata attachment, MessageRepresentation.AttachmentRepresentation messageAttachmentRepresentation) { - return MessageAttachmentMetadata.builder() - .attachment(attachment) - .name(messageAttachmentRepresentation.getName().orElse(null)) - .cid(messageAttachmentRepresentation.getCid()) - .isInline(messageAttachmentRepresentation.isInline()) - .build(); - } -} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapper.java index 01b7304eda4..961b51fb53b 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageIdMapper.java @@ -20,9 +20,6 @@ package org.apache.james.mailbox.postgres.mail; import static org.apache.james.blob.api.BlobStore.StoragePolicy.LOW_COST; -import static org.apache.james.blob.api.BlobStore.StoragePolicy.SIZE_BASED; -import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.BODY_BLOB_ID; -import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.HEADER_CONTENT; import java.io.IOException; import java.io.InputStream; @@ -44,8 +41,6 @@ import org.apache.james.mailbox.exception.MailboxException; import org.apache.james.mailbox.exception.MailboxNotFoundException; import org.apache.james.mailbox.model.ComposedMessageIdWithMetaData; -import org.apache.james.mailbox.model.Content; -import org.apache.james.mailbox.model.HeaderAndBodyByteContent; import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.MessageId; @@ -100,9 +95,8 @@ public long size() { private final PostgresMailboxMessageDAO mailboxMessageDAO; private final PostgresModSeqProvider modSeqProvider; private final BlobStore blobStore; - private final BlobId.Factory blobIdFactory; private final Clock clock; - private final AttachmentLoader attachmentLoader; + private final PostgresMessageRetriever messageRetriever; public PostgresMessageIdMapper(PostgresMailboxDAO mailboxDAO, PostgresMessageDAO messageDAO, @@ -117,9 +111,8 @@ public PostgresMessageIdMapper(PostgresMailboxDAO mailboxDAO, this.mailboxMessageDAO = mailboxMessageDAO; this.modSeqProvider = modSeqProvider; this.blobStore = blobStore; - this.blobIdFactory = blobIdFactory; this.clock = clock; - this.attachmentLoader = new AttachmentLoader(attachmentMapper);; + this.messageRetriever = new PostgresMessageRetriever(blobStore, blobIdFactory, attachmentMapper); } @Override @@ -136,23 +129,8 @@ public Publisher findMetadata(MessageId messageId @Override public Flux findReactive(Collection messageIds, MessageMapper.FetchType fetchType) { - Flux> fetchMessagePublisher = - mailboxMessageDAO.findMessagesByMessageIds(messageIds.stream().map(PostgresMessageId.class::cast).collect(ImmutableList.toImmutableList()), fetchType) - .transform(pairFlux -> attachmentLoader.addAttachmentToMessage(pairFlux, fetchType)); - - if (fetchType == MessageMapper.FetchType.FULL) { - return fetchMessagePublisher - .flatMapSequential(messageBuilderAndRecord -> { - SimpleMailboxMessage.Builder messageBuilder = messageBuilderAndRecord.getLeft(); - return retrieveFullContent(messageBuilderAndRecord.getRight()) - .map(headerAndBodyContent -> messageBuilder.content(headerAndBodyContent).build()); - }, ReactorUtils.DEFAULT_CONCURRENCY) - .map(message -> message); - } else { - return fetchMessagePublisher - .map(messageBuilderAndBlobId -> messageBuilderAndBlobId.getLeft() - .build()); - } + Flux> fetchMessagePublisher = mailboxMessageDAO.findMessagesByMessageIds(messageIds.stream().map(PostgresMessageId.class::cast).collect(ImmutableList.toImmutableList()), fetchType); + return messageRetriever.get(fetchType, fetchMessagePublisher); } @Override @@ -272,14 +250,6 @@ private boolean identicalFlags(ComposedMessageIdWithMetaData oldComposedId, Flag return oldComposedId.getFlags().equals(newFlags); } - private Mono retrieveFullContent(Record messageRecord) { - byte[] headerBytes = messageRecord.get(HEADER_CONTENT); - return Mono.from(blobStore.readBytes(blobStore.getDefaultBucketName(), - blobIdFactory.from(messageRecord.get(BODY_BLOB_ID)), - SIZE_BASED)) - .map(bodyBytes -> new HeaderAndBodyByteContent(headerBytes, bodyBytes)); - } - private Mono saveBodyContent(MailboxMessage message) { return Mono.fromCallable(() -> MESSAGE_BODY_CONTENT_LOADER.apply(message)) .flatMap(bodyByteSource -> Mono.from(blobStore.save(blobStore.getDefaultBucketName(), bodyByteSource, LOW_COST))); diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java index ef9bb0afe96..ff00ee4e2f4 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java @@ -20,9 +20,6 @@ package org.apache.james.mailbox.postgres.mail; import static org.apache.james.blob.api.BlobStore.StoragePolicy.LOW_COST; -import static org.apache.james.blob.api.BlobStore.StoragePolicy.SIZE_BASED; -import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.BODY_BLOB_ID; -import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.HEADER_CONTENT; import java.io.IOException; import java.io.InputStream; @@ -48,8 +45,6 @@ import org.apache.james.mailbox.exception.MailboxException; import org.apache.james.mailbox.model.ComposedMessageId; import org.apache.james.mailbox.model.ComposedMessageIdWithMetaData; -import org.apache.james.mailbox.model.Content; -import org.apache.james.mailbox.model.HeaderAndBodyByteContent; import org.apache.james.mailbox.model.Mailbox; import org.apache.james.mailbox.model.MailboxCounters; import org.apache.james.mailbox.model.MessageMetaData; @@ -65,7 +60,6 @@ import org.apache.james.mailbox.store.mail.MessageMapper; import org.apache.james.mailbox.store.mail.model.MailboxMessage; import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; -import org.apache.james.util.ReactorUtils; import org.apache.james.util.streams.Limit; import org.jooq.Record; @@ -100,8 +94,7 @@ public long size() { private final PostgresUidProvider uidProvider; private final BlobStore blobStore; private final Clock clock; - private final BlobId.Factory blobIdFactory; - private final AttachmentLoader attachmentLoader; + private final PostgresMessageRetriever messageRetriever; public PostgresMessageMapper(PostgresExecutor postgresExecutor, PostgresModSeqProvider modSeqProvider, @@ -116,8 +109,8 @@ public PostgresMessageMapper(PostgresExecutor postgresExecutor, this.uidProvider = uidProvider; this.blobStore = blobStore; this.clock = clock; - this.blobIdFactory = blobIdFactory; - this.attachmentLoader = new AttachmentLoader(new PostgresAttachmentMapper(new PostgresAttachmentDAO(postgresExecutor, blobIdFactory), blobStore)); + PostgresAttachmentMapper attachmentMapper = new PostgresAttachmentMapper(new PostgresAttachmentDAO(postgresExecutor, blobIdFactory), blobStore); + this.messageRetriever = new PostgresMessageRetriever(blobStore, blobIdFactory, attachmentMapper); } @@ -135,22 +128,8 @@ public Flux listMessagesMetadata(Mailbox mailbox, @Override public Flux findInMailboxReactive(Mailbox mailbox, MessageRange messageRange, FetchType fetchType, int limitAsInt) { - Flux> fetchMessagePublisher = fetchMessageWithoutFullContent(mailbox, messageRange, fetchType, limitAsInt) - .transform(pairFlux -> attachmentLoader.addAttachmentToMessage(pairFlux, fetchType)); - - if (fetchType == FetchType.FULL) { - return fetchMessagePublisher - .flatMapSequential(messageBuilderAndRecord -> { - SimpleMailboxMessage.Builder messageBuilder = messageBuilderAndRecord.getLeft(); - return retrieveFullContent(messageBuilderAndRecord.getRight()) - .map(headerAndBodyContent -> messageBuilder.content(headerAndBodyContent).build()); - }, ReactorUtils.DEFAULT_CONCURRENCY) - .map(message -> message); - } else { - return fetchMessagePublisher - .map(messageBuilderAndBlobId -> messageBuilderAndBlobId.getLeft() - .build()); - } + Flux> fetchMessagePublisher = fetchMessageWithoutFullContent(mailbox, messageRange, fetchType, limitAsInt); + return messageRetriever.get(fetchType, fetchMessagePublisher); } private Flux> fetchMessageWithoutFullContent(Mailbox mailbox, MessageRange messageRange, FetchType fetchType, int limitAsInt) { @@ -173,14 +152,6 @@ private Flux> fetchMessageWithoutFull }); } - private Mono retrieveFullContent(Record messageRecord) { - byte[] headerBytes = messageRecord.get(HEADER_CONTENT); - return Mono.from(blobStore.readBytes(blobStore.getDefaultBucketName(), - blobIdFactory.from(messageRecord.get(BODY_BLOB_ID)), - SIZE_BASED)) - .map(bodyBytes -> new HeaderAndBodyByteContent(headerBytes, bodyBytes)); - } - @Override public List retrieveMessagesMarkedForDeletion(Mailbox mailbox, MessageRange messageRange) { return retrieveMessagesMarkedForDeletionReactive(mailbox, messageRange) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageRetriever.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageRetriever.java new file mode 100644 index 00000000000..b415b780f28 --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageRetriever.java @@ -0,0 +1,142 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import static org.apache.james.blob.api.BlobStore.StoragePolicy.SIZE_BASED; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.ATTACHMENT_METADATA; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.BODY_BLOB_ID; +import static org.apache.james.mailbox.postgres.mail.PostgresMessageModule.MessageTable.HEADER_CONTENT; + +import java.util.List; +import java.util.Map; + +import org.apache.commons.lang3.tuple.Pair; +import org.apache.james.blob.api.BlobId; +import org.apache.james.blob.api.BlobStore; +import org.apache.james.mailbox.model.AttachmentId; +import org.apache.james.mailbox.model.AttachmentMetadata; +import org.apache.james.mailbox.model.Content; +import org.apache.james.mailbox.model.HeaderAndBodyByteContent; +import org.apache.james.mailbox.model.MessageAttachmentMetadata; +import org.apache.james.mailbox.postgres.mail.dto.AttachmentsDTO; +import org.apache.james.mailbox.store.mail.MessageMapper; +import org.apache.james.mailbox.store.mail.model.MailboxMessage; +import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; +import org.apache.james.util.ReactorUtils; +import org.jooq.Record; + +import com.google.common.collect.ImmutableMap; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresMessageRetriever { + + interface PartRetriever { + + boolean isApplicable(MessageMapper.FetchType fetchType); + + Flux> doRetrieve(Flux> chain); + } + + class AttachmentPartRetriever implements PartRetriever { + + @Override + public boolean isApplicable(MessageMapper.FetchType fetchType) { + return fetchType == MessageMapper.FetchType.FULL || fetchType == MessageMapper.FetchType.ATTACHMENTS_METADATA; + } + + @Override + public Flux> doRetrieve(Flux> chain) { + return chain.collectList() // convert to list to avoid hanging the database connection with Jooq + .flatMapMany(list -> Flux.fromIterable(list) + .flatMapSequential(pair -> Mono.fromCallable(() -> toMap(pair.getRight().get(ATTACHMENT_METADATA))) + .flatMap(this::getAttachments) + .map(messageAttachmentMetadata -> { + pair.getLeft().addAttachments(messageAttachmentMetadata); + return pair; + }).switchIfEmpty(Mono.just(pair)))); + } + + private Map toMap(AttachmentsDTO attachmentRepresentations) { + return attachmentRepresentations.stream().collect(ImmutableMap.toImmutableMap(MessageRepresentation.AttachmentRepresentation::getAttachmentId, obj -> obj)); + } + + private Mono> getAttachments(Map mapAttachmentIdToAttachmentRepresentation) { + return Mono.fromCallable(mapAttachmentIdToAttachmentRepresentation::keySet) + .flatMapMany(attachmentMapper::getAttachmentsReactive) + .map(attachmentMetadata -> constructMessageAttachment(attachmentMetadata, mapAttachmentIdToAttachmentRepresentation.get(attachmentMetadata.getAttachmentId()))) + .collectList(); + } + + private MessageAttachmentMetadata constructMessageAttachment(AttachmentMetadata attachment, MessageRepresentation.AttachmentRepresentation messageAttachmentRepresentation) { + return MessageAttachmentMetadata.builder() + .attachment(attachment) + .name(messageAttachmentRepresentation.getName().orElse(null)) + .cid(messageAttachmentRepresentation.getCid()) + .isInline(messageAttachmentRepresentation.isInline()) + .build(); + } + } + + class BlobContentPartRetriever implements PartRetriever { + + @Override + public boolean isApplicable(MessageMapper.FetchType fetchType) { + return fetchType == MessageMapper.FetchType.FULL; + } + + @Override + public Flux> doRetrieve(Flux> chain) { + return chain + .flatMapSequential(pair -> retrieveFullContent(pair.getRight()) + .map(headerAndBodyContent -> Pair.of(pair.getLeft().content(headerAndBodyContent), pair.getRight())), + ReactorUtils.DEFAULT_CONCURRENCY); + } + + private Mono retrieveFullContent(Record messageRecord) { + return Mono.from(blobStore.readBytes(blobStore.getDefaultBucketName(), + blobIdFactory.from(messageRecord.get(BODY_BLOB_ID)), + SIZE_BASED)) + .map(bodyBytes -> new HeaderAndBodyByteContent(messageRecord.get(HEADER_CONTENT), bodyBytes)); + } + } + + private final BlobStore blobStore; + private final BlobId.Factory blobIdFactory; + private final PostgresAttachmentMapper attachmentMapper; + private final List partRetrievers = List.of(new AttachmentPartRetriever(), new BlobContentPartRetriever()); + + public PostgresMessageRetriever(BlobStore blobStore, + BlobId.Factory blobIdFactory, + PostgresAttachmentMapper attachmentMapper) { + this.blobStore = blobStore; + this.blobIdFactory = blobIdFactory; + this.attachmentMapper = attachmentMapper; + } + + public Flux get(MessageMapper.FetchType fetchType, Flux> initialFlux) { + return Flux.fromIterable(partRetrievers) + .filter(partRetriever -> partRetriever.isApplicable(fetchType)) + .reduce(initialFlux, (flux, partRetriever) -> partRetriever.doRetrieve(flux)) + .flatMapMany(flux -> flux) + .map(pair -> pair.getLeft().build()); + } +} From ba3780676a2236a2762a9cf0a11ddfc538d9f09e Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 20 Mar 2024 18:07:30 +0700 Subject: [PATCH 263/341] JAMES-2586 Fix MailboxSetMethodContract --- .../contract/MailboxSetMethodContract.scala | 14 +++++------ .../james/jmap/rfc8621/contract/package.scala | 24 ++++++++++++++++++- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxSetMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxSetMethodContract.scala index 363a2d4de13..b33c312508c 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxSetMethodContract.scala +++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/MailboxSetMethodContract.scala @@ -45,7 +45,7 @@ import org.apache.james.modules.{ACLProbeImpl, MailboxProbeImpl} import org.apache.james.util.concurrency.ConcurrentTestRunner import org.apache.james.utils.DataProbeImpl import org.assertj.core.api.Assertions.assertThat -import org.assertj.core.api.{Assertions, SoftAssertions} +import org.assertj.core.api.{Assertions, SoftAssertions, ThrowingConsumer} import org.awaitility.Awaitility import org.hamcrest.Matchers.{equalTo, hasSize, not} import org.junit.jupiter.api.{BeforeEach, RepeatedTest, Tag, Test} @@ -61,6 +61,7 @@ import sttp.monad.MonadError import sttp.ws.WebSocketFrame import scala.collection.mutable.ListBuffer +import scala.concurrent.duration.MILLISECONDS import scala.jdk.CollectionConverters._ @@ -8167,18 +8168,15 @@ trait MailboxSetMethodContract { | } | }, "c1"]] |}""".stripMargin)) - - ws.receive().asPayload - List(ws.receive().asPayload) + ws.receiveMessageInTimespan(scala.concurrent.duration.Duration(1000, MILLISECONDS)) }) .send(backend) .body - Thread.sleep(200) + val hasMailboxStateChangeConsumer : ThrowingConsumer[String] = (s: String) => assertThat(s) + .startsWith("{\"@type\":\"StateChange\",\"changed\":{\"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6\":{\"Mailbox\":") assertThat(response.toOption.get.asJava) - .hasSize(1) - assertThat(response.toOption.get.head) - .startsWith("{\"@type\":\"StateChange\",\"changed\":{\"29883977c13473ae7cb7678ef767cbfbaffc8a44a6e463d971d23a65c1dc4af6\":{\"Mailbox\":") + .anySatisfy(hasMailboxStateChangeConsumer) } @Test diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/package.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/package.scala index 4b2a41999ab..a004d608dca 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/package.scala +++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/package.scala @@ -19,10 +19,17 @@ package org.apache.james.jmap.rfc8621 +import java.util.concurrent.TimeoutException + import cats.implicits.toFunctorOps +import reactor.core.publisher.Flux +import reactor.core.scala.publisher.SMono +import reactor.core.scheduler.Schedulers import sttp.client3.Identity -import sttp.ws.WebSocketFrame import sttp.ws.WebSocketFrame.Text +import sttp.ws.{WebSocket, WebSocketFrame} + +import scala.concurrent.duration.{Duration, MILLISECONDS} package object contract { @@ -32,4 +39,19 @@ package object contract { case _ => throw new RuntimeException("Not a text frame") } } + + + implicit class receiveMessageInTimespan(val ws: WebSocket[Identity]) { + def receiveMessageInTimespan(timeout: Duration = scala.concurrent.duration.Duration(1000, MILLISECONDS)): List[Identity[String]] = + SMono.fromCallable(() => ws.receive().asPayload) + .publishOn(Schedulers.boundedElastic()) + .repeat() + .take(timeout) + .onErrorResume { + case _: TimeoutException => + Flux.empty[String] + } + .collectSeq() + .block().toList + } } From e7e9df80b23acac9a069bc8b8fd35616969dd9b7 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Thu, 21 Mar 2024 10:58:21 +0700 Subject: [PATCH 264/341] JAMES-2586 Avoid declare jooq and r2dbc-postgresql version in multiple places Was declared in the backend-common postgres module, and it is enough. --- server/blob/blob-postgres/pom.xml | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/server/blob/blob-postgres/pom.xml b/server/blob/blob-postgres/pom.xml index 09ab43e02b4..d5bb4bfd06f 100644 --- a/server/blob/blob-postgres/pom.xml +++ b/server/blob/blob-postgres/pom.xml @@ -31,11 +31,6 @@ Apache James :: Server :: Blob :: Postgres - - 3.16.22 - 1.0.2.RELEASE - - ${james.groupId} @@ -106,26 +101,11 @@ awaitility test - - org.jooq - jooq - ${jooq.version} - - - org.jooq - jooq-postgres-extensions - ${jooq.version} - org.mockito mockito-core test - - org.postgresql - r2dbc-postgresql - ${r2dbc.postgresql.version} - org.testcontainers junit-jupiter From 354eee24e3641672f961c852d5bf913798657a3e Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Thu, 21 Mar 2024 10:58:43 +0700 Subject: [PATCH 265/341] JAMES-2586 Bump jOOQ to 3.19.6 --- backends-common/postgres/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backends-common/postgres/pom.xml b/backends-common/postgres/pom.xml index 2ec6e658a5d..2b4ec8797e8 100644 --- a/backends-common/postgres/pom.xml +++ b/backends-common/postgres/pom.xml @@ -29,7 +29,7 @@ Apache James :: Backends Common :: Postgres - 3.16.23 + 3.19.6 1.0.3.RELEASE From 36f0a1aebad448dbe0d47551583f86574e0bad9c Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Thu, 21 Mar 2024 11:59:30 +0700 Subject: [PATCH 266/341] JAMES-2586 Bump r2dbc-postgresql to 1.0.4 --- backends-common/postgres/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backends-common/postgres/pom.xml b/backends-common/postgres/pom.xml index 2b4ec8797e8..437c49bd538 100644 --- a/backends-common/postgres/pom.xml +++ b/backends-common/postgres/pom.xml @@ -30,7 +30,7 @@ 3.19.6 - 1.0.3.RELEASE + 1.0.4.RELEASE From 317f109f125df04ecd33b01b0b4754b323ba04a4 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Fri, 22 Mar 2024 10:07:29 +0700 Subject: [PATCH 267/341] JAMES-2586 Adapt jooq 3.19.6 change --- .../james/backends/postgres/utils/PostgresExecutor.java | 3 +-- .../apache/james/sieve/postgres/PostgresSieveRepository.java | 2 +- .../apache/james/sieve/postgres/PostgresSieveScriptDAO.java | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index 37d3726e140..4bfb730ab0c 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -138,10 +138,9 @@ public Mono executeExists(Function> .map(record -> record.get(0, Boolean.class)); } - public Mono executeReturnAffectedRowsCount(Function> queryFunction) { + public Mono executeReturnAffectedRowsCount(Function> queryFunction) { return dslContext() .flatMap(queryFunction) - .cast(Long.class) .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) .filter(preparedStatementConflictException())); } diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveRepository.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveRepository.java index f9b09e8eabb..0fb63a018fa 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveRepository.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveRepository.java @@ -196,7 +196,7 @@ public void deleteScript(Username username, ScriptName name) throws ScriptNotFou @Override public void renameScript(Username username, ScriptName oldName, ScriptName newName) throws DuplicateException, ScriptNotFoundException { try { - long renamedScripts = postgresSieveScriptDAO.renameScript(username, oldName, newName).block(); + int renamedScripts = postgresSieveScriptDAO.renameScript(username, oldName, newName).block(); if (renamedScripts == 0) { throw new ScriptNotFoundException(); } diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveScriptDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveScriptDAO.java index 88ff9c40342..92e81ce3476 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveScriptDAO.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveScriptDAO.java @@ -53,7 +53,7 @@ public PostgresSieveScriptDAO(@Named(DEFAULT_INJECT) PostgresExecutor postgresEx this.postgresExecutor = postgresExecutor; } - public Mono upsertScript(PostgresSieveScript sieveScript) { + public Mono upsertScript(PostgresSieveScript sieveScript) { return postgresExecutor.executeReturnAffectedRowsCount(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME) .set(SCRIPT_ID, sieveScript.getId().getValue()) .set(USERNAME, sieveScript.getUsername()) @@ -128,7 +128,7 @@ public Mono deactivateCurrentActiveScript(Username username) { IS_ACTIVE.eq(true)))); } - public Mono renameScript(Username username, ScriptName oldName, ScriptName newName) { + public Mono renameScript(Username username, ScriptName oldName, ScriptName newName) { return postgresExecutor.executeReturnAffectedRowsCount(dslContext -> Mono.from(dslContext.update(TABLE_NAME) .set(SCRIPT_NAME, newName.getValue()) .where(USERNAME.eq(username.asString()), From b744953bbeb2d7b06cc8108fb76e6aac723cd967 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Fri, 22 Mar 2024 12:34:37 +0700 Subject: [PATCH 268/341] JAMES-2586 Postgres RewriteTablesTest should not fail unstable test phase cf: https://github.com/apache/james-project/pull/1963/commits/c6f2462a8d8cf7b90c1509ca9c13ae16ecc74ba5 --- .../java/org/apache/james/rrt/postgres/RewriteTablesTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/RewriteTablesTest.java b/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/RewriteTablesTest.java index ee1e00e3f56..e6f3e2cef24 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/RewriteTablesTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/RewriteTablesTest.java @@ -25,7 +25,7 @@ import org.junit.platform.suite.api.SelectClasspathResource; import org.junit.platform.suite.api.Suite; -@Suite +@Suite(failIfNoTests = false) @IncludeEngines("cucumber") @SelectClasspathResource("cucumber") @ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "org.apache.james.rrt.lib,org.apache.james.rrt.postgres") From 9a550760fd1deb2c176985711c9b8d0a67bd2d20 Mon Sep 17 00:00:00 2001 From: hung phan Date: Tue, 19 Mar 2024 15:53:50 +0700 Subject: [PATCH 269/341] JAMES-2586 Create AttachmentIdFactory --- .../jmap/draft/methods/integration/SetMessagesMethodTest.java | 0 .../java/org/apache/james/jmap/draft/methods/BlobManagerImpl.java | 0 .../src/main/java/org/apache/james/jmap/draft/model/BlobId.java | 0 .../org/apache/james/jmap/draft/methods/BlobManagerImplTest.java | 0 .../apache/james/jmap/draft/methods/MIMEMessageConverterTest.java | 0 .../jmap/draft/model/message/view/MessageFastViewFactoryTest.java | 0 .../jmap/draft/model/message/view/MessageFullViewFactoryTest.java | 0 .../draft/model/message/view/MessageHeaderViewFactoryTest.java | 0 .../draft/model/message/view/MessageMetadataViewFactoryTest.java | 0 9 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 server/protocols/jmap-draft-integration-testing/jmap-draft-integration-testing-common/src/test/java/org/apache/james/jmap/draft/methods/integration/SetMessagesMethodTest.java create mode 100644 server/protocols/jmap-draft/src/main/java/org/apache/james/jmap/draft/methods/BlobManagerImpl.java create mode 100644 server/protocols/jmap-draft/src/main/java/org/apache/james/jmap/draft/model/BlobId.java create mode 100644 server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/draft/methods/BlobManagerImplTest.java create mode 100644 server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/draft/methods/MIMEMessageConverterTest.java create mode 100644 server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/draft/model/message/view/MessageFastViewFactoryTest.java create mode 100644 server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/draft/model/message/view/MessageFullViewFactoryTest.java create mode 100644 server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/draft/model/message/view/MessageHeaderViewFactoryTest.java create mode 100644 server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/draft/model/message/view/MessageMetadataViewFactoryTest.java diff --git a/server/protocols/jmap-draft-integration-testing/jmap-draft-integration-testing-common/src/test/java/org/apache/james/jmap/draft/methods/integration/SetMessagesMethodTest.java b/server/protocols/jmap-draft-integration-testing/jmap-draft-integration-testing-common/src/test/java/org/apache/james/jmap/draft/methods/integration/SetMessagesMethodTest.java new file mode 100644 index 00000000000..e69de29bb2d diff --git a/server/protocols/jmap-draft/src/main/java/org/apache/james/jmap/draft/methods/BlobManagerImpl.java b/server/protocols/jmap-draft/src/main/java/org/apache/james/jmap/draft/methods/BlobManagerImpl.java new file mode 100644 index 00000000000..e69de29bb2d diff --git a/server/protocols/jmap-draft/src/main/java/org/apache/james/jmap/draft/model/BlobId.java b/server/protocols/jmap-draft/src/main/java/org/apache/james/jmap/draft/model/BlobId.java new file mode 100644 index 00000000000..e69de29bb2d diff --git a/server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/draft/methods/BlobManagerImplTest.java b/server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/draft/methods/BlobManagerImplTest.java new file mode 100644 index 00000000000..e69de29bb2d diff --git a/server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/draft/methods/MIMEMessageConverterTest.java b/server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/draft/methods/MIMEMessageConverterTest.java new file mode 100644 index 00000000000..e69de29bb2d diff --git a/server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/draft/model/message/view/MessageFastViewFactoryTest.java b/server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/draft/model/message/view/MessageFastViewFactoryTest.java new file mode 100644 index 00000000000..e69de29bb2d diff --git a/server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/draft/model/message/view/MessageFullViewFactoryTest.java b/server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/draft/model/message/view/MessageFullViewFactoryTest.java new file mode 100644 index 00000000000..e69de29bb2d diff --git a/server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/draft/model/message/view/MessageHeaderViewFactoryTest.java b/server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/draft/model/message/view/MessageHeaderViewFactoryTest.java new file mode 100644 index 00000000000..e69de29bb2d diff --git a/server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/draft/model/message/view/MessageMetadataViewFactoryTest.java b/server/protocols/jmap-draft/src/test/java/org/apache/james/jmap/draft/model/message/view/MessageMetadataViewFactoryTest.java new file mode 100644 index 00000000000..e69de29bb2d From 322380ae0b797a205aa4fa8283d9ae879ec06c9b Mon Sep 17 00:00:00 2001 From: hung phan Date: Thu, 21 Mar 2024 10:19:58 +0700 Subject: [PATCH 270/341] JAMES-2586 Add UuidBackedAttachmentIdFactory --- .../UuidBackedAttachmentIdFactory.java | 34 +++++++++ .../mailbox/model/UuidBackedAttachmentId.java | 76 +++++++++++++++++++ .../mail/PostgresAttachmentMapper.java | 3 +- .../mail/PostgresAttachmentModule.java | 2 +- .../mail/dao/PostgresAttachmentDAO.java | 7 +- .../postgres/mail/dto/AttachmentsDTO.java | 3 +- ...gresAttachmentBlobReferenceSourceTest.java | 5 +- .../mailbox/PostgresMailboxModule.java | 3 + 8 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 mailbox/api/src/main/java/org/apache/james/mailbox/UuidBackedAttachmentIdFactory.java create mode 100644 mailbox/api/src/main/java/org/apache/james/mailbox/model/UuidBackedAttachmentId.java diff --git a/mailbox/api/src/main/java/org/apache/james/mailbox/UuidBackedAttachmentIdFactory.java b/mailbox/api/src/main/java/org/apache/james/mailbox/UuidBackedAttachmentIdFactory.java new file mode 100644 index 00000000000..3cebb5ab36e --- /dev/null +++ b/mailbox/api/src/main/java/org/apache/james/mailbox/UuidBackedAttachmentIdFactory.java @@ -0,0 +1,34 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox; + +import org.apache.james.mailbox.model.UuidBackedAttachmentId; + +public class UuidBackedAttachmentIdFactory implements AttachmentIdFactory { + @Override + public UuidBackedAttachmentId random() { + return UuidBackedAttachmentId.random(); + } + + @Override + public UuidBackedAttachmentId from(String id) { + return UuidBackedAttachmentId.from(id); + } +} diff --git a/mailbox/api/src/main/java/org/apache/james/mailbox/model/UuidBackedAttachmentId.java b/mailbox/api/src/main/java/org/apache/james/mailbox/model/UuidBackedAttachmentId.java new file mode 100644 index 00000000000..12186a28821 --- /dev/null +++ b/mailbox/api/src/main/java/org/apache/james/mailbox/model/UuidBackedAttachmentId.java @@ -0,0 +1,76 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ +package org.apache.james.mailbox.model; + +import java.util.UUID; + +import com.google.common.base.MoreObjects; +import com.google.common.base.Objects; + +public class UuidBackedAttachmentId implements AttachmentId { + public static UuidBackedAttachmentId random() { + return new UuidBackedAttachmentId(UUID.randomUUID()); + } + + public static UuidBackedAttachmentId from(String id) { + return new UuidBackedAttachmentId(UUID.fromString(id)); + } + + public static UuidBackedAttachmentId from(UUID id) { + return new UuidBackedAttachmentId(id); + } + + private final UUID id; + + private UuidBackedAttachmentId(UUID id) { + this.id = id; + } + + @Override + public String getId() { + return id.toString(); + } + + @Override + public UUID asUUID() { + return id; + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof UuidBackedAttachmentId) { + UuidBackedAttachmentId other = (UuidBackedAttachmentId) obj; + return Objects.equal(id, other.id); + } + return false; + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } + + @Override + public String toString() { + return MoreObjects + .toStringHelper(this) + .add("id", id) + .toString(); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentMapper.java index f1d00421c25..1be53fa3a64 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentMapper.java @@ -33,6 +33,7 @@ import org.apache.james.mailbox.model.MessageAttachmentMetadata; import org.apache.james.mailbox.model.MessageId; import org.apache.james.mailbox.model.ParsedAttachment; +import org.apache.james.mailbox.model.UuidBackedAttachmentId; import org.apache.james.mailbox.postgres.mail.dao.PostgresAttachmentDAO; import org.apache.james.mailbox.store.mail.AttachmentMapper; @@ -111,7 +112,7 @@ private Mono storeAttachmentAsync(ParsedAttachment pa return Mono.fromCallable(parsedAttachment::getContent) .flatMap(content -> Mono.from(blobStore.save(blobStore.getDefaultBucketName(), parsedAttachment.getContent(), BlobStore.StoragePolicy.LOW_COST)) .flatMap(blobId -> { - AttachmentId attachmentId = AttachmentId.random(); + AttachmentId attachmentId = UuidBackedAttachmentId.random(); return postgresAttachmentDAO.storeAttachment(AttachmentMetadata.builder() .attachmentId(attachmentId) .type(parsedAttachment.getContentType()) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentModule.java index 4b3fb59510d..2bc4e0b16b2 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentModule.java @@ -35,7 +35,7 @@ public interface PostgresAttachmentModule { interface PostgresAttachmentTable { Table TABLE_NAME = DSL.table("attachment"); - Field ID = DSL.field("id", SQLDataType.VARCHAR.notNull()); + Field ID = DSL.field("id", SQLDataType.UUID.notNull()); Field BLOB_ID = DSL.field("blob_id", SQLDataType.VARCHAR); Field TYPE = DSL.field("type", SQLDataType.VARCHAR); Field MESSAGE_ID = DSL.field("message_id", SQLDataType.UUID); diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresAttachmentDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresAttachmentDAO.java index 8649a1329c6..9f89de648e6 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresAttachmentDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresAttachmentDAO.java @@ -31,6 +31,7 @@ import org.apache.james.core.Domain; import org.apache.james.mailbox.model.AttachmentId; import org.apache.james.mailbox.model.AttachmentMetadata; +import org.apache.james.mailbox.model.UuidBackedAttachmentId; import org.apache.james.mailbox.postgres.PostgresMessageId; import org.apache.james.mailbox.postgres.mail.PostgresAttachmentModule.PostgresAttachmentTable; @@ -72,7 +73,7 @@ public Mono> getAttachment(AttachmentId attachm PostgresAttachmentTable.MESSAGE_ID, PostgresAttachmentTable.SIZE) .from(PostgresAttachmentTable.TABLE_NAME) - .where(PostgresAttachmentTable.ID.eq(attachmentId.getId())))) + .where(PostgresAttachmentTable.ID.eq(attachmentId.asUUID())))) .map(row -> Pair.of( AttachmentMetadata.builder() .attachmentId(attachmentId) @@ -90,7 +91,7 @@ public Flux getAttachments(Collection attachme return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.selectFrom(PostgresAttachmentTable.TABLE_NAME) .where(PostgresAttachmentTable.ID.in(attachmentIds.stream().map(AttachmentId::getId).collect(ImmutableList.toImmutableList()))))) .map(row -> AttachmentMetadata.builder() - .attachmentId(AttachmentId.from(row.get(PostgresAttachmentTable.ID))) + .attachmentId(UuidBackedAttachmentId.from(row.get(PostgresAttachmentTable.ID))) .type(row.get(PostgresAttachmentTable.TYPE)) .messageId(PostgresMessageId.Factory.of(row.get(PostgresAttachmentTable.MESSAGE_ID))) .size(row.get(PostgresAttachmentTable.SIZE)) @@ -99,7 +100,7 @@ public Flux getAttachments(Collection attachme public Mono storeAttachment(AttachmentMetadata attachment, BlobId blobId) { return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(PostgresAttachmentTable.TABLE_NAME) - .set(PostgresAttachmentTable.ID, attachment.getAttachmentId().getId()) + .set(PostgresAttachmentTable.ID, attachment.getAttachmentId().asUUID()) .set(PostgresAttachmentTable.BLOB_ID, blobId.asString()) .set(PostgresAttachmentTable.TYPE, attachment.getType().asString()) .set(PostgresAttachmentTable.MESSAGE_ID, ((PostgresMessageId) attachment.getMessageId()).asUuid()) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dto/AttachmentsDTO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dto/AttachmentsDTO.java index 10c1d8eebc5..a54f0e09727 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dto/AttachmentsDTO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dto/AttachmentsDTO.java @@ -32,6 +32,7 @@ import org.apache.james.mailbox.model.AttachmentId; import org.apache.james.mailbox.model.Cid; import org.apache.james.mailbox.model.MessageAttachmentMetadata; +import org.apache.james.mailbox.model.UuidBackedAttachmentId; import org.apache.james.mailbox.postgres.mail.MessageRepresentation; import org.jooq.BindingGetResultSetContext; import org.jooq.BindingSetStatementContext; @@ -94,7 +95,7 @@ public Object to(AttachmentsDTO userObject) { } private MessageRepresentation.AttachmentRepresentation fromJsonNode(JsonNode jsonNode) { - AttachmentId attachmentId = AttachmentId.from(jsonNode.get(ATTACHMENT_ID_PROPERTY).asText()); + AttachmentId attachmentId = UuidBackedAttachmentId.from(jsonNode.get(ATTACHMENT_ID_PROPERTY).asText()); Optional name = Optional.ofNullable(jsonNode.get(NAME_PROPERTY)).map(JsonNode::asText); Optional cid = Optional.ofNullable(jsonNode.get(CID_PROPERTY)).map(JsonNode::asText).map(Cid::from); boolean isInline = jsonNode.get(IN_LINE_PROPERTY).asBoolean(); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentBlobReferenceSourceTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentBlobReferenceSourceTest.java index cfe0be56009..b48d76ffa48 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentBlobReferenceSourceTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentBlobReferenceSourceTest.java @@ -26,6 +26,7 @@ import org.apache.james.blob.api.HashBlobId; import org.apache.james.mailbox.model.AttachmentId; import org.apache.james.mailbox.model.AttachmentMetadata; +import org.apache.james.mailbox.model.UuidBackedAttachmentId; import org.apache.james.mailbox.postgres.PostgresMailboxAggregateModule; import org.apache.james.mailbox.postgres.PostgresMessageId; import org.apache.james.mailbox.postgres.mail.dao.PostgresAttachmentDAO; @@ -35,8 +36,8 @@ class PostgresAttachmentBlobReferenceSourceTest { - private static final AttachmentId ATTACHMENT_ID = AttachmentId.from("id1"); - private static final AttachmentId ATTACHMENT_ID_2 = AttachmentId.from("id2"); + private static final AttachmentId ATTACHMENT_ID = UuidBackedAttachmentId.random(); + private static final AttachmentId ATTACHMENT_ID_2 = UuidBackedAttachmentId.random(); private static final HashBlobId.Factory BLOB_ID_FACTORY = new HashBlobId.Factory(); @RegisterExtension diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java index bde8e18b3be..c0c050e03b7 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java @@ -33,6 +33,7 @@ import org.apache.james.blob.api.BlobReferenceSource; import org.apache.james.events.EventListener; import org.apache.james.mailbox.AttachmentContentLoader; +import org.apache.james.mailbox.AttachmentIdFactory; import org.apache.james.mailbox.AttachmentManager; import org.apache.james.mailbox.Authenticator; import org.apache.james.mailbox.Authorizator; @@ -42,6 +43,7 @@ import org.apache.james.mailbox.RightManager; import org.apache.james.mailbox.SessionProvider; import org.apache.james.mailbox.SubscriptionManager; +import org.apache.james.mailbox.UuidBackedAttachmentIdFactory; import org.apache.james.mailbox.acl.MailboxACLResolver; import org.apache.james.mailbox.acl.UnionMailboxACLResolver; import org.apache.james.mailbox.indexer.ReIndexer; @@ -128,6 +130,7 @@ protected void configure() { bind(MailboxACLResolver.class).to(UnionMailboxACLResolver.class); bind(MessageIdManager.class).to(StoreMessageIdManager.class); bind(RightManager.class).to(StoreRightManager.class); + bind(AttachmentIdFactory.class).to(UuidBackedAttachmentIdFactory.class); bind(AttachmentManager.class).to(StoreAttachmentManager.class); bind(AttachmentContentLoader.class).to(AttachmentManager.class); bind(AttachmentMapperFactory.class).to(PostgresMailboxSessionMapperFactory.class); From 919bb5ab08c4c572bd02d1cc3ae5a135345872e0 Mon Sep 17 00:00:00 2001 From: vttran Date: Tue, 26 Mar 2024 10:02:06 +0700 Subject: [PATCH 271/341] JAMES-2586 Update Guice binding Postgres (#2154) --- .../main/java/org/apache/james/PostgresJamesServerMain.java | 6 +++--- .../apache/james/modules/mailbox/PostgresMailboxModule.java | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index 1062367e597..2ea342ecdf0 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -92,7 +92,8 @@ public class PostgresJamesServerMain implements JamesServerMain { new MailboxesExportRoutesModule(), new UserIdentityModule(), new DLPRoutesModule(), - new JmapUploadCleanupModule()); + new JmapUploadCleanupModule(), + new JmapTasksModule()); private static final Module PROTOCOLS = Modules.combine( new IMAPServerModule(), @@ -122,8 +123,7 @@ public class PostgresJamesServerMain implements JamesServerMain { new PostgresJmapModule(), new PostgresDataJmapModule(), new JmapEventBusModule(), - new JMAPServerModule(), - new JmapTasksModule()); + new JMAPServerModule()); private static final Module POSTGRES_MODULE_AGGREGATE = Modules.combine( new MailetProcessingModule(), POSTGRES_SERVER_MODULE, PROTOCOLS, JMAP); diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java index c0c050e03b7..ca688e502e6 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java @@ -46,6 +46,7 @@ import org.apache.james.mailbox.UuidBackedAttachmentIdFactory; import org.apache.james.mailbox.acl.MailboxACLResolver; import org.apache.james.mailbox.acl.UnionMailboxACLResolver; +import org.apache.james.mailbox.indexer.MessageIdReIndexer; import org.apache.james.mailbox.indexer.ReIndexer; import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.MessageId; @@ -79,6 +80,7 @@ import org.apache.james.user.api.DeleteUserDataTaskStep; import org.apache.james.user.api.UsernameChangeTaskStep; import org.apache.james.utils.MailboxManagerDefinition; +import org.apache.mailbox.tools.indexer.MessageIdReIndexerImpl; import org.apache.mailbox.tools.indexer.ReIndexerImpl; import com.google.inject.AbstractModule; @@ -136,6 +138,7 @@ protected void configure() { bind(AttachmentMapperFactory.class).to(PostgresMailboxSessionMapperFactory.class); bind(ReIndexer.class).to(ReIndexerImpl.class); + bind(MessageIdReIndexer.class).to(MessageIdReIndexerImpl.class); bind(PostgresMessageDAO.class).in(Scopes.SINGLETON); From d1d442a17c463fd1bffb22e30637553710e0b68f Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 27 Mar 2024 17:02:26 +0700 Subject: [PATCH 272/341] Duplicated the QuotaDTO event and related classes from quota-mailing-cassandra to quota-mailing module --- .../mailing/events/HistoryEvolutionDTO.java | 66 +++++++++++++++++++ .../quota/mailing/events/QuotaDTO.java | 59 +++++++++++++++++ .../mailing/events/QuotaEventDTOModules.java | 34 ++++++++++ .../events/QuotaThresholdChangedEventDTO.java | 65 ++++++++++++++++++ .../modules/plugins/QuotaMailingModule.java | 40 +++++++++++ 5 files changed, 264 insertions(+) create mode 100644 mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/mailing/events/HistoryEvolutionDTO.java create mode 100644 mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/mailing/events/QuotaDTO.java create mode 100644 mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/mailing/events/QuotaEventDTOModules.java create mode 100644 mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/mailing/events/QuotaThresholdChangedEventDTO.java create mode 100644 server/container/guice/distributed/src/main/java/org/apache/james/modules/plugins/QuotaMailingModule.java diff --git a/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/mailing/events/HistoryEvolutionDTO.java b/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/mailing/events/HistoryEvolutionDTO.java new file mode 100644 index 00000000000..afeaab89b2f --- /dev/null +++ b/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/mailing/events/HistoryEvolutionDTO.java @@ -0,0 +1,66 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.quota.mailing.events; + +import java.time.Instant; +import java.util.Optional; + +import org.apache.james.mailbox.quota.model.HistoryEvolution; +import org.apache.james.mailbox.quota.model.QuotaThreshold; +import org.apache.james.mailbox.quota.model.QuotaThresholdChange; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.Preconditions; +import com.google.common.primitives.Booleans; + +public record HistoryEvolutionDTO(@JsonProperty("change") HistoryEvolution.HistoryChangeType change, + @JsonProperty("recentness") Optional recentness, + @JsonProperty("threshold") Optional threshold, + @JsonProperty("instant") Optional instant) { + + @JsonIgnore + public static HistoryEvolutionDTO toDto(HistoryEvolution historyEvolution) { + return new HistoryEvolutionDTO( + historyEvolution.getThresholdHistoryChange(), + historyEvolution.getRecentness(), + historyEvolution.getThresholdChange() + .map(QuotaThresholdChange::getQuotaThreshold) + .map(QuotaThreshold::getQuotaOccupationRatio), + historyEvolution.getThresholdChange() + .map(QuotaThresholdChange::getInstant) + .map(Instant::toEpochMilli)); + } + + @JsonIgnore + public HistoryEvolution toHistoryEvolution() { + Preconditions.checkState(Booleans.countTrue(threshold.isPresent(), instant.isPresent()) != 1, + "threshold and instant needs to be both set, or both unset. Mixed states not allowed."); + + Optional quotaThresholdChange = threshold + .map(QuotaThreshold::new) + .map(value -> new QuotaThresholdChange(value, Instant.ofEpochMilli(instant.get()))); + + return new HistoryEvolution( + change, + recentness, + quotaThresholdChange); + } +} \ No newline at end of file diff --git a/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/mailing/events/QuotaDTO.java b/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/mailing/events/QuotaDTO.java new file mode 100644 index 00000000000..eff3a667e40 --- /dev/null +++ b/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/mailing/events/QuotaDTO.java @@ -0,0 +1,59 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.quota.mailing.events; + +import java.util.Optional; + +import org.apache.james.core.quota.QuotaCountLimit; +import org.apache.james.core.quota.QuotaCountUsage; +import org.apache.james.core.quota.QuotaSizeLimit; +import org.apache.james.core.quota.QuotaSizeUsage; +import org.apache.james.mailbox.model.Quota; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + +public record QuotaDTO(@JsonProperty("used") long used, + @JsonProperty("limit") Optional limit) { + + @JsonIgnore + public static QuotaDTO from(Quota quota) { + if (quota.getLimit().isUnlimited()) { + return new QuotaDTO(quota.getUsed().asLong(), Optional.empty()); + } + return new QuotaDTO(quota.getUsed().asLong(), Optional.of(quota.getLimit().asLong())); + } + + @JsonIgnore + public Quota asSizeQuota() { + return Quota.builder() + .used(QuotaSizeUsage.size(used)) + .computedLimit(QuotaSizeLimit.size(limit)) + .build(); + } + + @JsonIgnore + public Quota asCountQuota() { + return Quota.builder() + .used(QuotaCountUsage.count(used)) + .computedLimit(QuotaCountLimit.count(limit)) + .build(); + } +} \ No newline at end of file diff --git a/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/mailing/events/QuotaEventDTOModules.java b/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/mailing/events/QuotaEventDTOModules.java new file mode 100644 index 00000000000..5a0bb983c5c --- /dev/null +++ b/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/mailing/events/QuotaEventDTOModules.java @@ -0,0 +1,34 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.quota.mailing.events; + +import org.apache.james.eventsourcing.eventstore.dto.EventDTOModule; + +public interface QuotaEventDTOModules { + EventDTOModule QUOTA_THRESHOLD_CHANGE = + EventDTOModule + .forEvent(QuotaThresholdChangedEvent.class) + .convertToDTO(QuotaThresholdChangedEventDTO.class) + .toDomainObjectConverter(QuotaThresholdChangedEventDTO::toEvent) + .toDTOConverter(QuotaThresholdChangedEventDTO::from) + .typeName("quota-threshold-change") + .withFactory(EventDTOModule::new); + +} diff --git a/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/mailing/events/QuotaThresholdChangedEventDTO.java b/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/mailing/events/QuotaThresholdChangedEventDTO.java new file mode 100644 index 00000000000..725a1f5ecdf --- /dev/null +++ b/mailbox/plugin/quota-mailing/src/main/java/org/apache/james/mailbox/quota/mailing/events/QuotaThresholdChangedEventDTO.java @@ -0,0 +1,65 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.quota.mailing.events; + +import org.apache.james.eventsourcing.EventId; +import org.apache.james.eventsourcing.eventstore.dto.EventDTO; +import org.apache.james.mailbox.quota.mailing.aggregates.UserQuotaThresholds; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + +public record QuotaThresholdChangedEventDTO(@JsonProperty("type") String type, + @JsonProperty("eventId") int eventId, + @JsonProperty("aggregateId") String aggregateId, + @JsonProperty("sizeQuota") QuotaDTO sizeQuota, + @JsonProperty("countQuota") QuotaDTO countQuota, + @JsonProperty("sizeEvolution") HistoryEvolutionDTO sizeEvolution, + @JsonProperty("countEvolution") HistoryEvolutionDTO countEvolution) implements EventDTO { + + @JsonIgnore + public static QuotaThresholdChangedEventDTO from(QuotaThresholdChangedEvent event, String type) { + return new QuotaThresholdChangedEventDTO( + type, + event.eventId().serialize(), + event.getAggregateId().asAggregateKey(), + QuotaDTO.from(event.getSizeQuota()), + QuotaDTO.from(event.getCountQuota()), + HistoryEvolutionDTO.toDto(event.getSizeHistoryEvolution()), + HistoryEvolutionDTO.toDto(event.getCountHistoryEvolution())); + } + + @JsonIgnore + public QuotaThresholdChangedEvent toEvent() { + return new QuotaThresholdChangedEvent( + EventId.fromSerialized(eventId), + sizeEvolution.toHistoryEvolution(), + countEvolution.toHistoryEvolution(), + sizeQuota.asSizeQuota(), + countQuota.asCountQuota(), + UserQuotaThresholds.Id.fromKey(aggregateId)); + } + + @Override + @JsonIgnore + public String getType() { + return type; + } +} \ No newline at end of file diff --git a/server/container/guice/distributed/src/main/java/org/apache/james/modules/plugins/QuotaMailingModule.java b/server/container/guice/distributed/src/main/java/org/apache/james/modules/plugins/QuotaMailingModule.java new file mode 100644 index 00000000000..d5de19c2912 --- /dev/null +++ b/server/container/guice/distributed/src/main/java/org/apache/james/modules/plugins/QuotaMailingModule.java @@ -0,0 +1,40 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.plugins; + +import static org.apache.james.mailbox.quota.mailing.events.QuotaEventDTOModules.QUOTA_THRESHOLD_CHANGE; + +import org.apache.james.eventsourcing.Event; +import org.apache.james.eventsourcing.eventstore.dto.EventDTO; +import org.apache.james.eventsourcing.eventstore.dto.EventDTOModule; + +import com.google.inject.AbstractModule; +import com.google.inject.TypeLiteral; +import com.google.inject.multibindings.Multibinder; + +public class QuotaMailingModule extends AbstractModule { + @Override + protected void configure() { + Multibinder> eventDTOModuleBinder = Multibinder.newSetBinder(binder(), new TypeLiteral>() {}); + + eventDTOModuleBinder.addBinding() + .toInstance(QUOTA_THRESHOLD_CHANGE); + } +} \ No newline at end of file From dce47afa4d87984cd777d05a398d673030eb916a Mon Sep 17 00:00:00 2001 From: vttran Date: Thu, 28 Mar 2024 14:24:39 +0700 Subject: [PATCH 273/341] Refactor cassandra-quota-mailing: using the QuotaDTO event and related classes from quota-mailing module --- .../cassandra/dto/HistoryEvolutionDTO.java | 98 ---------------- .../mailbox/quota/cassandra/dto/QuotaDTO.java | 75 ------------ .../cassandra/dto/QuotaEventDTOModules.java | 36 ------ .../dto/QuotaThresholdChangedEventDTO.java | 108 ------------------ .../mailbox/quota/cassandra/dto/DTOTest.java | 5 +- ...aQuotaMailingListenersIntegrationTest.java | 2 +- .../mailbox/CassandraQuotaMailingModule.java | 2 +- 7 files changed, 6 insertions(+), 320 deletions(-) delete mode 100644 mailbox/plugin/quota-mailing-cassandra/src/main/java/org/apache/james/mailbox/quota/cassandra/dto/HistoryEvolutionDTO.java delete mode 100644 mailbox/plugin/quota-mailing-cassandra/src/main/java/org/apache/james/mailbox/quota/cassandra/dto/QuotaDTO.java delete mode 100644 mailbox/plugin/quota-mailing-cassandra/src/main/java/org/apache/james/mailbox/quota/cassandra/dto/QuotaEventDTOModules.java delete mode 100644 mailbox/plugin/quota-mailing-cassandra/src/main/java/org/apache/james/mailbox/quota/cassandra/dto/QuotaThresholdChangedEventDTO.java diff --git a/mailbox/plugin/quota-mailing-cassandra/src/main/java/org/apache/james/mailbox/quota/cassandra/dto/HistoryEvolutionDTO.java b/mailbox/plugin/quota-mailing-cassandra/src/main/java/org/apache/james/mailbox/quota/cassandra/dto/HistoryEvolutionDTO.java deleted file mode 100644 index 2d1dda1cc08..00000000000 --- a/mailbox/plugin/quota-mailing-cassandra/src/main/java/org/apache/james/mailbox/quota/cassandra/dto/HistoryEvolutionDTO.java +++ /dev/null @@ -1,98 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.quota.cassandra.dto; - -import java.time.Instant; -import java.util.Optional; - -import org.apache.james.mailbox.quota.model.HistoryEvolution; -import org.apache.james.mailbox.quota.model.QuotaThreshold; -import org.apache.james.mailbox.quota.model.QuotaThresholdChange; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonProperty; -import com.google.common.base.Preconditions; -import com.google.common.primitives.Booleans; - -class HistoryEvolutionDTO { - - public static HistoryEvolutionDTO toDto(HistoryEvolution historyEvolution) { - return new HistoryEvolutionDTO( - historyEvolution.getThresholdHistoryChange(), - historyEvolution.getRecentness(), - historyEvolution.getThresholdChange() - .map(QuotaThresholdChange::getQuotaThreshold) - .map(QuotaThreshold::getQuotaOccupationRatio), - historyEvolution.getThresholdChange() - .map(QuotaThresholdChange::getInstant) - .map(Instant::toEpochMilli)); - } - - private final HistoryEvolution.HistoryChangeType change; - private final Optional recentness; - private final Optional threshold; - private final Optional instant; - - @JsonCreator - public HistoryEvolutionDTO( - @JsonProperty("changeType") HistoryEvolution.HistoryChangeType change, - @JsonProperty("recentness") Optional recentness, - @JsonProperty("threshold") Optional threshold, - @JsonProperty("instant") Optional instant) { - this.change = change; - this.recentness = recentness; - this.threshold = threshold; - this.instant = instant; - } - - public HistoryEvolution.HistoryChangeType getChange() { - return change; - } - - public Optional getRecentness() { - return recentness; - } - - public Optional getThreshold() { - return threshold; - } - - public Optional getInstant() { - return instant; - } - - @JsonIgnore - public HistoryEvolution toHistoryEvolution() { - Preconditions.checkState(Booleans.countTrue( - threshold.isPresent(), instant.isPresent()) != 1, - "threshold and instant needs to be both set, or both unset. Mixed states not allowed."); - - Optional quotaThresholdChange = threshold - .map(QuotaThreshold::new) - .map(value -> new QuotaThresholdChange(value, Instant.ofEpochMilli(instant.get()))); - - return new HistoryEvolution( - change, - recentness, - quotaThresholdChange); - - } -} diff --git a/mailbox/plugin/quota-mailing-cassandra/src/main/java/org/apache/james/mailbox/quota/cassandra/dto/QuotaDTO.java b/mailbox/plugin/quota-mailing-cassandra/src/main/java/org/apache/james/mailbox/quota/cassandra/dto/QuotaDTO.java deleted file mode 100644 index 78e69cd5e8f..00000000000 --- a/mailbox/plugin/quota-mailing-cassandra/src/main/java/org/apache/james/mailbox/quota/cassandra/dto/QuotaDTO.java +++ /dev/null @@ -1,75 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.quota.cassandra.dto; - -import java.util.Optional; - -import org.apache.james.core.quota.QuotaCountLimit; -import org.apache.james.core.quota.QuotaCountUsage; -import org.apache.james.core.quota.QuotaSizeLimit; -import org.apache.james.core.quota.QuotaSizeUsage; -import org.apache.james.mailbox.model.Quota; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonProperty; - -class QuotaDTO { - public static QuotaDTO from(Quota quota) { - if (quota.getLimit().isUnlimited()) { - return new QuotaDTO(quota.getUsed().asLong(), Optional.empty()); - } - return new QuotaDTO(quota.getUsed().asLong(), Optional.of(quota.getLimit().asLong())); - } - - private final long used; - private final Optional limit; - - @JsonCreator - private QuotaDTO(@JsonProperty("used") long used, - @JsonProperty("limit") Optional limit) { - this.used = used; - this.limit = limit; - } - - public long getUsed() { - return used; - } - - public Optional getLimit() { - return limit; - } - - @JsonIgnore - public Quota asSizeQuota() { - return Quota.builder() - .used(QuotaSizeUsage.size(used)) - .computedLimit(QuotaSizeLimit.size(limit)) - .build(); - } - - @JsonIgnore - public Quota asCountQuota() { - return Quota.builder() - .used(QuotaCountUsage.count(used)) - .computedLimit(QuotaCountLimit.count(limit)) - .build(); - } -} diff --git a/mailbox/plugin/quota-mailing-cassandra/src/main/java/org/apache/james/mailbox/quota/cassandra/dto/QuotaEventDTOModules.java b/mailbox/plugin/quota-mailing-cassandra/src/main/java/org/apache/james/mailbox/quota/cassandra/dto/QuotaEventDTOModules.java deleted file mode 100644 index 1295411bf6b..00000000000 --- a/mailbox/plugin/quota-mailing-cassandra/src/main/java/org/apache/james/mailbox/quota/cassandra/dto/QuotaEventDTOModules.java +++ /dev/null @@ -1,36 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.quota.cassandra.dto; - -import org.apache.james.eventsourcing.eventstore.dto.EventDTOModule; -import org.apache.james.mailbox.quota.mailing.events.QuotaThresholdChangedEvent; - -public interface QuotaEventDTOModules { - - EventDTOModule QUOTA_THRESHOLD_CHANGE = - EventDTOModule - .forEvent(QuotaThresholdChangedEvent.class) - .convertToDTO(QuotaThresholdChangedEventDTO.class) - .toDomainObjectConverter(QuotaThresholdChangedEventDTO::toEvent) - .toDTOConverter(QuotaThresholdChangedEventDTO::from) - .typeName("quota-threshold-change") - .withFactory(EventDTOModule::new); - -} diff --git a/mailbox/plugin/quota-mailing-cassandra/src/main/java/org/apache/james/mailbox/quota/cassandra/dto/QuotaThresholdChangedEventDTO.java b/mailbox/plugin/quota-mailing-cassandra/src/main/java/org/apache/james/mailbox/quota/cassandra/dto/QuotaThresholdChangedEventDTO.java deleted file mode 100644 index 829feda3ae6..00000000000 --- a/mailbox/plugin/quota-mailing-cassandra/src/main/java/org/apache/james/mailbox/quota/cassandra/dto/QuotaThresholdChangedEventDTO.java +++ /dev/null @@ -1,108 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.mailbox.quota.cassandra.dto; - -import org.apache.james.eventsourcing.EventId; -import org.apache.james.eventsourcing.eventstore.dto.EventDTO; -import org.apache.james.mailbox.quota.mailing.aggregates.UserQuotaThresholds; -import org.apache.james.mailbox.quota.mailing.events.QuotaThresholdChangedEvent; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.fasterxml.jackson.annotation.JsonProperty; - -class QuotaThresholdChangedEventDTO implements EventDTO { - - @JsonIgnore - public static QuotaThresholdChangedEventDTO from(QuotaThresholdChangedEvent event, String type) { - return new QuotaThresholdChangedEventDTO( - type, event.eventId().serialize(), - event.getAggregateId().asAggregateKey(), - QuotaDTO.from(event.getSizeQuota()), - QuotaDTO.from(event.getCountQuota()), - HistoryEvolutionDTO.toDto(event.getSizeHistoryEvolution()), - HistoryEvolutionDTO.toDto(event.getCountHistoryEvolution())); - } - - private final String type; - private final int eventId; - private final String aggregateId; - private final QuotaDTO sizeQuota; - private final QuotaDTO countQuota; - private final HistoryEvolutionDTO sizeEvolution; - private final HistoryEvolutionDTO countEvolution; - - @JsonCreator - private QuotaThresholdChangedEventDTO( - @JsonProperty("type") String type, - @JsonProperty("eventId") int eventId, - @JsonProperty("aggregateId") String aggregateId, - @JsonProperty("sizeQuota") QuotaDTO sizeQuota, - @JsonProperty("countQuota") QuotaDTO countQuota, - @JsonProperty("sizeEvolution") HistoryEvolutionDTO sizeEvolution, - @JsonProperty("countEvolution") HistoryEvolutionDTO countEvolution) { - this.type = type; - this.eventId = eventId; - this.aggregateId = aggregateId; - this.sizeQuota = sizeQuota; - this.countQuota = countQuota; - this.sizeEvolution = sizeEvolution; - this.countEvolution = countEvolution; - } - - public String getType() { - return type; - } - - public long getEventId() { - return eventId; - } - - public String getAggregateId() { - return aggregateId; - } - - public QuotaDTO getSizeQuota() { - return sizeQuota; - } - - public QuotaDTO getCountQuota() { - return countQuota; - } - - public HistoryEvolutionDTO getSizeEvolution() { - return sizeEvolution; - } - - public HistoryEvolutionDTO getCountEvolution() { - return countEvolution; - } - - @JsonIgnore - public QuotaThresholdChangedEvent toEvent() { - return new QuotaThresholdChangedEvent( - EventId.fromSerialized(eventId), - sizeEvolution.toHistoryEvolution(), - countEvolution.toHistoryEvolution(), - sizeQuota.asSizeQuota(), - countQuota.asCountQuota(), - UserQuotaThresholds.Id.fromKey(aggregateId)); - } -} diff --git a/mailbox/plugin/quota-mailing-cassandra/src/test/java/org/apache/james/mailbox/quota/cassandra/dto/DTOTest.java b/mailbox/plugin/quota-mailing-cassandra/src/test/java/org/apache/james/mailbox/quota/cassandra/dto/DTOTest.java index 2673f28d1a0..be60527068a 100644 --- a/mailbox/plugin/quota-mailing-cassandra/src/test/java/org/apache/james/mailbox/quota/cassandra/dto/DTOTest.java +++ b/mailbox/plugin/quota-mailing-cassandra/src/test/java/org/apache/james/mailbox/quota/cassandra/dto/DTOTest.java @@ -20,7 +20,7 @@ package org.apache.james.mailbox.quota.cassandra.dto; import static net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; -import static org.apache.james.mailbox.quota.cassandra.dto.QuotaEventDTOModules.QUOTA_THRESHOLD_CHANGE; +import static org.apache.james.mailbox.quota.mailing.events.QuotaEventDTOModules.QUOTA_THRESHOLD_CHANGE; import static org.apache.james.mailbox.quota.model.QuotaThresholdFixture._75; import static org.apache.james.mailbox.quota.model.QuotaThresholdFixture._80; import static org.assertj.core.api.Assertions.assertThat; @@ -36,7 +36,10 @@ import org.apache.james.eventsourcing.EventId; import org.apache.james.mailbox.model.Quota; import org.apache.james.mailbox.quota.mailing.aggregates.UserQuotaThresholds; +import org.apache.james.mailbox.quota.mailing.events.HistoryEvolutionDTO; +import org.apache.james.mailbox.quota.mailing.events.QuotaDTO; import org.apache.james.mailbox.quota.mailing.events.QuotaThresholdChangedEvent; +import org.apache.james.mailbox.quota.mailing.events.QuotaThresholdChangedEventDTO; import org.apache.james.mailbox.quota.model.HistoryEvolution; import org.apache.james.mailbox.quota.model.QuotaThresholdChange; import org.apache.james.util.ClassLoaderUtils; diff --git a/mailbox/plugin/quota-mailing-cassandra/src/test/java/org/apache/james/mailbox/quota/cassandra/listeners/CassandraQuotaMailingListenersIntegrationTest.java b/mailbox/plugin/quota-mailing-cassandra/src/test/java/org/apache/james/mailbox/quota/cassandra/listeners/CassandraQuotaMailingListenersIntegrationTest.java index 3c9b8a4e217..0464f95b75c 100644 --- a/mailbox/plugin/quota-mailing-cassandra/src/test/java/org/apache/james/mailbox/quota/cassandra/listeners/CassandraQuotaMailingListenersIntegrationTest.java +++ b/mailbox/plugin/quota-mailing-cassandra/src/test/java/org/apache/james/mailbox/quota/cassandra/listeners/CassandraQuotaMailingListenersIntegrationTest.java @@ -21,7 +21,7 @@ import org.apache.james.eventsourcing.eventstore.JsonEventSerializer; import org.apache.james.eventsourcing.eventstore.cassandra.CassandraEventStoreExtension; -import org.apache.james.mailbox.quota.cassandra.dto.QuotaEventDTOModules; +import org.apache.james.mailbox.quota.mailing.events.QuotaEventDTOModules; import org.apache.james.mailbox.quota.mailing.listeners.QuotaThresholdMailingIntegrationTest; import org.junit.jupiter.api.extension.RegisterExtension; diff --git a/server/container/guice/cassandra/src/main/java/org/apache/james/modules/mailbox/CassandraQuotaMailingModule.java b/server/container/guice/cassandra/src/main/java/org/apache/james/modules/mailbox/CassandraQuotaMailingModule.java index fd9145772f8..98d15767083 100644 --- a/server/container/guice/cassandra/src/main/java/org/apache/james/modules/mailbox/CassandraQuotaMailingModule.java +++ b/server/container/guice/cassandra/src/main/java/org/apache/james/modules/mailbox/CassandraQuotaMailingModule.java @@ -22,7 +22,7 @@ import org.apache.james.eventsourcing.Event; import org.apache.james.eventsourcing.eventstore.dto.EventDTO; import org.apache.james.eventsourcing.eventstore.dto.EventDTOModule; -import org.apache.james.mailbox.quota.cassandra.dto.QuotaEventDTOModules; +import org.apache.james.mailbox.quota.mailing.events.QuotaEventDTOModules; import com.google.inject.AbstractModule; import com.google.inject.TypeLiteral; From 18f212f4ee3c66f90df1662751fafa0a20a95770 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 27 Mar 2024 18:00:51 +0700 Subject: [PATCH 274/341] JAMES-2586 Postgres - Binding QuotaMailing module for postgres app Tung Tran 10 minutes ago --- .../main/java/org/apache/james/PostgresJamesServerMain.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index 2ea342ecdf0..452742958ec 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -44,6 +44,7 @@ import org.apache.james.modules.mailbox.PostgresDeletedMessageVaultModule; import org.apache.james.modules.mailbox.PostgresMailboxModule; import org.apache.james.modules.mailbox.TikaMailboxModule; +import org.apache.james.modules.plugins.QuotaMailingModule; import org.apache.james.modules.protocols.IMAPServerModule; import org.apache.james.modules.protocols.JMAPServerModule; import org.apache.james.modules.protocols.JmapEventBusModule; @@ -125,8 +126,10 @@ public class PostgresJamesServerMain implements JamesServerMain { new JmapEventBusModule(), new JMAPServerModule()); + public static final Module PLUGINS = new QuotaMailingModule(); + private static final Module POSTGRES_MODULE_AGGREGATE = Modules.combine( - new MailetProcessingModule(), POSTGRES_SERVER_MODULE, PROTOCOLS, JMAP); + new MailetProcessingModule(), POSTGRES_SERVER_MODULE, PROTOCOLS, JMAP, PLUGINS); public static void main(String[] args) throws Exception { ExtraProperties.initialize(); From 3d93a9dc57e727b801586db2f081962899970937 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 27 Mar 2024 17:10:47 +0700 Subject: [PATCH 275/341] JAMES-2586 Postgres - Guice binding EventDTO for DLP Configuration --- .../data/PostgresDLPConfigurationStoreModule.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDLPConfigurationStoreModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDLPConfigurationStoreModule.java index 61c436c57e1..f5a765b41f7 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDLPConfigurationStoreModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDLPConfigurationStoreModule.java @@ -21,9 +21,15 @@ import org.apache.james.dlp.api.DLPConfigurationStore; import org.apache.james.dlp.eventsourcing.EventSourcingDLPConfigurationStore; +import org.apache.james.dlp.eventsourcing.cassandra.DLPConfigurationModules; +import org.apache.james.eventsourcing.Event; +import org.apache.james.eventsourcing.eventstore.dto.EventDTO; +import org.apache.james.eventsourcing.eventstore.dto.EventDTOModule; import com.google.inject.AbstractModule; import com.google.inject.Scopes; +import com.google.inject.TypeLiteral; +import com.google.inject.multibindings.Multibinder; public class PostgresDLPConfigurationStoreModule extends AbstractModule { @@ -31,5 +37,8 @@ public class PostgresDLPConfigurationStoreModule extends AbstractModule { protected void configure() { bind(EventSourcingDLPConfigurationStore.class).in(Scopes.SINGLETON); bind(DLPConfigurationStore.class).to(EventSourcingDLPConfigurationStore.class); + Multibinder> eventDTOModuleBinder = Multibinder.newSetBinder(binder(), new TypeLiteral<>() {}); + eventDTOModuleBinder.addBinding().toInstance(DLPConfigurationModules.DLP_CONFIGURATION_STORE); + eventDTOModuleBinder.addBinding().toInstance(DLPConfigurationModules.DLP_CONFIGURATION_CLEAR); } } From f80b741fbcacaf457f4faa89e00ebf54d2c02897 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 27 Mar 2024 18:47:14 +0700 Subject: [PATCH 276/341] JAMES-2586 Postgres - Guice binding EventDTO for FilteringRuleSetDefine --- .../main/java/org/apache/james/PostgresJmapModule.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java index 566b91e3747..2d85fd2b8e5 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJmapModule.java @@ -20,10 +20,14 @@ package org.apache.james; import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.eventsourcing.Event; +import org.apache.james.eventsourcing.eventstore.dto.EventDTO; +import org.apache.james.eventsourcing.eventstore.dto.EventDTOModule; import org.apache.james.jmap.api.change.EmailChangeRepository; import org.apache.james.jmap.api.change.Limit; import org.apache.james.jmap.api.change.MailboxChangeRepository; import org.apache.james.jmap.api.change.State; +import org.apache.james.jmap.api.filtering.FilteringRuleSetDefineDTOModules; import org.apache.james.jmap.api.pushsubscription.PushSubscriptionRepository; import org.apache.james.jmap.api.upload.UploadUsageRepository; import org.apache.james.jmap.postgres.PostgresDataJMapAggregateModule; @@ -41,6 +45,7 @@ import com.google.inject.AbstractModule; import com.google.inject.Scopes; +import com.google.inject.TypeLiteral; import com.google.inject.multibindings.Multibinder; import com.google.inject.name.Names; @@ -71,5 +76,9 @@ protected void configure() { bind(State.Factory.class).to(PostgresStateFactory.class); bind(PushSubscriptionRepository.class).to(PostgresPushSubscriptionRepository.class); + + Multibinder> eventDTOModuleBinder = Multibinder.newSetBinder(binder(), new TypeLiteral<>() {}); + eventDTOModuleBinder.addBinding().toInstance(FilteringRuleSetDefineDTOModules.FILTERING_RULE_SET_DEFINED); + eventDTOModuleBinder.addBinding().toInstance(FilteringRuleSetDefineDTOModules.FILTERING_INCREMENT); } } From a877a3b00ea88b6c08994e22ca46467c06bfb9a3 Mon Sep 17 00:00:00 2001 From: vttran Date: Mon, 1 Apr 2024 10:19:46 +0700 Subject: [PATCH 277/341] JAMES-2586 - [Revert] Optimize query increase/decrease for Quota Current Value Revert commit: 721f9c0bfc80974a798b0c45df1498fe0bd0fb95 Reason: when provision messages mailbox, we have a lot of error related to: `quota_current_value_primary_key` The old way helps us avoid that --- .../quota/PostgresQuotaCurrentValueDAO.java | 54 ++++++++++--------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java index 7f6f4de0a36..9205b91c265 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java @@ -22,6 +22,7 @@ import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaCurrentValueTable.COMPONENT; import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaCurrentValueTable.CURRENT_VALUE; import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaCurrentValueTable.IDENTIFIER; +import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaCurrentValueTable.PRIMARY_KEY_CONSTRAINT_NAME; import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaCurrentValueTable.TABLE_NAME; import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaCurrentValueTable.TYPE; import static org.apache.james.backends.postgres.utils.PostgresExecutor.DEFAULT_INJECT; @@ -35,13 +36,15 @@ import org.apache.james.core.quota.QuotaComponent; import org.apache.james.core.quota.QuotaCurrentValue; import org.apache.james.core.quota.QuotaType; -import org.jooq.Field; import org.jooq.Record; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; public class PostgresQuotaCurrentValueDAO { + private static final Logger LOGGER = LoggerFactory.getLogger(PostgresQuotaCurrentValueDAO.class); private static final boolean IS_INCREASE = true; private final PostgresExecutor postgresExecutor; @@ -52,19 +55,19 @@ public PostgresQuotaCurrentValueDAO(@Named(DEFAULT_INJECT) PostgresExecutor post } public Mono increase(QuotaCurrentValue.Key quotaKey, long amount) { - return updateCurrentValue(quotaKey, amount, IS_INCREASE) - .switchIfEmpty(Mono.defer(() -> insert(quotaKey, amount, IS_INCREASE))) - .then(); - } - - public Mono updateCurrentValue(QuotaCurrentValue.Key quotaKey, long amount, boolean isIncrease) { - return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.update(TABLE_NAME) - .set(CURRENT_VALUE, getCurrentValueOperator(isIncrease, amount)) - .where(IDENTIFIER.eq(quotaKey.getIdentifier()), - COMPONENT.eq(quotaKey.getQuotaComponent().getValue()), - TYPE.eq(quotaKey.getQuotaType().getValue())) - .returning(CURRENT_VALUE))) - .map(record -> record.get(CURRENT_VALUE)); + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME) + .set(IDENTIFIER, quotaKey.getIdentifier()) + .set(COMPONENT, quotaKey.getQuotaComponent().getValue()) + .set(TYPE, quotaKey.getQuotaType().getValue()) + .set(CURRENT_VALUE, amount) + .onConflictOnConstraint(PRIMARY_KEY_CONSTRAINT_NAME) + .doUpdate() + .set(CURRENT_VALUE, CURRENT_VALUE.plus(amount)))) + .onErrorResume(ex -> { + LOGGER.warn("Failure when increasing {} {} quota for {}. Quota current value is thus not updated and needs re-computation", + quotaKey.getQuotaComponent().getValue(), quotaKey.getQuotaType().getValue(), quotaKey.getIdentifier(), ex); + return Mono.empty(); + }); } public Mono upsert(QuotaCurrentValue.Key quotaKey, long newCurrentValue) { @@ -82,13 +85,6 @@ public Mono update(QuotaCurrentValue.Key quotaKey, long newCurrentValue) { .map(record -> record.get(CURRENT_VALUE)); } - private Field getCurrentValueOperator(boolean isIncrease, long amount) { - if (isIncrease) { - return CURRENT_VALUE.plus(amount); - } - return CURRENT_VALUE.minus(amount); - } - public Mono insert(QuotaCurrentValue.Key quotaKey, long amount, boolean isIncrease) { return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME) .set(IDENTIFIER, quotaKey.getIdentifier()) @@ -107,9 +103,19 @@ private Long newCurrentValue(long amount, boolean isIncrease) { } public Mono decrease(QuotaCurrentValue.Key quotaKey, long amount) { - return updateCurrentValue(quotaKey, amount, !IS_INCREASE) - .switchIfEmpty(Mono.defer(() -> insert(quotaKey, amount, !IS_INCREASE))) - .then(); + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME) + .set(IDENTIFIER, quotaKey.getIdentifier()) + .set(COMPONENT, quotaKey.getQuotaComponent().getValue()) + .set(TYPE, quotaKey.getQuotaType().getValue()) + .set(CURRENT_VALUE, -amount) + .onConflictOnConstraint(PRIMARY_KEY_CONSTRAINT_NAME) + .doUpdate() + .set(CURRENT_VALUE, CURRENT_VALUE.minus(amount)))) + .onErrorResume(ex -> { + LOGGER.warn("Failure when decreasing {} {} quota for {}. Quota current value is thus not updated and needs re-computation", + quotaKey.getQuotaComponent().getValue(), quotaKey.getQuotaType().getValue(), quotaKey.getIdentifier(), ex); + return Mono.empty(); + }); } public Mono getQuotaCurrentValue(QuotaCurrentValue.Key quotaKey) { From 6b8378a579cb745ddb5b78c12a354cf0fdc588a9 Mon Sep 17 00:00:00 2001 From: vttran Date: Mon, 1 Apr 2024 10:20:02 +0700 Subject: [PATCH 278/341] Revert "Provision Current Quota when MailboxAdded event" This reverts commit 3bb549a593a1c197063a53c6d48e810ed195c298. Reason: the provision not works. The `getCurrentQuota` alway return the "ZERO" value (not empty) --- .../quota/ListeningCurrentQuotaUpdater.java | 22 +---------- .../ListeningCurrentQuotaUpdaterTest.java | 38 ------------------- 2 files changed, 1 insertion(+), 59 deletions(-) diff --git a/mailbox/store/src/main/java/org/apache/james/mailbox/store/quota/ListeningCurrentQuotaUpdater.java b/mailbox/store/src/main/java/org/apache/james/mailbox/store/quota/ListeningCurrentQuotaUpdater.java index 8d3aa1fb09c..1790db0321f 100644 --- a/mailbox/store/src/main/java/org/apache/james/mailbox/store/quota/ListeningCurrentQuotaUpdater.java +++ b/mailbox/store/src/main/java/org/apache/james/mailbox/store/quota/ListeningCurrentQuotaUpdater.java @@ -30,10 +30,8 @@ import org.apache.james.events.EventListener; import org.apache.james.events.Group; import org.apache.james.events.RegistrationKey; -import org.apache.james.mailbox.events.MailboxEvents; import org.apache.james.mailbox.events.MailboxEvents.Added; import org.apache.james.mailbox.events.MailboxEvents.Expunged; -import org.apache.james.mailbox.events.MailboxEvents.MailboxAdded; import org.apache.james.mailbox.events.MailboxEvents.MailboxDeletion; import org.apache.james.mailbox.events.MailboxEvents.MetaDataHoldingEvent; import org.apache.james.mailbox.model.QuotaOperation; @@ -76,10 +74,7 @@ public Group getDefaultGroup() { @Override public boolean isHandling(Event event) { - return event instanceof Added - || event instanceof Expunged - || event instanceof MailboxDeletion - || event instanceof MailboxAdded; + return event instanceof Added || event instanceof Expunged || event instanceof MailboxDeletion; } @Override @@ -95,9 +90,6 @@ public Publisher reactiveEvent(Event event) { } else if (event instanceof MailboxDeletion) { MailboxDeletion mailboxDeletionEvent = (MailboxDeletion) event; return handleMailboxDeletionEvent(mailboxDeletionEvent); - } else if (event instanceof MailboxAdded) { - MailboxEvents.MailboxAdded mailboxAdded = (MailboxEvents.MailboxAdded) event; - return handleMailboxAddedEvent(mailboxAdded); } return Mono.empty(); } @@ -157,16 +149,4 @@ private Mono handleMailboxDeletionEvent(MailboxDeletion mailboxDeletionEve return Mono.empty(); } - private Mono handleMailboxAddedEvent(MailboxAdded mailboxAdded) { - return provisionCurrentQuota(mailboxAdded); - } - - private Mono provisionCurrentQuota(MailboxAdded mailboxAdded) { - return Mono.from(quotaRootResolver.getQuotaRootReactive(mailboxAdded.getMailboxPath())) - .flatMap(quotaRoot -> Mono.from(currentQuotaManager.getCurrentQuotas(quotaRoot)) - .map(any -> quotaRoot) - .switchIfEmpty(Mono.defer(() -> Mono.from(currentQuotaManager.setCurrentQuotas(new QuotaOperation(quotaRoot, QuotaCountUsage.count(0), QuotaSizeUsage.ZERO))) - .thenReturn(quotaRoot)))) - .then(); - } } \ No newline at end of file diff --git a/mailbox/store/src/test/java/org/apache/james/mailbox/store/quota/ListeningCurrentQuotaUpdaterTest.java b/mailbox/store/src/test/java/org/apache/james/mailbox/store/quota/ListeningCurrentQuotaUpdaterTest.java index b01935faa1e..bd0c937944f 100644 --- a/mailbox/store/src/test/java/org/apache/james/mailbox/store/quota/ListeningCurrentQuotaUpdaterTest.java +++ b/mailbox/store/src/test/java/org/apache/james/mailbox/store/quota/ListeningCurrentQuotaUpdaterTest.java @@ -42,11 +42,9 @@ import org.apache.james.events.Group; import org.apache.james.mailbox.MessageUid; import org.apache.james.mailbox.ModSeq; -import org.apache.james.mailbox.events.MailboxEvents; import org.apache.james.mailbox.events.MailboxEvents.Added; import org.apache.james.mailbox.events.MailboxEvents.Expunged; import org.apache.james.mailbox.events.MailboxEvents.MailboxDeletion; -import org.apache.james.mailbox.model.CurrentQuotas; import org.apache.james.mailbox.model.MailboxId; import org.apache.james.mailbox.model.MailboxPath; import org.apache.james.mailbox.model.MessageMetaData; @@ -196,40 +194,4 @@ void mailboxDeletionEventShouldDoNothingWhenEmptyMailbox() throws Exception { verifyNoMoreInteractions(mockedCurrentQuotaManager); } - - @Test - void mailboxAddEventShouldProvisionCurrentQuota() throws Exception { - QuotaOperation operation = new QuotaOperation(QUOTA_ROOT, QuotaCountUsage.count(0), QuotaSizeUsage.size(0)); - - MailboxEvents.MailboxAdded added; - added = mock(MailboxEvents.MailboxAdded.class); - - when(added.getMailboxId()).thenReturn(MAILBOX_ID); - when(added.getMailboxPath()).thenReturn(MAILBOX_PATH); - when(added.getUsername()).thenReturn(USERNAME_BENWA); - when(mockedQuotaRootResolver.getQuotaRootReactive(eq(MAILBOX_PATH))) - .thenReturn(Mono.just(QUOTA_ROOT)); - when(mockedCurrentQuotaManager.getCurrentQuotas(QUOTA_ROOT)).thenAnswer(any -> Mono.empty()); - when(mockedCurrentQuotaManager.setCurrentQuotas(operation)).thenAnswer(any -> Mono.empty()); - - testee.event(added); - - verify(mockedCurrentQuotaManager).setCurrentQuotas(operation); - } - - @Test - void mailboxAddEventShouldNotProvisionWhenAlreadyExist() throws Exception { - MailboxEvents.MailboxAdded added = mock(MailboxEvents.MailboxAdded.class); - when(added.getMailboxId()).thenReturn(MAILBOX_ID); - when(added.getMailboxPath()).thenReturn(MAILBOX_PATH); - when(added.getUsername()).thenReturn(USERNAME_BENWA); - when(mockedQuotaRootResolver.getQuotaRootReactive(eq(MAILBOX_PATH))) - .thenReturn(Mono.just(QUOTA_ROOT)); - when(mockedCurrentQuotaManager.getCurrentQuotas(QUOTA_ROOT)) - .thenAnswer(any -> Mono.just(CurrentQuotas.from(QUOTA))); - - testee.event(added); - - verify(mockedCurrentQuotaManager, never()).setCurrentQuotas(any()); - } } From cb502385d44d90f3ddabe01e307b8b315b323fe4 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Thu, 28 Mar 2024 13:50:55 +0700 Subject: [PATCH 279/341] JAMES-2586 Introduce module task-postgres --- Jenkinsfile | 1 + pom.xml | 11 +++ server/pom.xml | 1 + server/task/task-postgres/pom.xml | 128 ++++++++++++++++++++++++++++++ 4 files changed, 141 insertions(+) create mode 100644 server/task/task-postgres/pom.xml diff --git a/Jenkinsfile b/Jenkinsfile index 56d954fc2ac..1299da2b917 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -48,6 +48,7 @@ pipeline { 'server/apps/postgres-app,' + 'server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests,' + 'server/protocols/webadmin-integration-test/postgres-webadmin-integration-test,' + + 'server/task/task-postgres,' + 'mpt/impl/imap-mailbox/postgres,' + 'event-bus/postgres,' + 'mailbox/plugin/deleted-messages-vault-postgres' diff --git a/pom.xml b/pom.xml index e9fb8321622..e06397d02db 100644 --- a/pom.xml +++ b/pom.xml @@ -1926,6 +1926,17 @@ ${project.version} test-jar + + ${james.groupId} + james-server-task-postgres + ${project.version} + + + ${james.groupId} + james-server-task-postgres + ${project.version} + test-jar + ${james.groupId} james-server-testing diff --git a/server/pom.xml b/server/pom.xml index f8aeb96de43..81d7cf120f1 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -123,6 +123,7 @@ task/task-distributed task/task-json task/task-memory + task/task-postgres testing diff --git a/server/task/task-postgres/pom.xml b/server/task/task-postgres/pom.xml new file mode 100644 index 00000000000..3cf839f8480 --- /dev/null +++ b/server/task/task-postgres/pom.xml @@ -0,0 +1,128 @@ + + + 4.0.0 + + org.apache.james + james-server + 3.9.0-SNAPSHOT + ../../pom.xml + + + james-server-task-postgres + Apache James :: Server :: Task :: PostgreSQL + Distributed task manager leveraging PostgreSQL + + + + ${james.groupId} + apache-james-backends-postgres + + + ${james.groupId} + apache-james-backends-postgres + test-jar + test + + + ${james.groupId} + james-json + + + ${james.groupId} + james-json + test-jar + test + + + ${james.groupId} + james-server-lifecycle-api + + + ${james.groupId} + james-server-task-api + test-jar + test + + + ${james.groupId} + james-server-task-json + + + ${james.groupId} + james-server-task-json + test-jar + test + + + ${james.groupId} + james-server-task-memory + + + ${james.groupId} + james-server-task-memory + test-jar + test + + + ${james.groupId} + james-server-testing + test + + + ${james.groupId} + metrics-tests + test + + + ${james.groupId} + testing-base + test + + + com.fasterxml.jackson.core + jackson-databind + + + commons-codec + commons-codec + test + + + net.javacrumbs.json-unit + json-unit-assertj + test + + + org.awaitility + awaitility + test + + + org.mockito + mockito-core + test + + + org.scala-lang + scala-library + + + org.scala-lang.modules + scala-java8-compat_${scala.base} + + + org.testcontainers + postgresql + test + + + + + + + net.alchim31.maven + scala-maven-plugin + + + + From d397aeed8b26510d18a77371eecf9c9be16e8556 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Fri, 29 Mar 2024 13:05:58 +0700 Subject: [PATCH 280/341] JAMES-2586 Implement PostgresTaskExecutionDetailsProjection --- .../backends/postgres/PostgresCommons.java | 4 + server/task/task-postgres/pom.xml | 6 + ...stgresTaskExecutionDetailsProjection.scala | 54 +++++ ...resTaskExecutionDetailsProjectionDAO.scala | 112 ++++++++++ ...TaskExecutionDetailsProjectionModule.scala | 72 +++++++ ...TaskExecutionDetailsProjectionDAOTest.java | 202 ++++++++++++++++++ ...resTaskExecutionDetailsProjectionTest.java | 52 +++++ 7 files changed, 502 insertions(+) create mode 100644 server/task/task-postgres/src/main/scala/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjection.scala create mode 100644 server/task/task-postgres/src/main/scala/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionDAO.scala create mode 100644 server/task/task-postgres/src/main/scala/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionModule.scala create mode 100644 server/task/task-postgres/src/test/java/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionDAOTest.java create mode 100644 server/task/task-postgres/src/test/java/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionTest.java diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresCommons.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresCommons.java index 5557b591b90..88201ac066c 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresCommons.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresCommons.java @@ -64,6 +64,10 @@ public static Field tableField(Table table, Field field) { .map(value -> LocalDateTime.ofInstant(value.toInstant(), ZoneOffset.UTC)) .orElse(null); + public static final Function ZONED_DATE_TIME_TO_LOCAL_DATE_TIME = date -> Optional.ofNullable(date) + .map(value -> value.withZoneSameInstant(ZoneOffset.UTC).toLocalDateTime()) + .orElse(null); + public static final Function INSTANT_TO_LOCAL_DATE_TIME = instant -> Optional.ofNullable(instant) .map(value -> LocalDateTime.ofInstant(instant, ZoneOffset.UTC)) .orElse(null); diff --git a/server/task/task-postgres/pom.xml b/server/task/task-postgres/pom.xml index 3cf839f8480..35160283f7d 100644 --- a/server/task/task-postgres/pom.xml +++ b/server/task/task-postgres/pom.xml @@ -33,6 +33,12 @@ test-jar test + + ${james.groupId} + james-server-guice-common + test-jar + test + ${james.groupId} james-server-lifecycle-api diff --git a/server/task/task-postgres/src/main/scala/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjection.scala b/server/task/task-postgres/src/main/scala/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjection.scala new file mode 100644 index 00000000000..999ea770d44 --- /dev/null +++ b/server/task/task-postgres/src/main/scala/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjection.scala @@ -0,0 +1,54 @@ + /*************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.task.eventsourcing.postgres + +import java.time.Instant + +import javax.inject.Inject +import org.apache.james.task.eventsourcing.TaskExecutionDetailsProjection +import org.apache.james.task.{TaskExecutionDetails, TaskId} +import org.reactivestreams.Publisher + +import scala.compat.java8.OptionConverters._ +import scala.jdk.CollectionConverters._ + +class PostgresTaskExecutionDetailsProjection @Inject()(taskExecutionDetailsProjectionDAO: PostgresTaskExecutionDetailsProjectionDAO) + extends TaskExecutionDetailsProjection { + + override def load(taskId: TaskId): Option[TaskExecutionDetails] = + taskExecutionDetailsProjectionDAO.readDetails(taskId).blockOptional().asScala + + override def list: List[TaskExecutionDetails] = + taskExecutionDetailsProjectionDAO.listDetails().collectList().block().asScala.toList + + override def update(details: TaskExecutionDetails): Unit = + taskExecutionDetailsProjectionDAO.saveDetails(details).block() + + override def loadReactive(taskId: TaskId): Publisher[TaskExecutionDetails] = + taskExecutionDetailsProjectionDAO.readDetails(taskId) + + override def listReactive(): Publisher[TaskExecutionDetails] = taskExecutionDetailsProjectionDAO.listDetails() + + override def updateReactive(details: TaskExecutionDetails): Publisher[Void] = taskExecutionDetailsProjectionDAO.saveDetails(details) + + override def listDetailsByBeforeDate(beforeDate: Instant): Publisher[TaskExecutionDetails] = taskExecutionDetailsProjectionDAO.listDetailsByBeforeDate(beforeDate) + + override def remove(taskExecutionDetails: TaskExecutionDetails): Publisher[Void] = taskExecutionDetailsProjectionDAO.remove(taskExecutionDetails) +} diff --git a/server/task/task-postgres/src/main/scala/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionDAO.scala b/server/task/task-postgres/src/main/scala/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionDAO.scala new file mode 100644 index 00000000000..5ed08bc536d --- /dev/null +++ b/server/task/task-postgres/src/main/scala/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionDAO.scala @@ -0,0 +1,112 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.task.eventsourcing.postgres + +import java.time.{Instant, LocalDateTime} +import java.util.Optional + +import com.google.common.collect.ImmutableMap +import javax.inject.Inject +import org.apache.james.backends.postgres.PostgresCommons.{LOCAL_DATE_TIME_ZONED_DATE_TIME_FUNCTION, ZONED_DATE_TIME_TO_LOCAL_DATE_TIME, INSTANT_TO_LOCAL_DATE_TIME} +import org.apache.james.backends.postgres.utils.PostgresExecutor +import org.apache.james.server.task.json.JsonTaskAdditionalInformationSerializer +import org.apache.james.task._ +import org.apache.james.task.eventsourcing.postgres.PostgresTaskExecutionDetailsProjectionModule._ +import org.apache.james.util.ReactorUtils +import org.jooq.JSONB.jsonb +import org.jooq.{InsertQuery, Record} +import reactor.core.publisher.{Flux, Mono} + +class PostgresTaskExecutionDetailsProjectionDAO @Inject()(postgresExecutor: PostgresExecutor, jsonTaskAdditionalInformationSerializer: JsonTaskAdditionalInformationSerializer) { + + def saveDetails(details: TaskExecutionDetails): Mono[Void] = + Mono.from(serializeAdditionalInformation(details) + .flatMap(serializedAdditionalInformation => postgresExecutor.executeVoid(dsl => { + val insertValues: ImmutableMap[Any, Any] = toInsertValues(details, serializedAdditionalInformation) + + val insertStatement: InsertQuery[Record] = dsl.insertQuery(TABLE_NAME) + insertStatement.addValue(TASK_ID, details.getTaskId.getValue) + insertStatement.addValues(insertValues) + insertStatement.onConflict(TASK_ID) + insertStatement.onDuplicateKeyUpdate(true) + insertStatement.addValuesForUpdate(insertValues) + + Mono.from(insertStatement) + }))) + + private def toInsertValues(details: TaskExecutionDetails, serializedAdditionalInformation: Optional[String]): ImmutableMap[Any, Any] = { + val builder: ImmutableMap.Builder[Any, Any] = ImmutableMap.builder() + builder.put(TYPE, details.getType.asString()) + builder.put(STATUS, details.getStatus.getValue) + builder.put(SUBMITTED_DATE, ZONED_DATE_TIME_TO_LOCAL_DATE_TIME.apply(details.getSubmittedDate)) + builder.put(SUBMITTED_NODE, details.getSubmittedNode.asString) + details.getStartedDate.ifPresent(startedDate => builder.put(STARTED_DATE, ZONED_DATE_TIME_TO_LOCAL_DATE_TIME.apply(startedDate))) + details.getRanNode.ifPresent(hostname => builder.put(RAN_NODE, hostname.asString)) + details.getCompletedDate.ifPresent(completedDate => builder.put(COMPLETED_DATE, ZONED_DATE_TIME_TO_LOCAL_DATE_TIME.apply(completedDate))) + details.getCanceledDate.ifPresent(canceledDate => builder.put(CANCELED_DATE, ZONED_DATE_TIME_TO_LOCAL_DATE_TIME.apply(canceledDate))) + details.getCancelRequestedNode.ifPresent(hostname => builder.put(CANCEL_REQUESTED_NODE, hostname.asString)) + details.getFailedDate.ifPresent(failedDate => builder.put(FAILED_DATE, ZONED_DATE_TIME_TO_LOCAL_DATE_TIME.apply(failedDate))) + serializedAdditionalInformation.ifPresent(info => builder.put(ADDITIONAL_INFORMATION, jsonb(info))) + builder.build() + } + + private def serializeAdditionalInformation(details: TaskExecutionDetails): Mono[Optional[String]] = Mono.fromCallable(() => details + .getAdditionalInformation + .map(jsonTaskAdditionalInformationSerializer.serialize(_))) + .cast(classOf[Optional[String]]) + .subscribeOn(ReactorUtils.BLOCKING_CALL_WRAPPER) + + def readDetails(taskId: TaskId): Mono[TaskExecutionDetails] = + postgresExecutor.executeRow(dsl => Mono.from(dsl.selectFrom(TABLE_NAME) + .where(TASK_ID.eq(taskId.getValue)))) + .map(toTaskExecutionDetails) + + def listDetails(): Flux[TaskExecutionDetails] = + postgresExecutor.executeRows(dsl => Flux.from(dsl.selectFrom(TABLE_NAME))) + .map(toTaskExecutionDetails) + + def listDetailsByBeforeDate(beforeDate: Instant): Flux[TaskExecutionDetails] = + postgresExecutor.executeRows(dsl => Flux.from(dsl.selectFrom(TABLE_NAME) + .where(SUBMITTED_DATE.lt(INSTANT_TO_LOCAL_DATE_TIME.apply(beforeDate))))) + .map(toTaskExecutionDetails) + + def remove(details: TaskExecutionDetails): Mono[Void] = + postgresExecutor.executeVoid(dsl => Mono.from(dsl.deleteFrom(TABLE_NAME) + .where(TASK_ID.eq(details.getTaskId.getValue)))) + + private def toTaskExecutionDetails(record: Record): TaskExecutionDetails = + new TaskExecutionDetails( + taskId = TaskId.fromUUID(record.get(TASK_ID)), + `type` = TaskType.of(record.get(TYPE)), + status = TaskManager.Status.fromString(record.get(STATUS)), + submittedDate = LOCAL_DATE_TIME_ZONED_DATE_TIME_FUNCTION.apply(record.get(SUBMITTED_DATE, classOf[LocalDateTime])), + submittedNode = Hostname(record.get(SUBMITTED_NODE)), + startedDate = Optional.ofNullable(LOCAL_DATE_TIME_ZONED_DATE_TIME_FUNCTION.apply(record.get(STARTED_DATE, classOf[LocalDateTime]))), + ranNode = Optional.ofNullable(record.get(RAN_NODE)).map(Hostname(_)), + completedDate = Optional.ofNullable(LOCAL_DATE_TIME_ZONED_DATE_TIME_FUNCTION.apply(record.get(COMPLETED_DATE, classOf[LocalDateTime]))), + canceledDate = Optional.ofNullable(LOCAL_DATE_TIME_ZONED_DATE_TIME_FUNCTION.apply(record.get(CANCELED_DATE, classOf[LocalDateTime]))), + cancelRequestedNode = Optional.ofNullable(record.get(CANCEL_REQUESTED_NODE)).map(Hostname(_)), + failedDate = Optional.ofNullable(LOCAL_DATE_TIME_ZONED_DATE_TIME_FUNCTION.apply(record.get(FAILED_DATE, classOf[LocalDateTime]))), + additionalInformation = () => deserializeAdditionalInformation(record)) + + private def deserializeAdditionalInformation(record: Record): Optional[TaskExecutionDetails.AdditionalInformation] = + Optional.ofNullable(record.get(ADDITIONAL_INFORMATION)) + .map(additionalInformation => jsonTaskAdditionalInformationSerializer.deserialize(additionalInformation.data())) +} diff --git a/server/task/task-postgres/src/main/scala/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionModule.scala b/server/task/task-postgres/src/main/scala/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionModule.scala new file mode 100644 index 00000000000..21918fd8042 --- /dev/null +++ b/server/task/task-postgres/src/main/scala/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionModule.scala @@ -0,0 +1,72 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.task.eventsourcing.postgres + +import java.time.LocalDateTime +import java.util.UUID + +import org.apache.james.backends.postgres.{PostgresCommons, PostgresIndex, PostgresModule, PostgresTable} +import org.jooq.impl.{DSL, SQLDataType} +import org.jooq.{Field, JSONB, Record, Table} + +object PostgresTaskExecutionDetailsProjectionModule { + val TABLE_NAME: Table[Record] = DSL.table("task_execution_details_projection") + + val TASK_ID: Field[UUID] = DSL.field("task_id", SQLDataType.UUID.notNull) + val ADDITIONAL_INFORMATION: Field[JSONB] = DSL.field("additional_information", SQLDataType.JSONB) + val TYPE: Field[String] = DSL.field("type", SQLDataType.VARCHAR) + val STATUS: Field[String] = DSL.field("status", SQLDataType.VARCHAR) + val SUBMITTED_DATE: Field[LocalDateTime] = DSL.field("submitted_date", PostgresCommons.DataTypes.TIMESTAMP) + val SUBMITTED_NODE: Field[String] = DSL.field("submitted_node", SQLDataType.VARCHAR) + val STARTED_DATE: Field[LocalDateTime] = DSL.field("started_date", PostgresCommons.DataTypes.TIMESTAMP) + val RAN_NODE: Field[String] = DSL.field("ran_node", SQLDataType.VARCHAR) + val COMPLETED_DATE: Field[LocalDateTime] = DSL.field("completed_date", PostgresCommons.DataTypes.TIMESTAMP) + val CANCELED_DATE: Field[LocalDateTime] = DSL.field("canceled_date", PostgresCommons.DataTypes.TIMESTAMP) + val CANCEL_REQUESTED_NODE: Field[String] = DSL.field("cancel_requested_node", SQLDataType.VARCHAR) + val FAILED_DATE: Field[LocalDateTime] = DSL.field("failed_date", PostgresCommons.DataTypes.TIMESTAMP) + + private val TABLE: PostgresTable = PostgresTable.name(TABLE_NAME.getName) + .createTableStep((dsl, tableName) => dsl.createTableIfNotExists(tableName) + .column(TASK_ID) + .column(ADDITIONAL_INFORMATION) + .column(TYPE) + .column(STATUS) + .column(SUBMITTED_DATE) + .column(SUBMITTED_NODE) + .column(STARTED_DATE) + .column(RAN_NODE) + .column(COMPLETED_DATE) + .column(CANCELED_DATE) + .column(CANCEL_REQUESTED_NODE) + .column(FAILED_DATE) + .constraint(DSL.primaryKey(TASK_ID))) + .disableRowLevelSecurity + .build + + private val SUBMITTED_DATE_INDEX: PostgresIndex = PostgresIndex.name("task_execution_details_projection_submittedDate_index") + .createIndexStep((dsl, indexName) => dsl.createIndexIfNotExists(indexName) + .on(TABLE_NAME, SUBMITTED_DATE)); + + val MODULE: PostgresModule = PostgresModule + .builder + .addTable(TABLE) + .addIndex(SUBMITTED_DATE_INDEX) + .build +} diff --git a/server/task/task-postgres/src/test/java/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionDAOTest.java b/server/task/task-postgres/src/test/java/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionDAOTest.java new file mode 100644 index 00000000000..22f07fd340d --- /dev/null +++ b/server/task/task-postgres/src/test/java/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionDAOTest.java @@ -0,0 +1,202 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.task.eventsourcing.postgres; + +import static org.apache.james.task.TaskExecutionDetailsFixture.TASK_EXECUTION_DETAILS; +import static org.apache.james.task.TaskExecutionDetailsFixture.TASK_EXECUTION_DETAILS_2; +import static org.apache.james.task.TaskExecutionDetailsFixture.TASK_EXECUTION_DETAILS_UPDATED; +import static org.apache.james.task.TaskExecutionDetailsFixture.TASK_EXECUTION_DETAILS_WITH_ADDITIONAL_INFORMATION; +import static org.apache.james.task.TaskExecutionDetailsFixture.TASK_ID; +import static org.apache.james.task.TaskExecutionDetailsFixture.TASK_ID_2; +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Optional; +import java.util.stream.Stream; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.server.task.json.JsonTaskAdditionalInformationSerializer; +import org.apache.james.server.task.json.dto.MemoryReferenceWithCounterTaskAdditionalInformationDTO; +import org.apache.james.task.TaskExecutionDetails; +import org.apache.james.task.TaskExecutionDetailsFixture; +import org.apache.james.task.TaskManager; +import org.apache.james.task.TaskType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import reactor.core.publisher.Flux; + +class PostgresTaskExecutionDetailsProjectionDAOTest { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresTaskExecutionDetailsProjectionModule.MODULE()); + + private static final JsonTaskAdditionalInformationSerializer JSON_TASK_ADDITIONAL_INFORMATION_SERIALIZER = JsonTaskAdditionalInformationSerializer.of(MemoryReferenceWithCounterTaskAdditionalInformationDTO.SERIALIZATION_MODULE); + + private PostgresTaskExecutionDetailsProjectionDAO testee; + + @BeforeEach + void setUp() { + testee = new PostgresTaskExecutionDetailsProjectionDAO(postgresExtension.getPostgresExecutor(), JSON_TASK_ADDITIONAL_INFORMATION_SERIALIZER); + } + + @Test + void readDetailsShouldBeAbleToRetrieveASavedRecord() { + testee.saveDetails(TASK_EXECUTION_DETAILS()).block(); + + TaskExecutionDetails taskExecutionDetails = testee.readDetails(TASK_ID()).block(); + + assertThat(taskExecutionDetails) + .usingRecursiveComparison() + .ignoringFields("submittedDate") + .isEqualTo(TASK_EXECUTION_DETAILS()); + } + + @Test + void readDetailsShouldBeAbleToRetrieveASavedRecordWithAdditionalInformation() { + testee.saveDetails(TASK_EXECUTION_DETAILS_WITH_ADDITIONAL_INFORMATION()).block(); + + TaskExecutionDetails taskExecutionDetails = testee.readDetails(TASK_ID()).block(); + + assertThat(taskExecutionDetails) + .usingRecursiveComparison() + .ignoringFields("submittedDate") + .isEqualTo(TASK_EXECUTION_DETAILS_WITH_ADDITIONAL_INFORMATION()); + + assertThat(taskExecutionDetails.getSubmittedDate().isEqual(TASK_EXECUTION_DETAILS_WITH_ADDITIONAL_INFORMATION().getSubmittedDate())) + .isTrue(); + } + + @Test + void saveDetailsShouldUpdateRecords() { + testee.saveDetails(TASK_EXECUTION_DETAILS()).block(); + + testee.saveDetails(TASK_EXECUTION_DETAILS_UPDATED()).block(); + + TaskExecutionDetails taskExecutionDetails = testee.readDetails(TASK_ID()).block(); + + assertThat(taskExecutionDetails) + .usingRecursiveComparison() + .ignoringFields("submittedDate") + .isEqualTo(TASK_EXECUTION_DETAILS_UPDATED()); + + assertThat(taskExecutionDetails.getSubmittedDate().isEqual(TASK_EXECUTION_DETAILS_UPDATED().getSubmittedDate())) + .isTrue(); + } + + @Test + void readDetailsShouldReturnEmptyWhenNone() { + Optional taskExecutionDetails = testee.readDetails(TASK_ID()).blockOptional(); + assertThat(taskExecutionDetails).isEmpty(); + } + + @Test + void listDetailsShouldReturnEmptyWhenNone() { + Stream taskExecutionDetails = testee.listDetails().toStream(); + assertThat(taskExecutionDetails).isEmpty(); + } + + @Test + void listDetailsShouldReturnAllRecords() { + testee.saveDetails(TASK_EXECUTION_DETAILS()).block(); + testee.saveDetails(TASK_EXECUTION_DETAILS_2()).block(); + + Stream taskExecutionDetails = testee.listDetails().toStream(); + + assertThat(taskExecutionDetails) + .usingRecursiveFieldByFieldElementComparatorIgnoringFields("submittedDate") + .containsOnly(TASK_EXECUTION_DETAILS(), TASK_EXECUTION_DETAILS_2()); + } + + @Test + void listDetailsShouldReturnLastUpdatedRecords() { + testee.saveDetails(TASK_EXECUTION_DETAILS()).block(); + testee.saveDetails(TASK_EXECUTION_DETAILS_UPDATED()).block(); + + Stream taskExecutionDetails = testee.listDetails().toStream(); + assertThat(taskExecutionDetails) + .usingRecursiveFieldByFieldElementComparatorIgnoringFields("submittedDate") + .containsOnly(TASK_EXECUTION_DETAILS_UPDATED()); + } + + @Test + void listBeforeDateShouldReturnCorrectEntry() { + TaskExecutionDetails taskExecutionDetails1 = new TaskExecutionDetails(TASK_ID(), + TaskType.of("type"), + TaskManager.Status.COMPLETED, + ZonedDateTime.ofInstant(Instant.parse("2000-01-01T00:00:00Z"), ZoneId.systemDefault()), + TaskExecutionDetailsFixture.SUBMITTED_NODE(), + Optional::empty, + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty()); + + TaskExecutionDetails taskExecutionDetails2 = new TaskExecutionDetails(TASK_ID_2(), + TaskType.of("type"), + TaskManager.Status.COMPLETED, + ZonedDateTime.ofInstant(Instant.parse("2000-01-20T00:00:00Z"), ZoneId.systemDefault()), + TaskExecutionDetailsFixture.SUBMITTED_NODE(), + Optional::empty, + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty()); + + testee.saveDetails(taskExecutionDetails1).block(); + testee.saveDetails(taskExecutionDetails2).block(); + + assertThat(Flux.from(testee.listDetailsByBeforeDate(Instant.parse("2000-01-15T12:00:55Z"))).collectList().block()) + .usingRecursiveFieldByFieldElementComparatorIgnoringFields("submittedDate") + .containsOnly(taskExecutionDetails1); + } + + @Test + void removeShouldDeleteAssignEntry() { + TaskExecutionDetails taskExecutionDetails1 = new TaskExecutionDetails(TASK_ID(), + TaskType.of("type"), + TaskManager.Status.COMPLETED, + ZonedDateTime.ofInstant(Instant.parse("2000-01-01T00:00:00Z"), ZoneId.systemDefault()), + TaskExecutionDetailsFixture.SUBMITTED_NODE(), + Optional::empty, + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty()); + + testee.saveDetails(taskExecutionDetails1).block(); + + assertThat(testee.listDetails().collectList().block()) + .hasSize(1); + + testee.remove(taskExecutionDetails1).block(); + + assertThat(testee.listDetails().collectList().block()) + .isEmpty(); + } +} \ No newline at end of file diff --git a/server/task/task-postgres/src/test/java/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionTest.java b/server/task/task-postgres/src/test/java/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionTest.java new file mode 100644 index 00000000000..d64c0688d21 --- /dev/null +++ b/server/task/task-postgres/src/test/java/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionTest.java @@ -0,0 +1,52 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.task.eventsourcing.postgres; + +import java.util.function.Supplier; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.server.task.json.JsonTaskAdditionalInformationSerializer; +import org.apache.james.server.task.json.dto.MemoryReferenceWithCounterTaskAdditionalInformationDTO; +import org.apache.james.task.eventsourcing.TaskExecutionDetailsProjection; +import org.apache.james.task.eventsourcing.TaskExecutionDetailsProjectionContract; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; + +class PostgresTaskExecutionDetailsProjectionTest implements TaskExecutionDetailsProjectionContract { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresTaskExecutionDetailsProjectionModule.MODULE()); + + private static final JsonTaskAdditionalInformationSerializer JSON_TASK_ADDITIONAL_INFORMATION_SERIALIZER = JsonTaskAdditionalInformationSerializer.of(MemoryReferenceWithCounterTaskAdditionalInformationDTO.SERIALIZATION_MODULE); + + private Supplier testeeSupplier; + + @BeforeEach + void setUp() { + PostgresTaskExecutionDetailsProjectionDAO dao = new PostgresTaskExecutionDetailsProjectionDAO(postgresExtension.getPostgresExecutor(), + JSON_TASK_ADDITIONAL_INFORMATION_SERIALIZER); + testeeSupplier = () -> new PostgresTaskExecutionDetailsProjection(dao); + } + + @Override + public TaskExecutionDetailsProjection testee() { + return testeeSupplier.get(); + } + +} From 9eabf24dcffcfab6a1580dfbfcbf96374151c528 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Fri, 29 Mar 2024 13:07:37 +0700 Subject: [PATCH 281/341] JAMES-2586 Relax TaskExecutionDetailsProjectionContract: can compare ZonedDateTime(s) with different timezones --- ...askExecutionDetailsProjectionContract.java | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/server/task/task-memory/src/test/java/org/apache/james/task/eventsourcing/TaskExecutionDetailsProjectionContract.java b/server/task/task-memory/src/test/java/org/apache/james/task/eventsourcing/TaskExecutionDetailsProjectionContract.java index 131812e497e..0c89c93aec2 100644 --- a/server/task/task-memory/src/test/java/org/apache/james/task/eventsourcing/TaskExecutionDetailsProjectionContract.java +++ b/server/task/task-memory/src/test/java/org/apache/james/task/eventsourcing/TaskExecutionDetailsProjectionContract.java @@ -45,7 +45,14 @@ default void loadShouldBeAbleToRetrieveASavedRecord() { testee.update(TASK_EXECUTION_DETAILS()); Optional taskExecutionDetails = OptionConverters.toJava(testee.load(TASK_ID())); - assertThat(taskExecutionDetails).contains(TASK_EXECUTION_DETAILS()); + + assertThat(taskExecutionDetails.get()) + .usingRecursiveComparison() + .ignoringFields("submittedDate") + .isEqualTo(TASK_EXECUTION_DETAILS()); + + assertThat(taskExecutionDetails.get().getSubmittedDate().isEqual(TASK_EXECUTION_DETAILS().getSubmittedDate())) + .isTrue(); } @Test @@ -54,7 +61,14 @@ default void readDetailsShouldBeAbleToRetrieveASavedRecordWithAdditionalInformat testee.update(TASK_EXECUTION_DETAILS_WITH_ADDITIONAL_INFORMATION()); Optional taskExecutionDetails = OptionConverters.toJava(testee.load(TASK_ID())); - assertThat(taskExecutionDetails).contains(TASK_EXECUTION_DETAILS_WITH_ADDITIONAL_INFORMATION()); + + assertThat(taskExecutionDetails.get()) + .usingRecursiveComparison() + .ignoringFields("submittedDate") + .isEqualTo(TASK_EXECUTION_DETAILS_WITH_ADDITIONAL_INFORMATION()); + + assertThat(taskExecutionDetails.get().getSubmittedDate().isEqual(TASK_EXECUTION_DETAILS_WITH_ADDITIONAL_INFORMATION().getSubmittedDate())) + .isTrue(); } @Test @@ -65,7 +79,14 @@ default void updateShouldUpdateRecords() { testee.update(TASK_EXECUTION_DETAILS_UPDATED()); Optional taskExecutionDetails = OptionConverters.toJava(testee.load(TASK_ID())); - assertThat(taskExecutionDetails).contains(TASK_EXECUTION_DETAILS_UPDATED()); + + assertThat(taskExecutionDetails.get()) + .usingRecursiveComparison() + .ignoringFields("submittedDate") + .isEqualTo(TASK_EXECUTION_DETAILS_UPDATED()); + + assertThat(taskExecutionDetails.get().getSubmittedDate().isEqual(TASK_EXECUTION_DETAILS_UPDATED().getSubmittedDate())) + .isTrue(); } @Test @@ -89,7 +110,10 @@ default void listShouldReturnAllRecords() { testee.update(TASK_EXECUTION_DETAILS_2()); List taskExecutionDetails = asJava(testee.list()); - assertThat(taskExecutionDetails).containsOnly(TASK_EXECUTION_DETAILS(), TASK_EXECUTION_DETAILS_2()); + + assertThat(taskExecutionDetails) + .usingRecursiveFieldByFieldElementComparatorIgnoringFields("submittedDate") + .containsOnly(TASK_EXECUTION_DETAILS(), TASK_EXECUTION_DETAILS_2()); } @Test @@ -99,6 +123,8 @@ default void listDetailsShouldReturnLastUpdatedRecords() { testee.update(TASK_EXECUTION_DETAILS_UPDATED()); List taskExecutionDetails = asJava(testee.list()); - assertThat(taskExecutionDetails).containsOnly(TASK_EXECUTION_DETAILS_UPDATED()); + assertThat(taskExecutionDetails) + .usingRecursiveFieldByFieldElementComparatorIgnoringFields("submittedDate") + .containsOnly(TASK_EXECUTION_DETAILS_UPDATED()); } } From d8e74da84cadebeb4cad5917bc0ebd284db9ec6f Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Fri, 29 Mar 2024 15:26:46 +0700 Subject: [PATCH 282/341] JAMES-2586 Guice binding Distributed TaskManager for postgres-app --- .../apache/james/PostgresJamesServerMain.java | 33 ++++- .../container/guice/postgres-common/pom.xml | 4 + .../data/PostgresEventStoreModule.java | 9 -- .../task/DistributedTaskManagerModule.java | 117 ++++++++++++++++++ 4 files changed, 150 insertions(+), 13 deletions(-) create mode 100644 server/container/guice/postgres-common/src/main/java/org/apache/james/modules/task/DistributedTaskManagerModule.java diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index 452742958ec..358b3eb401b 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -20,10 +20,15 @@ package org.apache.james; import java.util.List; +import java.util.Set; import org.apache.james.data.UsersRepositoryModuleChooser; +import org.apache.james.eventsourcing.eventstore.EventNestedTypes; import org.apache.james.jmap.draft.JMAPListenerModule; +import org.apache.james.json.DTO; +import org.apache.james.json.DTOModule; import org.apache.james.modules.BlobExportMechanismModule; +import org.apache.james.modules.DistributedTaskSerializationModule; import org.apache.james.modules.MailboxModule; import org.apache.james.modules.MailetProcessingModule; import org.apache.james.modules.RunArgumentsModule; @@ -71,15 +76,23 @@ import org.apache.james.modules.server.UserIdentityModule; import org.apache.james.modules.server.WebAdminReIndexingTaskSerializationModule; import org.apache.james.modules.server.WebAdminServerModule; +import org.apache.james.modules.task.DistributedTaskManagerModule; import org.apache.james.modules.vault.DeletedMessageVaultRoutesModule; import org.apache.james.vault.VaultConfiguration; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; import com.google.inject.Module; +import com.google.inject.TypeLiteral; +import com.google.inject.name.Names; import com.google.inject.util.Modules; public class PostgresJamesServerMain implements JamesServerMain { + private static final Module EVENT_STORE_JSON_SERIALIZATION_DEFAULT_MODULE = binder -> + binder.bind(new TypeLiteral>>() {}).annotatedWith(Names.named(EventNestedTypes.EVENT_NESTED_TYPES_INJECTION_NAME)) + .toInstance(ImmutableSet.of()); + private static final Module WEBADMIN = Modules.combine( new WebAdminServerModule(), new DataRoutesModules(), @@ -114,11 +127,11 @@ public class PostgresJamesServerMain implements JamesServerMain { new PostgresDataModule(), new MailboxModule(), new SievePostgresRepositoryModules(), - new TaskManagerModule(), new PostgresEventStoreModule(), new TikaMailboxModule(), new PostgresDLPConfigurationStoreModule(), - new PostgresVacationModule()); + new PostgresVacationModule(), + EVENT_STORE_JSON_SERIALIZATION_DEFAULT_MODULE); public static final Module JMAP = Modules.combine( new PostgresJmapModule(), @@ -150,13 +163,14 @@ public static GuiceJamesServer createServer(PostgresJamesConfiguration configura SearchConfiguration searchConfiguration = configuration.searchConfiguration(); return GuiceJamesServer.forConfiguration(configuration) + .combineWith(POSTGRES_MODULE_AGGREGATE) .combineWith(SearchModuleChooser.chooseModules(searchConfiguration)) .combineWith(chooseUsersRepositoryModule(configuration)) .combineWith(chooseBlobStoreModules(configuration)) .combineWith(chooseEventBusModules(configuration)) .combineWith(chooseDeletedMessageVaultModules(configuration.getDeletedMessageVaultConfiguration())) - .combineWith(POSTGRES_MODULE_AGGREGATE) - .overrideWith(chooseJmapModules(configuration)); + .overrideWith(chooseJmapModules(configuration)) + .overrideWith(chooseTaskManagerModules(configuration)); } private static List chooseUsersRepositoryModule(PostgresJamesConfiguration configuration) { @@ -173,6 +187,17 @@ private static List chooseBlobStoreModules(PostgresJamesConfiguration co return builder.build(); } + public static List chooseTaskManagerModules(PostgresJamesConfiguration configuration) { + switch (configuration.eventBusImpl()) { + case IN_MEMORY: + return List.of(new TaskManagerModule()); + case RABBITMQ: + return List.of(new DistributedTaskManagerModule(), new DistributedTaskSerializationModule()); + default: + throw new RuntimeException("Unsupported event-bus implementation " + configuration.eventBusImpl().name()); + } + } + public static List chooseEventBusModules(PostgresJamesConfiguration configuration) { switch (configuration.eventBusImpl()) { case IN_MEMORY: diff --git a/server/container/guice/postgres-common/pom.xml b/server/container/guice/postgres-common/pom.xml index 5cc0f9d2b30..c0d95997d3e 100644 --- a/server/container/guice/postgres-common/pom.xml +++ b/server/container/guice/postgres-common/pom.xml @@ -84,6 +84,10 @@ ${james.groupId} james-server-mailbox-adapter + + ${james.groupId} + james-server-task-postgres + ${james.groupId} testing-base diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresEventStoreModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresEventStoreModule.java index fefe5aa309a..843ea4031ea 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresEventStoreModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresEventStoreModule.java @@ -19,24 +19,17 @@ package org.apache.james.modules.data; -import java.util.Set; - import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.eventsourcing.Event; -import org.apache.james.eventsourcing.eventstore.EventNestedTypes; import org.apache.james.eventsourcing.eventstore.EventStore; import org.apache.james.eventsourcing.eventstore.dto.EventDTO; import org.apache.james.eventsourcing.eventstore.dto.EventDTOModule; import org.apache.james.eventsourcing.eventstore.postgres.PostgresEventStore; -import org.apache.james.json.DTO; -import org.apache.james.json.DTOModule; -import com.google.common.collect.ImmutableSet; import com.google.inject.AbstractModule; import com.google.inject.Scopes; import com.google.inject.TypeLiteral; import com.google.inject.multibindings.Multibinder; -import com.google.inject.name.Names; public class PostgresEventStoreModule extends AbstractModule { @Override @@ -47,8 +40,6 @@ protected void configure() { Multibinder postgresDataDefinitions = Multibinder.newSetBinder(binder(), PostgresModule.class); postgresDataDefinitions.addBinding().toInstance(org.apache.james.eventsourcing.eventstore.postgres.PostgresEventStoreModule.MODULE); - bind(new TypeLiteral>>() {}).annotatedWith(Names.named(EventNestedTypes.EVENT_NESTED_TYPES_INJECTION_NAME)) - .toInstance(ImmutableSet.of()); Multibinder.newSetBinder(binder(), new TypeLiteral>() {}); } } diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/task/DistributedTaskManagerModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/task/DistributedTaskManagerModule.java new file mode 100644 index 00000000000..1812b2421cd --- /dev/null +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/task/DistributedTaskManagerModule.java @@ -0,0 +1,117 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.task; + +import static org.apache.james.modules.queue.rabbitmq.RabbitMQModule.RABBITMQ_CONFIGURATION_NAME; + +import java.io.FileNotFoundException; + +import javax.inject.Singleton; + +import org.apache.commons.configuration2.Configuration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.rabbitmq.SimpleConnectionPool; +import org.apache.james.core.healthcheck.HealthCheck; +import org.apache.james.modules.server.HostnameModule; +import org.apache.james.modules.server.TaskSerializationModule; +import org.apache.james.task.TaskManager; +import org.apache.james.task.eventsourcing.EventSourcingTaskManager; +import org.apache.james.task.eventsourcing.TaskExecutionDetailsProjection; +import org.apache.james.task.eventsourcing.TerminationSubscriber; +import org.apache.james.task.eventsourcing.WorkQueueSupplier; +import org.apache.james.task.eventsourcing.distributed.CancelRequestQueueName; +import org.apache.james.task.eventsourcing.distributed.DistributedTaskManagerHealthCheck; +import org.apache.james.task.eventsourcing.distributed.RabbitMQTerminationSubscriber; +import org.apache.james.task.eventsourcing.distributed.RabbitMQWorkQueue; +import org.apache.james.task.eventsourcing.distributed.RabbitMQWorkQueueConfiguration; +import org.apache.james.task.eventsourcing.distributed.RabbitMQWorkQueueConfiguration$; +import org.apache.james.task.eventsourcing.distributed.RabbitMQWorkQueueReconnectionHandler; +import org.apache.james.task.eventsourcing.distributed.RabbitMQWorkQueueSupplier; +import org.apache.james.task.eventsourcing.distributed.TerminationQueueName; +import org.apache.james.task.eventsourcing.distributed.TerminationReconnectionHandler; +import org.apache.james.task.eventsourcing.postgres.PostgresTaskExecutionDetailsProjection; +import org.apache.james.task.eventsourcing.postgres.PostgresTaskExecutionDetailsProjectionModule; +import org.apache.james.utils.InitializationOperation; +import org.apache.james.utils.InitilizationOperationBuilder; +import org.apache.james.utils.PropertiesProvider; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import com.google.inject.Scopes; +import com.google.inject.multibindings.Multibinder; +import com.google.inject.multibindings.ProvidesIntoSet; + +public class DistributedTaskManagerModule extends AbstractModule { + + @Override + protected void configure() { + install(new HostnameModule()); + install(new TaskSerializationModule()); + + bind(PostgresTaskExecutionDetailsProjection.class).in(Scopes.SINGLETON); + bind(EventSourcingTaskManager.class).in(Scopes.SINGLETON); + bind(RabbitMQWorkQueueSupplier.class).in(Scopes.SINGLETON); + bind(RabbitMQTerminationSubscriber.class).in(Scopes.SINGLETON); + bind(TaskExecutionDetailsProjection.class).to(PostgresTaskExecutionDetailsProjection.class); + bind(TerminationSubscriber.class).to(RabbitMQTerminationSubscriber.class); + bind(TaskManager.class).to(EventSourcingTaskManager.class); + bind(WorkQueueSupplier.class).to(RabbitMQWorkQueueSupplier.class); + bind(CancelRequestQueueName.class).toInstance(CancelRequestQueueName.generate()); + bind(TerminationQueueName.class).toInstance(TerminationQueueName.generate()); + + Multibinder postgresDataDefinitions = Multibinder.newSetBinder(binder(), PostgresModule.class); + postgresDataDefinitions.addBinding().toInstance(PostgresTaskExecutionDetailsProjectionModule.MODULE()); + + Multibinder reconnectionHandlerMultibinder = Multibinder.newSetBinder(binder(), SimpleConnectionPool.ReconnectionHandler.class); + reconnectionHandlerMultibinder.addBinding().to(RabbitMQWorkQueueReconnectionHandler.class); + reconnectionHandlerMultibinder.addBinding().to(TerminationReconnectionHandler.class); + + Multibinder.newSetBinder(binder(), HealthCheck.class) + .addBinding() + .to(DistributedTaskManagerHealthCheck.class); + } + + @Provides + @Singleton + private RabbitMQWorkQueueConfiguration getWorkQueueConfiguration(PropertiesProvider propertiesProvider) throws ConfigurationException { + try { + Configuration configuration = propertiesProvider.getConfiguration(RABBITMQ_CONFIGURATION_NAME); + return RabbitMQWorkQueueConfiguration$.MODULE$.from(configuration); + } catch (FileNotFoundException e) { + return RabbitMQWorkQueueConfiguration$.MODULE$.enabled(); + } + } + + @ProvidesIntoSet + InitializationOperation terminationSubscriber(RabbitMQTerminationSubscriber instance) { + return InitilizationOperationBuilder + .forClass(RabbitMQTerminationSubscriber.class) + .init(instance::start); + } + + @ProvidesIntoSet + InitializationOperation workQueue(EventSourcingTaskManager instance) { + return InitilizationOperationBuilder + .forClass(RabbitMQWorkQueue.class) + .init(instance::start); + } + +} From 4bcc55c33a28e1151719d8ad5ecf39e5d4f8baa2 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Mon, 1 Apr 2024 14:47:06 +0700 Subject: [PATCH 283/341] JAMES-2586 Disable DistributedTaskSerializationModule TODO handle [Guice/CanNotProxyClass]: Tried proxying JsonTaskSerializer to support a circular dependency, but it is not an interface. in another ticket. --- .../main/java/org/apache/james/PostgresJamesServerMain.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index 358b3eb401b..1a481e6fb2a 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -28,7 +28,6 @@ import org.apache.james.json.DTO; import org.apache.james.json.DTOModule; import org.apache.james.modules.BlobExportMechanismModule; -import org.apache.james.modules.DistributedTaskSerializationModule; import org.apache.james.modules.MailboxModule; import org.apache.james.modules.MailetProcessingModule; import org.apache.james.modules.RunArgumentsModule; @@ -192,7 +191,7 @@ public static List chooseTaskManagerModules(PostgresJamesConfiguration c case IN_MEMORY: return List.of(new TaskManagerModule()); case RABBITMQ: - return List.of(new DistributedTaskManagerModule(), new DistributedTaskSerializationModule()); + return List.of(new DistributedTaskManagerModule()); default: throw new RuntimeException("Unsupported event-bus implementation " + configuration.eventBusImpl().name()); } From 445aa7b4b2aa993fc0d5350a0608707d80357f52 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Mon, 1 Apr 2024 15:48:41 +0700 Subject: [PATCH 284/341] JAMES-2586 Add missing cleanup task webadmin routes --- .../apache/james/PostgresJamesServerMain.java | 9 ++++- .../task/DistributedTaskManagerModule.java | 10 +---- ...ExecutionDetailsProjectionGuiceModule.java | 40 +++++++++++++++++++ 3 files changed, 48 insertions(+), 11 deletions(-) create mode 100644 server/container/guice/postgres-common/src/main/java/org/apache/james/modules/task/PostgresTaskExecutionDetailsProjectionGuiceModule.java diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index 1a481e6fb2a..f4fad8d3872 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -31,6 +31,7 @@ import org.apache.james.modules.MailboxModule; import org.apache.james.modules.MailetProcessingModule; import org.apache.james.modules.RunArgumentsModule; +import org.apache.james.modules.TasksCleanupTaskSerializationModule; import org.apache.james.modules.blobstore.BlobStoreCacheModulesChooser; import org.apache.james.modules.blobstore.BlobStoreModulesChooser; import org.apache.james.modules.data.PostgresDLPConfigurationStoreModule; @@ -76,7 +77,9 @@ import org.apache.james.modules.server.WebAdminReIndexingTaskSerializationModule; import org.apache.james.modules.server.WebAdminServerModule; import org.apache.james.modules.task.DistributedTaskManagerModule; +import org.apache.james.modules.task.PostgresTaskExecutionDetailsProjectionGuiceModule; import org.apache.james.modules.vault.DeletedMessageVaultRoutesModule; +import org.apache.james.modules.webadmin.TasksCleanupRoutesModule; import org.apache.james.vault.VaultConfiguration; import com.google.common.collect.ImmutableList; @@ -106,7 +109,9 @@ public class PostgresJamesServerMain implements JamesServerMain { new UserIdentityModule(), new DLPRoutesModule(), new JmapUploadCleanupModule(), - new JmapTasksModule()); + new JmapTasksModule(), + new TasksCleanupRoutesModule(), + new TasksCleanupTaskSerializationModule()); private static final Module PROTOCOLS = Modules.combine( new IMAPServerModule(), @@ -189,7 +194,7 @@ private static List chooseBlobStoreModules(PostgresJamesConfiguration co public static List chooseTaskManagerModules(PostgresJamesConfiguration configuration) { switch (configuration.eventBusImpl()) { case IN_MEMORY: - return List.of(new TaskManagerModule()); + return List.of(new TaskManagerModule(), new PostgresTaskExecutionDetailsProjectionGuiceModule()); case RABBITMQ: return List.of(new DistributedTaskManagerModule()); default: diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/task/DistributedTaskManagerModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/task/DistributedTaskManagerModule.java index 1812b2421cd..12dd7f896a5 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/task/DistributedTaskManagerModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/task/DistributedTaskManagerModule.java @@ -27,14 +27,12 @@ import org.apache.commons.configuration2.Configuration; import org.apache.commons.configuration2.ex.ConfigurationException; -import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.backends.rabbitmq.SimpleConnectionPool; import org.apache.james.core.healthcheck.HealthCheck; import org.apache.james.modules.server.HostnameModule; import org.apache.james.modules.server.TaskSerializationModule; import org.apache.james.task.TaskManager; import org.apache.james.task.eventsourcing.EventSourcingTaskManager; -import org.apache.james.task.eventsourcing.TaskExecutionDetailsProjection; import org.apache.james.task.eventsourcing.TerminationSubscriber; import org.apache.james.task.eventsourcing.WorkQueueSupplier; import org.apache.james.task.eventsourcing.distributed.CancelRequestQueueName; @@ -47,8 +45,6 @@ import org.apache.james.task.eventsourcing.distributed.RabbitMQWorkQueueSupplier; import org.apache.james.task.eventsourcing.distributed.TerminationQueueName; import org.apache.james.task.eventsourcing.distributed.TerminationReconnectionHandler; -import org.apache.james.task.eventsourcing.postgres.PostgresTaskExecutionDetailsProjection; -import org.apache.james.task.eventsourcing.postgres.PostgresTaskExecutionDetailsProjectionModule; import org.apache.james.utils.InitializationOperation; import org.apache.james.utils.InitilizationOperationBuilder; import org.apache.james.utils.PropertiesProvider; @@ -65,21 +61,17 @@ public class DistributedTaskManagerModule extends AbstractModule { protected void configure() { install(new HostnameModule()); install(new TaskSerializationModule()); + install(new PostgresTaskExecutionDetailsProjectionGuiceModule()); - bind(PostgresTaskExecutionDetailsProjection.class).in(Scopes.SINGLETON); bind(EventSourcingTaskManager.class).in(Scopes.SINGLETON); bind(RabbitMQWorkQueueSupplier.class).in(Scopes.SINGLETON); bind(RabbitMQTerminationSubscriber.class).in(Scopes.SINGLETON); - bind(TaskExecutionDetailsProjection.class).to(PostgresTaskExecutionDetailsProjection.class); bind(TerminationSubscriber.class).to(RabbitMQTerminationSubscriber.class); bind(TaskManager.class).to(EventSourcingTaskManager.class); bind(WorkQueueSupplier.class).to(RabbitMQWorkQueueSupplier.class); bind(CancelRequestQueueName.class).toInstance(CancelRequestQueueName.generate()); bind(TerminationQueueName.class).toInstance(TerminationQueueName.generate()); - Multibinder postgresDataDefinitions = Multibinder.newSetBinder(binder(), PostgresModule.class); - postgresDataDefinitions.addBinding().toInstance(PostgresTaskExecutionDetailsProjectionModule.MODULE()); - Multibinder reconnectionHandlerMultibinder = Multibinder.newSetBinder(binder(), SimpleConnectionPool.ReconnectionHandler.class); reconnectionHandlerMultibinder.addBinding().to(RabbitMQWorkQueueReconnectionHandler.class); reconnectionHandlerMultibinder.addBinding().to(TerminationReconnectionHandler.class); diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/task/PostgresTaskExecutionDetailsProjectionGuiceModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/task/PostgresTaskExecutionDetailsProjectionGuiceModule.java new file mode 100644 index 00000000000..9f7bb0694a5 --- /dev/null +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/task/PostgresTaskExecutionDetailsProjectionGuiceModule.java @@ -0,0 +1,40 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.task; + +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.task.eventsourcing.TaskExecutionDetailsProjection; +import org.apache.james.task.eventsourcing.postgres.PostgresTaskExecutionDetailsProjection; +import org.apache.james.task.eventsourcing.postgres.PostgresTaskExecutionDetailsProjectionModule; + +import com.google.inject.AbstractModule; +import com.google.inject.Scopes; +import com.google.inject.multibindings.Multibinder; + +public class PostgresTaskExecutionDetailsProjectionGuiceModule extends AbstractModule { + @Override + protected void configure() { + bind(TaskExecutionDetailsProjection.class).to(PostgresTaskExecutionDetailsProjection.class) + .in(Scopes.SINGLETON); + + Multibinder postgresDataDefinitions = Multibinder.newSetBinder(binder(), PostgresModule.class); + postgresDataDefinitions.addBinding().toInstance(PostgresTaskExecutionDetailsProjectionModule.MODULE()); + } +} From 798bb00c486acc26604061a37caf96041a590916 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Mon, 1 Apr 2024 15:56:53 +0700 Subject: [PATCH 285/341] JAMES-2586 Do not use ActiveMQ mail queue when distributed mode Use RabbitMQ mail queue instead. --- .../org/apache/james/PostgresJamesServerMain.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index f4fad8d3872..ced9292a389 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -59,6 +59,8 @@ import org.apache.james.modules.protocols.ProtocolHandlerModule; import org.apache.james.modules.protocols.SMTPServerModule; import org.apache.james.modules.queue.activemq.ActiveMQQueueModule; +import org.apache.james.modules.queue.rabbitmq.FakeMailQueueViewModule; +import org.apache.james.modules.queue.rabbitmq.RabbitMQMailQueueModule; import org.apache.james.modules.queue.rabbitmq.RabbitMQModule; import org.apache.james.modules.server.DLPRoutesModule; import org.apache.james.modules.server.DataRoutesModules; @@ -70,6 +72,7 @@ import org.apache.james.modules.server.MailRepositoriesRoutesModule; import org.apache.james.modules.server.MailboxRoutesModule; import org.apache.james.modules.server.MailboxesExportRoutesModule; +import org.apache.james.modules.server.RabbitMailQueueRoutesModule; import org.apache.james.modules.server.ReIndexingModule; import org.apache.james.modules.server.SieveRoutesModule; import org.apache.james.modules.server.TaskManagerModule; @@ -123,7 +126,6 @@ public class PostgresJamesServerMain implements JamesServerMain { WEBADMIN); private static final Module POSTGRES_SERVER_MODULE = Modules.combine( - new ActiveMQQueueModule(), new BlobExportMechanismModule(), new PostgresDelegationStoreModule(), new PostgresMailboxModule(), @@ -205,10 +207,14 @@ public static List chooseTaskManagerModules(PostgresJamesConfiguration c public static List chooseEventBusModules(PostgresJamesConfiguration configuration) { switch (configuration.eventBusImpl()) { case IN_MEMORY: - return List.of(new DefaultEventModule()); + return List.of(new DefaultEventModule(), + new ActiveMQQueueModule()); case RABBITMQ: return List.of(new RabbitMQModule(), - Modules.override(new DefaultEventModule()).with(new RabbitMQEventBusModule())); + Modules.override(new DefaultEventModule()).with(new RabbitMQEventBusModule()), + new RabbitMQMailQueueModule(), + new FakeMailQueueViewModule(), + new RabbitMailQueueRoutesModule()); default: throw new RuntimeException("Unsupported event-bus implementation " + configuration.eventBusImpl().name()); } From 94bf1d244d95a28c9419f97e63c571a4116a3670 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Mon, 1 Apr 2024 16:00:57 +0700 Subject: [PATCH 286/341] JAMES-2586 Add binding for DKIMMailetModule --- .../main/java/org/apache/james/PostgresJamesServerMain.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index ced9292a389..b11cd7cbfb0 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -62,6 +62,7 @@ import org.apache.james.modules.queue.rabbitmq.FakeMailQueueViewModule; import org.apache.james.modules.queue.rabbitmq.RabbitMQMailQueueModule; import org.apache.james.modules.queue.rabbitmq.RabbitMQModule; +import org.apache.james.modules.server.DKIMMailetModule; import org.apache.james.modules.server.DLPRoutesModule; import org.apache.james.modules.server.DataRoutesModules; import org.apache.james.modules.server.InconsistencyQuotasSolvingRoutesModule; @@ -148,7 +149,7 @@ public class PostgresJamesServerMain implements JamesServerMain { public static final Module PLUGINS = new QuotaMailingModule(); private static final Module POSTGRES_MODULE_AGGREGATE = Modules.combine( - new MailetProcessingModule(), POSTGRES_SERVER_MODULE, PROTOCOLS, JMAP, PLUGINS); + new MailetProcessingModule(), new DKIMMailetModule(), POSTGRES_SERVER_MODULE, PROTOCOLS, JMAP, PLUGINS); public static void main(String[] args) throws Exception { ExtraProperties.initialize(); From 3dbf76d95c77cba9c3aa3271aa412777c18e6e3f Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Tue, 2 Apr 2024 13:57:34 +0700 Subject: [PATCH 287/341] JAMES-2586 - Postgres - Bind DistributedTaskSerializationModule into postgres-app --- .../apache/james/PostgresJamesServerMain.java | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index b11cd7cbfb0..30271bf3733 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.Set; +import java.util.function.Function; import org.apache.james.data.UsersRepositoryModuleChooser; import org.apache.james.eventsourcing.eventstore.EventNestedTypes; @@ -28,6 +29,7 @@ import org.apache.james.json.DTO; import org.apache.james.json.DTOModule; import org.apache.james.modules.BlobExportMechanismModule; +import org.apache.james.modules.DistributedTaskSerializationModule; import org.apache.james.modules.MailboxModule; import org.apache.james.modules.MailetProcessingModule; import org.apache.james.modules.RunArgumentsModule; @@ -148,8 +150,15 @@ public class PostgresJamesServerMain implements JamesServerMain { public static final Module PLUGINS = new QuotaMailingModule(); - private static final Module POSTGRES_MODULE_AGGREGATE = Modules.combine( - new MailetProcessingModule(), new DKIMMailetModule(), POSTGRES_SERVER_MODULE, PROTOCOLS, JMAP, PLUGINS); + private static final Function POSTGRES_MODULE_AGGREGATE = configuration -> + Modules.override(Modules.combine( + new MailetProcessingModule(), + new DKIMMailetModule(), + POSTGRES_SERVER_MODULE, + JMAP, + PROTOCOLS, + PLUGINS)) + .with(chooseEventBusModules(configuration)); public static void main(String[] args) throws Exception { ExtraProperties.initialize(); @@ -170,11 +179,10 @@ public static GuiceJamesServer createServer(PostgresJamesConfiguration configura SearchConfiguration searchConfiguration = configuration.searchConfiguration(); return GuiceJamesServer.forConfiguration(configuration) - .combineWith(POSTGRES_MODULE_AGGREGATE) + .combineWith(POSTGRES_MODULE_AGGREGATE.apply(configuration)) .combineWith(SearchModuleChooser.chooseModules(searchConfiguration)) .combineWith(chooseUsersRepositoryModule(configuration)) .combineWith(chooseBlobStoreModules(configuration)) - .combineWith(chooseEventBusModules(configuration)) .combineWith(chooseDeletedMessageVaultModules(configuration.getDeletedMessageVaultConfiguration())) .overrideWith(chooseJmapModules(configuration)) .overrideWith(chooseTaskManagerModules(configuration)); @@ -208,14 +216,17 @@ public static List chooseTaskManagerModules(PostgresJamesConfiguration c public static List chooseEventBusModules(PostgresJamesConfiguration configuration) { switch (configuration.eventBusImpl()) { case IN_MEMORY: - return List.of(new DefaultEventModule(), + return List.of( + new DefaultEventModule(), new ActiveMQQueueModule()); case RABBITMQ: - return List.of(new RabbitMQModule(), + return List.of( Modules.override(new DefaultEventModule()).with(new RabbitMQEventBusModule()), + new RabbitMQModule(), new RabbitMQMailQueueModule(), new FakeMailQueueViewModule(), - new RabbitMailQueueRoutesModule()); + new RabbitMailQueueRoutesModule(), + new DistributedTaskSerializationModule()); default: throw new RuntimeException("Unsupported event-bus implementation " + configuration.eventBusImpl().name()); } From 93adc14ab28d0b50b998771577f961ca4d80c9ad Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 3 Apr 2024 15:23:53 +0700 Subject: [PATCH 288/341] JAMES-2586 - Postgres - Binding ACLUpdated Event DTO --- mailbox/postgres/pom.xml | 4 ++ .../mail/eventsourcing/acl/ACLModule.java | 41 +++++++++++++++++++ .../mailbox/PostgresMailboxModule.java | 8 ++++ 3 files changed, 53 insertions(+) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/eventsourcing/acl/ACLModule.java diff --git a/mailbox/postgres/pom.xml b/mailbox/postgres/pom.xml index 628e995e3de..96f13038906 100644 --- a/mailbox/postgres/pom.xml +++ b/mailbox/postgres/pom.xml @@ -54,6 +54,10 @@ test-jar test + + ${james.groupId} + apache-james-mailbox-event-json + ${james.groupId} apache-james-mailbox-store diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/eventsourcing/acl/ACLModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/eventsourcing/acl/ACLModule.java new file mode 100644 index 00000000000..7c99c08392e --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/eventsourcing/acl/ACLModule.java @@ -0,0 +1,41 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail.eventsourcing.acl; + +import org.apache.james.event.acl.ACLUpdated; +import org.apache.james.event.acl.ACLUpdatedDTO; +import org.apache.james.eventsourcing.eventstore.dto.EventDTOModule; +import org.apache.james.json.DTOModule; +import org.apache.james.mailbox.model.MailboxId; +import org.apache.james.mailbox.postgres.PostgresMailboxId; + +public interface ACLModule { + String UPDATE_TYPE_NAME = "acl-updated"; + + MailboxId.Factory mailboxIdFactory = new PostgresMailboxId.Factory(); + + EventDTOModule ACL_UPDATE = + new DTOModule.Builder<>(ACLUpdated.class) + .convertToDTO(ACLUpdatedDTO.class) + .toDomainObjectConverter(dto -> dto.toEvent(mailboxIdFactory)) + .toDTOConverter(ACLUpdatedDTO::from) + .typeName(UPDATE_TYPE_NAME) + .withFactory(EventDTOModule::new); +} \ No newline at end of file diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java index ca688e502e6..649df147623 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java @@ -32,6 +32,9 @@ import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.blob.api.BlobReferenceSource; import org.apache.james.events.EventListener; +import org.apache.james.eventsourcing.Event; +import org.apache.james.eventsourcing.eventstore.dto.EventDTO; +import org.apache.james.eventsourcing.eventstore.dto.EventDTOModule; import org.apache.james.mailbox.AttachmentContentLoader; import org.apache.james.mailbox.AttachmentIdFactory; import org.apache.james.mailbox.AttachmentManager; @@ -60,6 +63,7 @@ import org.apache.james.mailbox.postgres.mail.PostgresAttachmentBlobReferenceSource; import org.apache.james.mailbox.postgres.mail.PostgresMessageBlobReferenceSource; import org.apache.james.mailbox.postgres.mail.dao.PostgresMessageDAO; +import org.apache.james.mailbox.postgres.mail.eventsourcing.acl.ACLModule; import org.apache.james.mailbox.store.MailboxManagerConfiguration; import org.apache.james.mailbox.store.MailboxSessionMapperFactory; import org.apache.james.mailbox.store.NoMailboxPathLocker; @@ -86,6 +90,7 @@ import com.google.inject.AbstractModule; import com.google.inject.Inject; import com.google.inject.Scopes; +import com.google.inject.TypeLiteral; import com.google.inject.multibindings.Multibinder; import com.google.inject.name.Names; @@ -170,6 +175,9 @@ protected void configure() { Multibinder blobReferenceSourceMultibinder = Multibinder.newSetBinder(binder(), BlobReferenceSource.class); blobReferenceSourceMultibinder.addBinding().to(PostgresMessageBlobReferenceSource.class); blobReferenceSourceMultibinder.addBinding().to(PostgresAttachmentBlobReferenceSource.class); + + Multibinder.newSetBinder(binder(), new TypeLiteral>() {}) + .addBinding().toInstance(ACLModule.ACL_UPDATE); } @Singleton From 26cb7552831518b9628bdffd97671f3972ae9465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20H=E1=BB=93ng=20Qu=C3=A2n?= <55171818+quantranhong1999@users.noreply.github.com> Date: Tue, 9 Apr 2024 10:22:58 +0700 Subject: [PATCH 289/341] JAMES-2586 PopulateEmailQueryViewTask should not hang for postgres-app (#2179) --- .../james/user/postgres/PostgresUsersDAO.java | 5 +- .../postgres/PostgresUsersRepositoryTest.java | 33 +++- ...lateEmailQueryViewTaskIntegrationTest.java | 155 ++++++++++++++++++ 3 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresPopulateEmailQueryViewTaskIntegrationTest.java diff --git a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java index fcd0ac80b16..e936c8cb80a 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java @@ -32,6 +32,7 @@ import java.util.Iterator; import java.util.Optional; +import java.util.function.Function; import javax.inject.Inject; import javax.inject.Named; @@ -141,7 +142,9 @@ public Iterator list() throws UsersRepositoryException { @Override public Flux listReactive() { return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.selectFrom(TABLE_NAME))) - .map(record -> Username.of(record.get(USERNAME))); + .map(record -> Username.of(record.get(USERNAME))) + .collectList() + .flatMapIterable(Function.identity()); } @Override diff --git a/server/data/data-postgres/src/test/java/org/apache/james/user/postgres/PostgresUsersRepositoryTest.java b/server/data/data-postgres/src/test/java/org/apache/james/user/postgres/PostgresUsersRepositoryTest.java index 00c250104d7..3676ee8dcd6 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/user/postgres/PostgresUsersRepositoryTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/user/postgres/PostgresUsersRepositoryTest.java @@ -19,9 +19,13 @@ package org.apache.james.user.postgres; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Optional; + import org.apache.commons.configuration2.BaseHierarchicalConfiguration; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.backends.postgres.utils.SinglePostgresConnectionFactory; +import org.apache.james.core.Domain; import org.apache.james.core.Username; import org.apache.james.domainlist.api.DomainList; import org.apache.james.user.api.UsersRepository; @@ -29,9 +33,14 @@ import org.apache.james.user.lib.UsersRepositoryImpl; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import java.util.Optional; +import com.github.fge.lambdas.Throwing; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; class PostgresUsersRepositoryTest { @@ -61,6 +70,26 @@ public UsersRepositoryImpl testee() { public UsersRepository testee(Optional administrator) throws Exception { return getUsersRepository(testSystem.getDomainList(), extension.isSupportVirtualHosting(), administrator); } + + @Test + void listUsersReactiveThenExecuteOtherPostgresQueriesShouldNotHang() throws Exception { + Domain domain = Domain.of("example.com"); + testSystem.getDomainList().addDomain(domain); + + Flux.range(1, 1000) + .flatMap(counter -> Mono.fromRunnable(Throwing.runnable(() -> usersRepository.addUser(Username.fromLocalPartWithDomain(counter.toString(), domain), "password"))), + 128) + .collectList() + .block(); + + assertThat(Flux.from(usersRepository.listReactive()) + .flatMap(username -> Mono.fromCallable(() -> usersRepository.test(username, "password") + .orElseThrow(() -> new RuntimeException("Wrong user credential"))) + .subscribeOn(Schedulers.boundedElastic())) + .collectList() + .block()) + .hasSize(1000); + } } @Nested diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresPopulateEmailQueryViewTaskIntegrationTest.java b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresPopulateEmailQueryViewTaskIntegrationTest.java new file mode 100644 index 00000000000..69518da4b3d --- /dev/null +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/postgres/PostgresPopulateEmailQueryViewTaskIntegrationTest.java @@ -0,0 +1,155 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.webadmin.integration.postgres; + +import static io.restassured.RestAssured.given; +import static io.restassured.RestAssured.with; +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; +import static org.hamcrest.Matchers.is; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.stream.IntStream; + +import org.apache.james.GuiceJamesServer; +import org.apache.james.JamesServerBuilder; +import org.apache.james.JamesServerExtension; +import org.apache.james.PostgresJamesConfiguration; +import org.apache.james.PostgresJamesServerMain; +import org.apache.james.SearchConfiguration; +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.core.Username; +import org.apache.james.mailbox.MessageManager; +import org.apache.james.mailbox.model.MailboxPath; +import org.apache.james.mime4j.dom.Message; +import org.apache.james.modules.MailboxProbeImpl; +import org.apache.james.modules.TestJMAPServerModule; +import org.apache.james.probe.DataProbe; +import org.apache.james.utils.DataProbeImpl; +import org.apache.james.utils.WebAdminGuiceProbe; +import org.apache.james.webadmin.WebAdminUtils; +import org.apache.james.webadmin.routes.TasksRoutes; +import org.awaitility.Awaitility; +import org.awaitility.core.ConditionFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.github.fge.lambdas.Throwing; + +import io.restassured.RestAssured; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +class PostgresPopulateEmailQueryViewTaskIntegrationTest { + @RegisterExtension + static JamesServerExtension jamesServerExtension = new JamesServerBuilder(tmpDir -> + PostgresJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .searchConfiguration(SearchConfiguration.scanning()) + .usersRepository(DEFAULT) + .eventBusImpl(PostgresJamesConfiguration.EventBusImpl.IN_MEMORY) + .build()) + .extension(PostgresExtension.empty()) + .server(configuration -> PostgresJamesServerMain.createServer(configuration) + .overrideWith(new TestJMAPServerModule())) + .build(); + + private static final String DOMAIN = "domain.tld"; + private static final Username BOB = Username.of("bob@" + DOMAIN); + private static final String PASSWORD = "password"; + private static final MailboxPath BOB_INBOX_PATH = MailboxPath.inbox(Username.of(BOB.asString())); + private static final Username ALICE = Username.of("alice@" + DOMAIN); + private static final MailboxPath ALICE_INBOX_PATH = MailboxPath.inbox(Username.of(ALICE.asString())); + private static final Username CEDRIC = Username.of("cedric@" + DOMAIN); + private static final MailboxPath CEDRIC_INBOX_PATH = MailboxPath.inbox(Username.of(CEDRIC.asString())); + + ConditionFactory calmlyAwait = Awaitility.with() + .pollInterval(Duration.ofMillis(200)) + .and().with() + .await(); + + private MailboxProbeImpl mailboxProbe; + + @BeforeEach + void setUp(GuiceJamesServer guiceJamesServer) throws Exception { + DataProbe dataProbe = guiceJamesServer.getProbe(DataProbeImpl.class); + mailboxProbe = guiceJamesServer.getProbe(MailboxProbeImpl.class); + WebAdminGuiceProbe webAdminGuiceProbe = guiceJamesServer.getProbe(WebAdminGuiceProbe.class); + + RestAssured.requestSpecification = WebAdminUtils.buildRequestSpecification(webAdminGuiceProbe.getWebAdminPort()) + .build(); + + dataProbe.addDomain(DOMAIN); + dataProbe.addUser(BOB.asString(), PASSWORD); + dataProbe.addUser(ALICE.asString(), PASSWORD); + dataProbe.addUser(CEDRIC.asString(), PASSWORD); + + // Provision 1000 dummy users. A good users amount is needed to trigger the hanging scenario. + Flux.range(1, 1000) + .flatMap(counter -> Mono.fromRunnable(Throwing.runnable(() -> dataProbe.addUser(counter + "@" + DOMAIN, "password"))), + 128) + .collectList() + .block(); + + mailboxProbe.createMailbox(BOB_INBOX_PATH); + addMessagesToMailbox(BOB, BOB_INBOX_PATH); + + mailboxProbe.createMailbox(ALICE_INBOX_PATH); + addMessagesToMailbox(ALICE, ALICE_INBOX_PATH); + + mailboxProbe.createMailbox(CEDRIC_INBOX_PATH); + addMessagesToMailbox(CEDRIC, CEDRIC_INBOX_PATH); + } + + @Test + void populateEmailQueryViewTaskShouldNotHang() { + String taskId = with() + .post("/mailboxes?task=populateEmailQueryView") + .jsonPath() + .get("taskId"); + + calmlyAwait.atMost(Duration.ofSeconds(30)) + .untilAsserted(() -> + given() + .basePath(TasksRoutes.BASE) + .when() + .get(taskId) + .then() + .body("status", is("completed")) + .body("type", is("PopulateEmailQueryViewTask")) + .body("additionalInformation.processedUserCount", is(1003)) + .body("additionalInformation.failedUserCount", is(0)) + .body("additionalInformation.processedMessageCount", is(30)) + .body("additionalInformation.failedMessageCount", is(0))); + } + + private void addMessagesToMailbox(Username username, MailboxPath mailbox) { + IntStream.rangeClosed(1, 10) + .forEach(Throwing.intConsumer(ignored -> + mailboxProbe.appendMessage(username.asString(), mailbox, + MessageManager.AppendCommand.builder() + .build(Message.Builder.of() + .setSubject("small message") + .setBody("small message for postgres", StandardCharsets.UTF_8) + .build())))); + } +} \ No newline at end of file From 767b1593dad9d83e08f6cb88a2d046561dad17cf Mon Sep 17 00:00:00 2001 From: hung phan Date: Fri, 5 Apr 2024 11:05:57 +0700 Subject: [PATCH 290/341] JAMES-2586 Fix flaky tests in EmailQueryMethodTest --- .../james/jmap/rfc8621/contract/EmailQueryMethodContract.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailQueryMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailQueryMethodContract.scala index f7ed0d23aa5..ca4c8fec630 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailQueryMethodContract.scala +++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/EmailQueryMethodContract.scala @@ -54,7 +54,7 @@ import org.apache.james.util.ClassLoaderUtils import org.apache.james.utils.DataProbeImpl import org.awaitility.Awaitility import org.awaitility.Durations.ONE_HUNDRED_MILLISECONDS -import org.junit.jupiter.api.{BeforeEach, RepeatedTest, Test} +import org.junit.jupiter.api.{BeforeEach, Test} import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.{Arguments, MethodSource, ValueSource} import org.threeten.extra.Seconds From e550654f43670ed61f05728f4c15b08e54099118 Mon Sep 17 00:00:00 2001 From: hung phan Date: Fri, 5 Apr 2024 11:06:34 +0700 Subject: [PATCH 291/341] JAMES-2586 Enable flaky tests in PostgresEmailQueryMethodTest --- .../PostgresEmailQueryMethodTest.java | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailQueryMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailQueryMethodTest.java index 9ca92ff0d6a..094d01701fa 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailQueryMethodTest.java +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresEmailQueryMethodTest.java @@ -22,7 +22,6 @@ import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; import org.apache.james.DockerOpenSearchExtension; -import org.apache.james.GuiceJamesServer; import org.apache.james.JamesServerBuilder; import org.apache.james.JamesServerExtension; import org.apache.james.PostgresJamesConfiguration; @@ -35,8 +34,6 @@ import org.apache.james.modules.RabbitMQExtension; import org.apache.james.modules.TestJMAPServerModule; import org.apache.james.modules.blobstore.BlobStoreConfiguration; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; public class PostgresEmailQueryMethodTest implements EmailQueryMethodContract { @@ -62,22 +59,4 @@ public class PostgresEmailQueryMethodTest implements EmailQueryMethodContract { .overrideWith(new DelegationProbeModule()) .overrideWith(new IdentityProbeModule())) .build(); - - @Override - @Test - @Disabled("Flaky test. TODO stabilize it.") - public void listMailsShouldBeSortedWhenUsingTo(GuiceJamesServer server) { - } - - @Override - @Test - @Disabled("Flaky test. TODO stabilize it.") - public void listMailsShouldBeSortedWhenUsingFrom(GuiceJamesServer server) { - } - - @Override - @Test - @Disabled("Flaky test. TODO stabilize it.") - public void inMailboxOtherThanShouldBeRejectedWhenInOperator(GuiceJamesServer server) { - } } From 0e87b06a937a5c13afc87272caea9d02d72ce276 Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Fri, 5 Apr 2024 13:12:26 +0700 Subject: [PATCH 292/341] JAMES-2586 Apply reactor timeout for jOOQ --- .../postgres/PostgresConfiguration.java | 29 ++++++++++++++--- .../postgres/utils/PostgresExecutor.java | 31 +++++++++++++++++-- .../backends/postgres/PostgresExtension.java | 11 +++++-- ...sAnnotationMapperRowLevelSecurityTest.java | 3 +- ...gresMailboxMapperRowLevelSecurityTest.java | 3 +- ...gresMessageMapperRowLevelSecurityTest.java | 3 +- ...ubscriptionMapperRowLevelSecurityTest.java | 3 +- .../sample-configuration/postgres.properties | 5 ++- .../modules/data/PostgresCommonModule.java | 5 +-- 9 files changed, 76 insertions(+), 17 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java index 88f91d3d234..e00c803fd30 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java @@ -19,10 +19,13 @@ package org.apache.james.backends.postgres; +import java.time.Duration; +import java.time.temporal.ChronoUnit; import java.util.Objects; import java.util.Optional; import org.apache.commons.configuration2.Configuration; +import org.apache.james.util.DurationParser; import com.google.common.base.Preconditions; @@ -44,6 +47,8 @@ public class PostgresConfiguration { public static final String RLS_ENABLED = "row.level.security.enabled"; public static final String SSL_MODE = "ssl.mode"; public static final String SSL_MODE_DEFAULT_VALUE = "allow"; + public static final String JOOQ_REACTIVE_TIMEOUT = "jooq.reactive.timeout"; + public static final Duration JOOQ_REACTIVE_TIMEOUT_DEFAULT_VALUE = Duration.ofSeconds(10); public static class Credential { private final String username; @@ -75,6 +80,7 @@ public static class Builder { private Optional nonRLSPassword = Optional.empty(); private Optional rowLevelSecurityEnabled = Optional.empty(); private Optional sslMode = Optional.empty(); + private Optional jooqReactiveTimeout = Optional.empty(); public Builder databaseName(String databaseName) { this.databaseName = Optional.of(databaseName); @@ -176,6 +182,11 @@ public Builder sslMode(String sslMode) { return this; } + public Builder jooqReactiveTimeout(Optional jooqReactiveTimeout) { + this.jooqReactiveTimeout = jooqReactiveTimeout; + return this; + } + public PostgresConfiguration build() { Preconditions.checkArgument(username.isPresent() && !username.get().isBlank(), "You need to specify username"); Preconditions.checkArgument(password.isPresent() && !password.get().isBlank(), "You need to specify password"); @@ -192,7 +203,8 @@ public PostgresConfiguration build() { new Credential(username.get(), password.get()), new Credential(nonRLSUser.orElse(username.get()), nonRLSPassword.orElse(password.get())), rowLevelSecurityEnabled.orElse(false), - SSLMode.fromValue(sslMode.orElse(SSL_MODE_DEFAULT_VALUE))); + SSLMode.fromValue(sslMode.orElse(SSL_MODE_DEFAULT_VALUE)), + jooqReactiveTimeout.orElse(JOOQ_REACTIVE_TIMEOUT_DEFAULT_VALUE)); } } @@ -212,6 +224,8 @@ public static PostgresConfiguration from(Configuration propertiesConfiguration) .nonRLSPassword(Optional.ofNullable(propertiesConfiguration.getString(NON_RLS_PASSWORD))) .rowLevelSecurityEnabled(propertiesConfiguration.getBoolean(RLS_ENABLED, false)) .sslMode(Optional.ofNullable(propertiesConfiguration.getString(SSL_MODE))) + .jooqReactiveTimeout(Optional.ofNullable(propertiesConfiguration.getString(JOOQ_REACTIVE_TIMEOUT)) + .map(value -> DurationParser.parse(value, ChronoUnit.SECONDS))) .build(); } @@ -223,10 +237,11 @@ public static PostgresConfiguration from(Configuration propertiesConfiguration) private final Credential nonRLSCredential; private final boolean rowLevelSecurityEnabled; private final SSLMode sslMode; + private final Duration jooqReactiveTimeout; private PostgresConfiguration(String host, int port, String databaseName, String databaseSchema, Credential credential, Credential nonRLSCredential, boolean rowLevelSecurityEnabled, - SSLMode sslMode) { + SSLMode sslMode, Duration jooqReactiveTimeout) { this.host = host; this.port = port; this.databaseName = databaseName; @@ -235,6 +250,7 @@ private PostgresConfiguration(String host, int port, String databaseName, String this.nonRLSCredential = nonRLSCredential; this.rowLevelSecurityEnabled = rowLevelSecurityEnabled; this.sslMode = sslMode; + this.jooqReactiveTimeout = jooqReactiveTimeout; } public String getHost() { @@ -269,9 +285,13 @@ public SSLMode getSslMode() { return sslMode; } + public Duration getJooqReactiveTimeout() { + return jooqReactiveTimeout; + } + @Override public final int hashCode() { - return Objects.hash(host, port, databaseName, databaseSchema, credential, nonRLSCredential, rowLevelSecurityEnabled, sslMode); + return Objects.hash(host, port, databaseName, databaseSchema, credential, nonRLSCredential, rowLevelSecurityEnabled, sslMode, jooqReactiveTimeout); } @Override @@ -286,7 +306,8 @@ public final boolean equals(Object o) { && Objects.equals(this.nonRLSCredential, that.nonRLSCredential) && Objects.equals(this.databaseName, that.databaseName) && Objects.equals(this.databaseSchema, that.databaseSchema) - && Objects.equals(this.sslMode, that.sslMode); + && Objects.equals(this.sslMode, that.sslMode) + && Objects.equals(this.jooqReactiveTimeout, that.jooqReactiveTimeout); } return false; } diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index 4bfb730ab0c..ed192af4288 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -24,11 +24,13 @@ import java.time.Duration; import java.util.Optional; +import java.util.concurrent.TimeoutException; import java.util.function.Function; import java.util.function.Predicate; import javax.inject.Inject; +import org.apache.james.backends.postgres.PostgresConfiguration; import org.apache.james.core.Domain; import org.jooq.DSLContext; import org.jooq.DeleteResultStep; @@ -40,6 +42,8 @@ import org.jooq.conf.StatementType; import org.jooq.impl.DSL; import org.reactivestreams.Publisher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.google.common.annotations.VisibleForTesting; @@ -55,18 +59,23 @@ public class PostgresExecutor { public static final String NON_RLS_INJECT = "non_rls"; public static final int MAX_RETRY_ATTEMPTS = 5; public static final Duration MIN_BACKOFF = Duration.ofMillis(1); + private static final Logger LOGGER = LoggerFactory.getLogger(PostgresExecutor.class); + private static final String JOOQ_TIMEOUT_ERROR_LOG = "Time out executing Postgres query. May need to check either jOOQ reactive issue or Postgres DB performance."; public static class Factory { private final JamesPostgresConnectionFactory jamesPostgresConnectionFactory; + private final PostgresConfiguration postgresConfiguration; @Inject - public Factory(JamesPostgresConnectionFactory jamesPostgresConnectionFactory) { + public Factory(JamesPostgresConnectionFactory jamesPostgresConnectionFactory, + PostgresConfiguration postgresConfiguration) { this.jamesPostgresConnectionFactory = jamesPostgresConnectionFactory; + this.postgresConfiguration = postgresConfiguration; } public PostgresExecutor create(Optional domain) { - return new PostgresExecutor(jamesPostgresConnectionFactory.getConnection(domain)); + return new PostgresExecutor(jamesPostgresConnectionFactory.getConnection(domain), postgresConfiguration); } public PostgresExecutor create() { @@ -78,10 +87,14 @@ public PostgresExecutor create() { private static final Settings SETTINGS = new Settings() .withRenderFormatted(true) .withStatementType(StatementType.PREPARED_STATEMENT); + private final Mono connection; + private final PostgresConfiguration postgresConfiguration; - private PostgresExecutor(Mono connection) { + private PostgresExecutor(Mono connection, + PostgresConfiguration postgresConfiguration) { this.connection = connection; + this.postgresConfiguration = postgresConfiguration; } public Mono dslContext() { @@ -91,6 +104,8 @@ public Mono dslContext() { public Mono executeVoid(Function> queryFunction) { return dslContext() .flatMap(queryFunction) + .timeout(postgresConfiguration.getJooqReactiveTimeout()) + .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) .filter(preparedStatementConflictException())) .then(); @@ -99,6 +114,8 @@ public Mono executeVoid(Function> queryFunction) { public Flux executeRows(Function> queryFunction) { return dslContext() .flatMapMany(queryFunction) + .timeout(postgresConfiguration.getJooqReactiveTimeout()) + .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) .filter(preparedStatementConflictException())); } @@ -106,6 +123,8 @@ public Flux executeRows(Function> queryFunction public Flux executeDeleteAndReturnList(Function> queryFunction) { return dslContext() .flatMapMany(queryFunction) + .timeout(postgresConfiguration.getJooqReactiveTimeout()) + .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) .collectList() .flatMapIterable(list -> list) // The convert Flux -> Mono -> Flux to avoid a hanging issue. See: https://github.com/jOOQ/jOOQ/issues/16055 .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) @@ -115,6 +134,8 @@ public Flux executeDeleteAndReturnList(Function executeRow(Function> queryFunction) { return dslContext() .flatMap(queryFunction.andThen(Mono::from)) + .timeout(postgresConfiguration.getJooqReactiveTimeout()) + .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) .filter(preparedStatementConflictException())); } @@ -128,6 +149,8 @@ public Mono> executeSingleRowOptional(Function executeCount(Function>> queryFunction) { return dslContext() .flatMap(queryFunction) + .timeout(postgresConfiguration.getJooqReactiveTimeout()) + .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) .filter(preparedStatementConflictException())) .map(Record1::value1); @@ -141,6 +164,8 @@ public Mono executeExists(Function> public Mono executeReturnAffectedRowsCount(Function> queryFunction) { return dslContext() .flatMap(queryFunction) + .timeout(postgresConfiguration.getJooqReactiveTimeout()) + .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) .filter(preparedStatementConflictException())); } diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index 1f6f0a200cd..88c21244ce9 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -139,11 +139,12 @@ private void initPostgresSession() { .build()); if (rlsEnabled) { - executorFactory = new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(connectionFactory)); + executorFactory = new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(connectionFactory), postgresConfiguration); } else { executorFactory = new PostgresExecutor.Factory(new SinglePostgresConnectionFactory(connectionFactory.create() .cache() - .cast(Connection.class).block())); + .cast(Connection.class).block()), + postgresConfiguration); } postgresExecutor = executorFactory.create(); @@ -153,7 +154,7 @@ private void initPostgresSession() { .password(postgresConfiguration.getNonRLSCredential().getPassword()) .build()) .flatMap(configuration -> new PostgresqlConnectionFactory(configuration).create().cache()) - .map(connection -> new PostgresExecutor.Factory(new SinglePostgresConnectionFactory(connection)).create()) + .map(connection -> new PostgresExecutor.Factory(new SinglePostgresConnectionFactory(connection), postgresConfiguration).create()) .block(); } else { nonRLSPostgresExecutor = postgresExecutor; @@ -225,6 +226,10 @@ public PostgresExecutor.Factory getExecutorFactory() { return executorFactory; } + public PostgresConfiguration getPostgresConfiguration() { + return postgresConfiguration; + } + private void initTablesAndIndexes() { postgresTableManager.initializeTables().block(); postgresTableManager.initializeTableIndexes().block(); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperRowLevelSecurityTest.java index 826201eea7b..c6dced4ffef 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperRowLevelSecurityTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperRowLevelSecurityTest.java @@ -72,7 +72,8 @@ private MailboxId generateMailboxId() { @BeforeEach public void setUp() { BlobId.Factory blobIdFactory = new HashBlobId.Factory(); - postgresMailboxSessionMapperFactory = new PostgresMailboxSessionMapperFactory(new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory())), + postgresMailboxSessionMapperFactory = new PostgresMailboxSessionMapperFactory(new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory()), + postgresExtension.getPostgresConfiguration()), new UpdatableTickingClock(Instant.now()), new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory), blobIdFactory); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperRowLevelSecurityTest.java index bdf719dfe23..4680c58d36c 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperRowLevelSecurityTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperRowLevelSecurityTest.java @@ -44,7 +44,8 @@ public class PostgresMailboxMapperRowLevelSecurityTest { @BeforeEach public void setUp() { - PostgresExecutor.Factory executorFactory = new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory())); + PostgresExecutor.Factory executorFactory = new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory()), + postgresExtension.getPostgresConfiguration()); mailboxMapperFactory = session -> new PostgresMailboxMapper(new PostgresMailboxDAO(executorFactory.create(session.getUser().getDomainPart()))); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java index 43601491e0b..6b472e615e7 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java @@ -79,7 +79,8 @@ private Mailbox generateMailbox() { @BeforeEach public void setUp() { BlobId.Factory blobIdFactory = new HashBlobId.Factory(); - postgresMailboxSessionMapperFactory = new PostgresMailboxSessionMapperFactory(new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory())), + postgresMailboxSessionMapperFactory = new PostgresMailboxSessionMapperFactory(new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory()), + postgresExtension.getPostgresConfiguration()), new UpdatableTickingClock(Instant.now()), new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory), blobIdFactory); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java index acd0bb2cef5..daf676b6643 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java @@ -41,7 +41,8 @@ public class PostgresSubscriptionMapperRowLevelSecurityTest { @BeforeEach public void setUp() { - PostgresExecutor.Factory executorFactory = new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory())); + PostgresExecutor.Factory executorFactory = new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory()), + postgresExtension.getPostgresConfiguration()); subscriptionMapperFactory = session -> new PostgresSubscriptionMapper(new PostgresSubscriptionDAO(executorFactory.create(session.getUser().getDomainPart()))); } diff --git a/server/apps/postgres-app/sample-configuration/postgres.properties b/server/apps/postgres-app/sample-configuration/postgres.properties index 36512aa7574..8710adb814b 100644 --- a/server/apps/postgres-app/sample-configuration/postgres.properties +++ b/server/apps/postgres-app/sample-configuration/postgres.properties @@ -27,4 +27,7 @@ row.level.security.enabled=false # String. Optional, defaults to allow. SSLMode required to connect to the Postgresql db server. # Check https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-PROTECTION for a list of supported SSLModes. -ssl.mode=allow \ No newline at end of file +ssl.mode=allow + +## Duration. Optional, defaults to 10 second. jOOQ reactive timeout when executing Postgres query. This setting prevent jooq reactive bug from causing hanging issue. +#jooq.reactive.timeout=10second \ No newline at end of file diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index bc03e224eeb..a65fa05b361 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -140,8 +140,9 @@ PostgresTableManager postgresTableManager(PostgresExecutor postgresExecutor, @Provides @Named(PostgresExecutor.NON_RLS_INJECT) @Singleton - PostgresExecutor.Factory postgresExecutorFactoryWithRLSBypass(@Named(PostgresExecutor.NON_RLS_INJECT) JamesPostgresConnectionFactory singlePostgresConnectionFactory) { - return new PostgresExecutor.Factory(singlePostgresConnectionFactory); + PostgresExecutor.Factory postgresExecutorFactoryWithRLSBypass(@Named(PostgresExecutor.NON_RLS_INJECT) JamesPostgresConnectionFactory singlePostgresConnectionFactory, + PostgresConfiguration postgresConfiguration) { + return new PostgresExecutor.Factory(singlePostgresConnectionFactory, postgresConfiguration); } @Provides From a34ab8287af9503172f99fd73080d443818b386f Mon Sep 17 00:00:00 2001 From: Quan Tran Date: Mon, 8 Apr 2024 10:16:32 +0700 Subject: [PATCH 293/341] JAMES-2586 Mitigate fix for https://github.com/jOOQ/jOOQ/issues/16556 jooq reactive bug: SELECT many records then query something else would hang This is a temporary fix in the meantime waiting for the jooq fix. Bear in mind that `.collectList` lot of elements could impact performance. --- .../apache/james/backends/postgres/utils/PostgresExecutor.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index ed192af4288..cb63a7e4f7f 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -116,6 +116,8 @@ public Flux executeRows(Function> queryFunction .flatMapMany(queryFunction) .timeout(postgresConfiguration.getJooqReactiveTimeout()) .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) + .collectList() + .flatMapIterable(list -> list) // Mitigation fix for https://github.com/jOOQ/jOOQ/issues/16556 .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) .filter(preparedStatementConflictException())); } From 0d9efec25569fea462a1c4ba421e46384c6884c1 Mon Sep 17 00:00:00 2001 From: hung phan Date: Wed, 10 Apr 2024 11:57:00 +0700 Subject: [PATCH 294/341] JAMES-2586 Create metrics for PostgresExecutor --- backends-common/postgres/pom.xml | 4 + .../postgres/utils/PostgresExecutor.java | 103 ++++++++++-------- .../backends/postgres/PostgresExtension.java | 8 +- ...sAnnotationMapperRowLevelSecurityTest.java | 3 +- ...gresMailboxMapperRowLevelSecurityTest.java | 4 +- ...gresMessageMapperRowLevelSecurityTest.java | 3 +- ...ubscriptionMapperRowLevelSecurityTest.java | 3 +- .../modules/data/PostgresCommonModule.java | 6 +- 8 files changed, 80 insertions(+), 54 deletions(-) diff --git a/backends-common/postgres/pom.xml b/backends-common/postgres/pom.xml index 437c49bd538..b3477faa88b 100644 --- a/backends-common/postgres/pom.xml +++ b/backends-common/postgres/pom.xml @@ -52,6 +52,10 @@ ${james.groupId} james-server-util + + ${james.groupId} + metrics-api + ${james.groupId} testing-base diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index cb63a7e4f7f..879708aad7c 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -32,6 +32,7 @@ import org.apache.james.backends.postgres.PostgresConfiguration; import org.apache.james.core.Domain; +import org.apache.james.metrics.api.MetricFactory; import org.jooq.DSLContext; import org.jooq.DeleteResultStep; import org.jooq.Record; @@ -66,16 +67,19 @@ public static class Factory { private final JamesPostgresConnectionFactory jamesPostgresConnectionFactory; private final PostgresConfiguration postgresConfiguration; + private final MetricFactory metricFactory; @Inject public Factory(JamesPostgresConnectionFactory jamesPostgresConnectionFactory, - PostgresConfiguration postgresConfiguration) { + PostgresConfiguration postgresConfiguration, + MetricFactory metricFactory) { this.jamesPostgresConnectionFactory = jamesPostgresConnectionFactory; this.postgresConfiguration = postgresConfiguration; + this.metricFactory = metricFactory; } public PostgresExecutor create(Optional domain) { - return new PostgresExecutor(jamesPostgresConnectionFactory.getConnection(domain), postgresConfiguration); + return new PostgresExecutor(jamesPostgresConnectionFactory.getConnection(domain), postgresConfiguration, metricFactory); } public PostgresExecutor create() { @@ -90,11 +94,14 @@ public PostgresExecutor create() { private final Mono connection; private final PostgresConfiguration postgresConfiguration; + private final MetricFactory metricFactory; private PostgresExecutor(Mono connection, - PostgresConfiguration postgresConfiguration) { + PostgresConfiguration postgresConfiguration, + MetricFactory metricFactory) { this.connection = connection; this.postgresConfiguration = postgresConfiguration; + this.metricFactory = metricFactory; } public Mono dslContext() { @@ -102,44 +109,48 @@ public Mono dslContext() { } public Mono executeVoid(Function> queryFunction) { - return dslContext() - .flatMap(queryFunction) - .timeout(postgresConfiguration.getJooqReactiveTimeout()) - .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) - .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) - .filter(preparedStatementConflictException())) - .then(); + return Mono.from(metricFactory.decoratePublisherWithTimerMetric("postgres-execution", + dslContext() + .flatMap(queryFunction) + .timeout(postgresConfiguration.getJooqReactiveTimeout()) + .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) + .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) + .filter(preparedStatementConflictException())) + .then())); } public Flux executeRows(Function> queryFunction) { - return dslContext() - .flatMapMany(queryFunction) - .timeout(postgresConfiguration.getJooqReactiveTimeout()) - .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) - .collectList() - .flatMapIterable(list -> list) // Mitigation fix for https://github.com/jOOQ/jOOQ/issues/16556 - .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) - .filter(preparedStatementConflictException())); + return Flux.from(metricFactory.decoratePublisherWithTimerMetric("postgres-execution", + dslContext() + .flatMapMany(queryFunction) + .timeout(postgresConfiguration.getJooqReactiveTimeout()) + .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) + .collectList() + .flatMapIterable(list -> list) // Mitigation fix for https://github.com/jOOQ/jOOQ/issues/16556 + .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) + .filter(preparedStatementConflictException())))); } public Flux executeDeleteAndReturnList(Function> queryFunction) { - return dslContext() - .flatMapMany(queryFunction) - .timeout(postgresConfiguration.getJooqReactiveTimeout()) - .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) - .collectList() - .flatMapIterable(list -> list) // The convert Flux -> Mono -> Flux to avoid a hanging issue. See: https://github.com/jOOQ/jOOQ/issues/16055 - .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) - .filter(preparedStatementConflictException())); + return Flux.from(metricFactory.decoratePublisherWithTimerMetric("postgres-execution", + dslContext() + .flatMapMany(queryFunction) + .timeout(postgresConfiguration.getJooqReactiveTimeout()) + .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) + .collectList() + .flatMapIterable(list -> list) // The convert Flux -> Mono -> Flux to avoid a hanging issue. See: https://github.com/jOOQ/jOOQ/issues/16055 + .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) + .filter(preparedStatementConflictException())))); } public Mono executeRow(Function> queryFunction) { - return dslContext() - .flatMap(queryFunction.andThen(Mono::from)) - .timeout(postgresConfiguration.getJooqReactiveTimeout()) - .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) - .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) - .filter(preparedStatementConflictException())); + return Mono.from(metricFactory.decoratePublisherWithTimerMetric("postgres-execution", + dslContext() + .flatMap(queryFunction.andThen(Mono::from)) + .timeout(postgresConfiguration.getJooqReactiveTimeout()) + .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) + .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) + .filter(preparedStatementConflictException())))); } public Mono> executeSingleRowOptional(Function> queryFunction) { @@ -149,13 +160,14 @@ public Mono> executeSingleRowOptional(Function executeCount(Function>> queryFunction) { - return dslContext() - .flatMap(queryFunction) - .timeout(postgresConfiguration.getJooqReactiveTimeout()) - .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) - .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) - .filter(preparedStatementConflictException())) - .map(Record1::value1); + return Mono.from(metricFactory.decoratePublisherWithTimerMetric("postgres-execution", + dslContext() + .flatMap(queryFunction) + .timeout(postgresConfiguration.getJooqReactiveTimeout()) + .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) + .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) + .filter(preparedStatementConflictException())) + .map(Record1::value1))); } public Mono executeExists(Function> queryFunction) { @@ -164,12 +176,13 @@ public Mono executeExists(Function> } public Mono executeReturnAffectedRowsCount(Function> queryFunction) { - return dslContext() - .flatMap(queryFunction) - .timeout(postgresConfiguration.getJooqReactiveTimeout()) - .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) - .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) - .filter(preparedStatementConflictException())); + return Mono.from(metricFactory.decoratePublisherWithTimerMetric("postgres-execution", + dslContext() + .flatMap(queryFunction) + .timeout(postgresConfiguration.getJooqReactiveTimeout()) + .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) + .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) + .filter(preparedStatementConflictException())))); } public Mono connection() { diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index 88c21244ce9..35a4e9b33ba 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -30,6 +30,7 @@ import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.backends.postgres.utils.SinglePostgresConnectionFactory; +import org.apache.james.metrics.tests.RecordingMetricFactory; import org.junit.jupiter.api.extension.ExtensionContext; import org.testcontainers.containers.PostgreSQLContainer; @@ -139,12 +140,13 @@ private void initPostgresSession() { .build()); if (rlsEnabled) { - executorFactory = new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(connectionFactory), postgresConfiguration); + executorFactory = new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(connectionFactory), postgresConfiguration, new RecordingMetricFactory()); } else { executorFactory = new PostgresExecutor.Factory(new SinglePostgresConnectionFactory(connectionFactory.create() .cache() .cast(Connection.class).block()), - postgresConfiguration); + postgresConfiguration, + new RecordingMetricFactory()); } postgresExecutor = executorFactory.create(); @@ -154,7 +156,7 @@ private void initPostgresSession() { .password(postgresConfiguration.getNonRLSCredential().getPassword()) .build()) .flatMap(configuration -> new PostgresqlConnectionFactory(configuration).create().cache()) - .map(connection -> new PostgresExecutor.Factory(new SinglePostgresConnectionFactory(connection), postgresConfiguration).create()) + .map(connection -> new PostgresExecutor.Factory(new SinglePostgresConnectionFactory(connection), postgresConfiguration, new RecordingMetricFactory()).create()) .block(); } else { nonRLSPostgresExecutor = postgresExecutor; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperRowLevelSecurityTest.java index c6dced4ffef..f23a8c031f8 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperRowLevelSecurityTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperRowLevelSecurityTest.java @@ -42,6 +42,7 @@ import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; import org.apache.james.mailbox.store.mail.MailboxMapper; +import org.apache.james.metrics.tests.RecordingMetricFactory; import org.apache.james.server.blob.deduplication.DeDuplicationBlobStore; import org.apache.james.utils.UpdatableTickingClock; import org.junit.jupiter.api.BeforeEach; @@ -73,7 +74,7 @@ private MailboxId generateMailboxId() { public void setUp() { BlobId.Factory blobIdFactory = new HashBlobId.Factory(); postgresMailboxSessionMapperFactory = new PostgresMailboxSessionMapperFactory(new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory()), - postgresExtension.getPostgresConfiguration()), + postgresExtension.getPostgresConfiguration(), new RecordingMetricFactory()), new UpdatableTickingClock(Instant.now()), new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory), blobIdFactory); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperRowLevelSecurityTest.java index 4680c58d36c..cfc7dcc1d16 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperRowLevelSecurityTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperRowLevelSecurityTest.java @@ -31,6 +31,7 @@ import org.apache.james.mailbox.model.UidValidity; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; import org.apache.james.mailbox.store.mail.MailboxMapperFactory; +import org.apache.james.metrics.tests.RecordingMetricFactory; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -45,7 +46,8 @@ public class PostgresMailboxMapperRowLevelSecurityTest { @BeforeEach public void setUp() { PostgresExecutor.Factory executorFactory = new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory()), - postgresExtension.getPostgresConfiguration()); + postgresExtension.getPostgresConfiguration(), + new RecordingMetricFactory()); mailboxMapperFactory = session -> new PostgresMailboxMapper(new PostgresMailboxDAO(executorFactory.create(session.getUser().getDomainPart()))); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java index 6b472e615e7..55743bafb6f 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java @@ -50,6 +50,7 @@ import org.apache.james.mailbox.store.mail.model.MailboxMessage; import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; +import org.apache.james.metrics.tests.RecordingMetricFactory; import org.apache.james.server.blob.deduplication.DeDuplicationBlobStore; import org.apache.james.utils.UpdatableTickingClock; import org.junit.jupiter.api.BeforeEach; @@ -80,7 +81,7 @@ private Mailbox generateMailbox() { public void setUp() { BlobId.Factory blobIdFactory = new HashBlobId.Factory(); postgresMailboxSessionMapperFactory = new PostgresMailboxSessionMapperFactory(new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory()), - postgresExtension.getPostgresConfiguration()), + postgresExtension.getPostgresConfiguration(), new RecordingMetricFactory()), new UpdatableTickingClock(Instant.now()), new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory), blobIdFactory); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java index daf676b6643..a28bd34087f 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java @@ -29,6 +29,7 @@ import org.apache.james.mailbox.MailboxSessionUtil; import org.apache.james.mailbox.store.user.SubscriptionMapperFactory; import org.apache.james.mailbox.store.user.model.Subscription; +import org.apache.james.metrics.tests.RecordingMetricFactory; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -42,7 +43,7 @@ public class PostgresSubscriptionMapperRowLevelSecurityTest { @BeforeEach public void setUp() { PostgresExecutor.Factory executorFactory = new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory()), - postgresExtension.getPostgresConfiguration()); + postgresExtension.getPostgresConfiguration(), new RecordingMetricFactory()); subscriptionMapperFactory = session -> new PostgresSubscriptionMapper(new PostgresSubscriptionDAO(executorFactory.create(session.getUser().getDomainPart()))); } diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index a65fa05b361..a2e882f1e75 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -34,6 +34,7 @@ import org.apache.james.backends.postgres.utils.PostgresHealthCheck; import org.apache.james.backends.postgres.utils.SinglePostgresConnectionFactory; import org.apache.james.core.healthcheck.HealthCheck; +import org.apache.james.metrics.api.MetricFactory; import org.apache.james.utils.InitializationOperation; import org.apache.james.utils.InitilizationOperationBuilder; import org.apache.james.utils.PropertiesProvider; @@ -141,8 +142,9 @@ PostgresTableManager postgresTableManager(PostgresExecutor postgresExecutor, @Named(PostgresExecutor.NON_RLS_INJECT) @Singleton PostgresExecutor.Factory postgresExecutorFactoryWithRLSBypass(@Named(PostgresExecutor.NON_RLS_INJECT) JamesPostgresConnectionFactory singlePostgresConnectionFactory, - PostgresConfiguration postgresConfiguration) { - return new PostgresExecutor.Factory(singlePostgresConnectionFactory, postgresConfiguration); + PostgresConfiguration postgresConfiguration, + MetricFactory metricFactory) { + return new PostgresExecutor.Factory(singlePostgresConnectionFactory, postgresConfiguration, metricFactory); } @Provides From 69e16d3d78562d684a41f1dd99419dad0eccd359 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Tue, 16 Apr 2024 09:51:05 +0700 Subject: [PATCH 295/341] JAMES-2586 Update postgresql guice binding - adapt after rebase master (remove Jmap draft) --- server/apps/postgres-app/pom.xml | 6 -- .../james/PostgresJamesConfiguration.java | 2 +- .../apache/james/PostgresJamesServerMain.java | 2 +- .../james/PostgresJmapJamesServerTest.java | 2 +- .../src/test/resources/eml/htmlMail.eml | 81 +++++++++++++++++++ .../modules/data/PostgresDataJmapModule.java | 7 +- 6 files changed, 85 insertions(+), 15 deletions(-) create mode 100644 server/apps/postgres-app/src/test/resources/eml/htmlMail.eml diff --git a/server/apps/postgres-app/pom.xml b/server/apps/postgres-app/pom.xml index 0f0478a6a91..cc5c8feeff4 100644 --- a/server/apps/postgres-app/pom.xml +++ b/server/apps/postgres-app/pom.xml @@ -216,12 +216,6 @@ ${james.groupId} james-server-guice-webadmin-mailrepository - - ${james.groupId} - james-server-jmap-draft-integration-testing - test-jar - test - ${james.groupId} james-server-mailbox-adapter diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java index 4562716d296..c4af2fdb497 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java @@ -27,7 +27,7 @@ import org.apache.james.data.UsersRepositoryModuleChooser; import org.apache.james.filesystem.api.FileSystem; import org.apache.james.filesystem.api.JamesDirectoriesProvider; -import org.apache.james.jmap.draft.JMAPModule; +import org.apache.james.jmap.JMAPModule; import org.apache.james.modules.blobstore.BlobStoreConfiguration; import org.apache.james.server.core.JamesServerResourceLoader; import org.apache.james.server.core.MissingArgumentException; diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index 30271bf3733..cbd48190686 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -25,7 +25,7 @@ import org.apache.james.data.UsersRepositoryModuleChooser; import org.apache.james.eventsourcing.eventstore.EventNestedTypes; -import org.apache.james.jmap.draft.JMAPListenerModule; +import org.apache.james.jmap.JMAPListenerModule; import org.apache.james.json.DTO; import org.apache.james.json.DTOModule; import org.apache.james.modules.BlobExportMechanismModule; diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJmapJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJmapJamesServerTest.java index da28ff401b6..e0ba197e70b 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJmapJamesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresJmapJamesServerTest.java @@ -22,7 +22,7 @@ import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.jmap.draft.JmapJamesServerContract; +import org.apache.james.jmap.JmapJamesServerContract; import org.apache.james.modules.TestJMAPServerModule; import org.apache.james.vault.VaultConfiguration; import org.junit.jupiter.api.extension.RegisterExtension; diff --git a/server/apps/postgres-app/src/test/resources/eml/htmlMail.eml b/server/apps/postgres-app/src/test/resources/eml/htmlMail.eml new file mode 100644 index 00000000000..c8213f4d7ea --- /dev/null +++ b/server/apps/postgres-app/src/test/resources/eml/htmlMail.eml @@ -0,0 +1,81 @@ +Delivered-To: mister@james.org +Received: by 10.28.170.202 with SMTP id t193csp327634wme; + Thu, 4 Jun 2015 00:36:15 -0700 (PDT) +X-Received: by 10.180.77.195 with SMTP id u3mr5042880wiw.30.1433403375307; + Thu, 04 Jun 2015 00:36:15 -0700 (PDT) +Return-Path: +Received: from o7.email.airbnb.com (o7.email.airbnb.com. [167.89.32.249]) + by mx.google.com with ESMTPS id i2si5691730wjz.123.2015.06.04.00.36.13 + for + (version=TLSv1.2 cipher=ECDHE-RSA-AES128-GCM-SHA256 bits=128/128); + Thu, 04 Jun 2015 00:36:15 -0700 (PDT) +Received-SPF: pass (google.com: domain of bounces+1453977-062b-mister=james.org@email.airbnb.com designates 167.89.32.249 as permitted sender) client-ip=167.89.32.249; +Authentication-Results: mx.google.com; + spf=pass (google.com: domain of bounces+1453977-062b-mister=james.org@email.airbnb.com designates 167.89.32.249 as permitted sender) smtp.mail=bounces+1453977-062b-mister=james.org@email.airbnb.com; + dkim=pass header.i=@email.airbnb.com; + dmarc=pass (p=REJECT dis=NONE) header.from=airbnb.com +DKIM-Signature: v=1; a=rsa-sha1; c=relaxed; d=email.airbnb.com; + h=from:to:subject:mime-version:content-type:content-transfer-encoding; + s=s20150428; bh=2mhWUwzjtQTC0KljgpaEsuvrqok=; b=EhC2QHKb5+63egDD + qDCAepUELCeUZXCkw8+31kGT+O1va3iAKvQSAvzXJ3qJlIL9FgdeFk8sR78Vszn/ + A73vp6NGjAW60M4gUZjxEOIPzGKIS95KfmHxg10fXUOFW0uePojNEg4ZPCcuitrZ + HuWvzHK5I2siGnqupiivwxDgs5c= +DKIM-Signature: v=1; a=rsa-sha1; c=relaxed; d=sendgrid.info; + h=from:to:subject:mime-version:content-type:content-transfer-encoding:x-feedback-id; + s=smtpapi; bh=2mhWUwzjtQTC0KljgpaEsuvrqok=; b=FPiYMmNJLCrL2e8v/0 + DQC4voofe8nuuE7rhXZ25oqNAhAQja4oKIysJ1qAME2aEaqh+N5aJlCEuHrSG/7+ + NAQ0OY8KaJ2zlnxAbmgJETOjnf4oGdAa+nU/nVVEPfN2NRcBCNLGQZ80U4T5k8Xi + PakIuZvKDTRq7PiosSCSHT/FQ= +Received: by filter0490p1mdw1.sendgrid.net with SMTP id filter0490p1mdw1.13271.556FFFE7B + 2015-06-04 07:36:09.249601779 +0000 UTC +Received: from i-dee0850e.inst.aws.airbnb.com (ec2-54-90-154-187.compute-1.amazonaws.com [54.90.154.187]) + by ismtpd-017 (SG) with ESMTP id 14dbd7fa6b4.779a.254b43 + for ; Thu, 04 Jun 2015 07:36:09 +0000 (UTC) +Received: by i-dee0850e.inst.aws.airbnb.com (Postfix, from userid 1041) + id 19CBA24C60; Thu, 4 Jun 2015 07:36:09 +0000 (UTC) +Date: Thu, 04 Jun 2015 07:36:08 +0000 +From: Airbnb +To: mister@james.org +Message-ID: <556fffe8cac78_7ed0e0fe204457be@i-dee0850e.mail> +Subject: Text and Html not similar +Mime-Version: 1.0 +Content-Type: multipart/alternative; + boundary="--==_mimepart_556fffe8c7e84_7ed0e0fe20445637"; + charset=UTF-8 +Content-Transfer-Encoding: 7bit +X-User-ID: 32692788 +X-Locale: fr +X-Category: engagement +X-Template: low_intent_top_destinations +recipients: +sent-on: +X-SG-EID: mgVKhb3i1xMIKbRk82EYOUTMOPmiNk6g5BaWGQQKDTQchtClhw7VcIxig2BMwy1QMCr7h56hNVush8 + 4aRV0ieMn+WZ1XVnpY0OcmMYNZnuuvlOoNkBaiuiqeWuKVZO9c9S5OyxPy7WQeI0mccenq35NpLqjI + nQt7IAl2sIUksUD4lM8Ai0u2C88YW13cL+Lo +X-SG-ID: pQ7zy0fBcyQB3Gm22dZtqT6AR3zbAquH5ABZFkQfSKaxWRhz0YhtD36Li5uybRUjnPsuB21NpreKvG + t8iQBUn2ygs6hx6sMcgyI7L7bAY28p14Qj47KqA3JXbtMa0Xa3wdZaUUjZpemCg078XxMM5VaSHdDO + ChUhSV+z9RAJ38wAdUfXkpbO+m97vpU+mtWzVBoOrSiWCVYoNxPhvE4yIQ== +X-Feedback-ID: 1453977:N5+DXWRfRBXSDDbqVYXPNg8MjWYWwZibliGo1i/oSqY=:Ibl/atjs+SOcHCINmWWv/YvIGzDUihUrks9jdHsNF1+pafkc987UhcxmuyggxNgdCkEmMZDb9gJcndcUJy5KOw==:SG + +----==_mimepart_556fffe8c7e84_7ed0e0fe20445637 +Content-Type: text/plain; + charset=UTF-8 +Content-Transfer-Encoding: quoted-printable + +The text/plain part is not matching the html one. + +----==_mimepart_556fffe8c7e84_7ed0e0fe20445637 +Content-Type: text/html; charset=utf-8 +Content-Transfer-Encoding: 7bit + + + + + + + + This is a mail with beautifull html content which contains a banana.
    + + + +----==_mimepart_556fffe8c7e84_7ed0e0fe20445637-- diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java index b2ffdd3bfeb..b9a34ab1941 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDataJmapModule.java @@ -20,7 +20,6 @@ package org.apache.james.modules.data; import org.apache.james.core.healthcheck.HealthCheck; -import org.apache.james.jmap.api.access.AccessTokenRepository; import org.apache.james.jmap.api.filtering.FilteringManagement; import org.apache.james.jmap.api.filtering.FiltersDeleteUserDataTaskStep; import org.apache.james.jmap.api.filtering.impl.EventSourcingFilteringManagement; @@ -33,7 +32,6 @@ import org.apache.james.jmap.api.projections.MessageFastViewProjectionHealthCheck; import org.apache.james.jmap.api.pushsubscription.PushDeleteUserDataTaskStep; import org.apache.james.jmap.api.upload.UploadRepository; -import org.apache.james.jmap.memory.access.MemoryAccessTokenRepository; import org.apache.james.jmap.postgres.filtering.PostgresFilteringProjection; import org.apache.james.jmap.postgres.identity.PostgresCustomIdentityDAO; import org.apache.james.jmap.postgres.projections.PostgresEmailQueryView; @@ -52,16 +50,13 @@ public class PostgresDataJmapModule extends AbstractModule { @Override protected void configure() { - bind(MemoryAccessTokenRepository.class).in(Scopes.SINGLETON); - bind(AccessTokenRepository.class).to(MemoryAccessTokenRepository.class); - bind(UploadRepository.class).to(PostgresUploadRepository.class); bind(PostgresCustomIdentityDAO.class).in(Scopes.SINGLETON); bind(CustomIdentityDAO.class).to(PostgresCustomIdentityDAO.class); bind(EventSourcingFilteringManagement.class).in(Scopes.SINGLETON); - bind(FilteringManagement.class).to(EventSourcingFilteringManagement.class); + bind(FilteringManagement.class).to(EventSourcingFilteringManagement.class).asEagerSingleton(); bind(PostgresFilteringProjection.class).in(Scopes.SINGLETON); bind(EventSourcingFilteringManagement.ReadProjection.class).to(PostgresFilteringProjection.class); From 3060e18a5dbcb7cae10f929019acf7f5e59fa8d1 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Tue, 16 Apr 2024 09:56:47 +0700 Subject: [PATCH 296/341] JAMES-2586 [UPDATE] [PGSQL] more javax APIs migrated to jakarta --- backends-common/postgres/pom.xml | 8 ++++++-- .../james/backends/postgres/PostgresTableManager.java | 2 +- .../postgres/quota/PostgresQuotaCurrentValueDAO.java | 4 ++-- .../backends/postgres/quota/PostgresQuotaLimitDAO.java | 4 ++-- .../utils/DomainImplPostgresConnectionFactory.java | 2 +- .../james/backends/postgres/utils/PostgresExecutor.java | 2 +- .../backends/postgres/utils/PostgresHealthCheck.java | 2 +- .../org/apache/james/events/PostgresEventDeadLetters.java | 2 +- .../eventstore/postgres/PostgresEventStore.java | 2 +- .../eventstore/postgres/PostgresEventStoreDAO.java | 2 +- .../metadata/DeletedMessageVaultDeletionCallback.java | 2 +- .../metadata/PostgresDeletedMessageMetadataVault.java | 2 +- .../james/mailbox/postgres/DeleteMessageListener.java | 2 +- .../james/mailbox/postgres/PostgresMailboxManager.java | 2 +- .../postgres/PostgresMailboxSessionMapperFactory.java | 2 +- .../postgres/PostgresThreadIdGuessingAlgorithm.java | 2 +- .../mailbox/postgres/mail/PostgresAnnotationMapper.java | 2 +- .../mail/PostgresAttachmentBlobReferenceSource.java | 6 +++--- .../mailbox/postgres/mail/PostgresMailboxMapper.java | 2 +- .../postgres/mail/PostgresMessageBlobReferenceSource.java | 2 +- .../mailbox/postgres/mail/dao/PostgresAttachmentDAO.java | 4 ++-- .../postgres/mail/dao/PostgresMailboxMessageDAO.java | 5 ++--- .../mailbox/postgres/mail/dao/PostgresMessageDAO.java | 6 +++--- .../mailbox/postgres/mail/dao/PostgresThreadDAO.java | 4 ++-- .../postgres/quota/PostgresCurrentQuotaManager.java | 2 +- .../postgres/quota/PostgresPerUserMaxQuotaManager.java | 2 +- .../james/mailbox/postgres/search/AllSearchOverride.java | 2 +- .../mailbox/postgres/search/DeletedSearchOverride.java | 3 +-- .../postgres/search/DeletedWithRangeSearchOverride.java | 3 +-- .../search/NotDeletedWithRangeSearchOverride.java | 3 +-- .../james/mailbox/postgres/search/UidSearchOverride.java | 2 +- .../mailbox/postgres/search/UnseenSearchOverride.java | 4 +--- .../apache/james/blob/postgres/PostgresBlobStoreDAO.java | 2 +- .../james/modules/mailbox/PostgresMailboxModule.java | 2 +- .../james/modules/task/DistributedTaskManagerModule.java | 2 +- .../postgres/change/PostgresEmailChangeRepository.java | 4 ++-- .../postgres/change/PostgresMailboxChangeRepository.java | 4 ++-- .../postgres/filtering/PostgresFilteringProjection.java | 2 +- .../filtering/PostgresFilteringProjectionDAO.java | 2 +- .../jmap/postgres/identity/PostgresCustomIdentityDAO.java | 2 +- .../jmap/postgres/projections/PostgresEmailQueryView.java | 2 +- .../postgres/projections/PostgresEmailQueryViewDAO.java | 4 ++-- .../projections/PostgresEmailQueryViewManager.java | 2 +- .../projections/PostgresMessageFastViewProjection.java | 2 +- .../PostgresPushSubscriptionRepository.java | 4 ++-- .../james/jmap/postgres/upload/PostgresUploadDAO.java | 6 +++--- .../jmap/postgres/upload/PostgresUploadRepository.java | 4 ++-- .../postgres/upload/PostgresUploadUsageRepository.java | 4 ++-- .../api/projections/DefaultEmailQueryViewManager.java | 2 +- .../james/domainlist/postgres/PostgresDomainList.java | 4 ++-- .../mailrepository/postgres/PostgresMailRepository.java | 3 +-- .../PostgresMailRepositoryBlobReferenceSource.java | 2 +- .../postgres/PostgresMailRepositoryContentDAO.java | 3 +-- .../postgres/PostgresMailRepositoryFactory.java | 2 +- .../postgres/PostgresMailRepositoryUrlStore.java | 4 ++-- .../james/rrt/postgres/PostgresRecipientRewriteTable.java | 2 +- .../rrt/postgres/PostgresRecipientRewriteTableDAO.java | 2 +- .../james/sieve/postgres/PostgresSieveQuotaDAO.java | 2 +- .../james/sieve/postgres/PostgresSieveRepository.java | 2 +- .../james/sieve/postgres/PostgresSieveScriptDAO.java | 4 ++-- .../james/user/postgres/PostgresDelegationStore.java | 2 +- .../org/apache/james/user/postgres/PostgresUsersDAO.java | 4 ++-- .../james/user/postgres/PostgresUsersRepository.java | 2 +- .../vacation/postgres/PostgresNotificationRegistry.java | 2 +- .../vacation/postgres/PostgresVacationRepository.java | 4 ++-- .../postgres/PostgresTaskExecutionDetailsProjection.scala | 2 +- .../PostgresTaskExecutionDetailsProjectionDAO.scala | 2 +- 67 files changed, 94 insertions(+), 98 deletions(-) diff --git a/backends-common/postgres/pom.xml b/backends-common/postgres/pom.xml index b3477faa88b..6d256a2733a 100644 --- a/backends-common/postgres/pom.xml +++ b/backends-common/postgres/pom.xml @@ -62,8 +62,12 @@ test
    - javax.inject - javax.inject + jakarta.annotation + jakarta.annotation-api + + + jakarta.inject + jakarta.inject-api org.apache.commons diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java index 313bc8bc72f..5947579da1f 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java @@ -21,7 +21,7 @@ import java.util.List; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.lifecycle.api.Startable; diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java index 9205b91c265..531f58d8e27 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAO.java @@ -29,8 +29,8 @@ import java.util.function.Function; -import javax.inject.Inject; -import javax.inject.Named; +import jakarta.inject.Inject; +import jakarta.inject.Named; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.quota.QuotaComponent; diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaLimitDAO.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaLimitDAO.java index ee851a75d9f..02523bae40b 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaLimitDAO.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/quota/PostgresQuotaLimitDAO.java @@ -28,8 +28,8 @@ import static org.apache.james.backends.postgres.quota.PostgresQuotaModule.PostgresQuotaLimitTable.TABLE_NAME; import static org.apache.james.backends.postgres.utils.PostgresExecutor.DEFAULT_INJECT; -import javax.inject.Inject; -import javax.inject.Named; +import jakarta.inject.Inject; +import jakarta.inject.Named; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.quota.QuotaComponent; diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/DomainImplPostgresConnectionFactory.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/DomainImplPostgresConnectionFactory.java index 552eae74a8d..f988b4fcb11 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/DomainImplPostgresConnectionFactory.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/DomainImplPostgresConnectionFactory.java @@ -23,7 +23,7 @@ import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.core.Domain; import org.slf4j.Logger; diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index 879708aad7c..27b81cce40e 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -28,7 +28,7 @@ import java.util.function.Function; import java.util.function.Predicate; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.backends.postgres.PostgresConfiguration; import org.apache.james.core.Domain; diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresHealthCheck.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresHealthCheck.java index 40262bd88ee..2774c3bc79d 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresHealthCheck.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresHealthCheck.java @@ -21,7 +21,7 @@ import java.time.Duration; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.core.healthcheck.ComponentName; import org.apache.james.core.healthcheck.HealthCheck; diff --git a/event-bus/postgres/src/main/java/org/apache/james/events/PostgresEventDeadLetters.java b/event-bus/postgres/src/main/java/org/apache/james/events/PostgresEventDeadLetters.java index 540400266a3..01d7271cb71 100644 --- a/event-bus/postgres/src/main/java/org/apache/james/events/PostgresEventDeadLetters.java +++ b/event-bus/postgres/src/main/java/org/apache/james/events/PostgresEventDeadLetters.java @@ -24,7 +24,7 @@ import static org.apache.james.events.PostgresEventDeadLettersModule.PostgresEventDeadLettersTable.INSERTION_ID; import static org.apache.james.events.PostgresEventDeadLettersModule.PostgresEventDeadLettersTable.TABLE_NAME; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.jooq.Record; diff --git a/event-sourcing/event-store-postgres/src/main/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStore.java b/event-sourcing/event-store-postgres/src/main/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStore.java index 237744e9cbd..5d408d0ab68 100644 --- a/event-sourcing/event-store-postgres/src/main/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStore.java +++ b/event-sourcing/event-store-postgres/src/main/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStore.java @@ -24,7 +24,7 @@ import java.util.List; import java.util.Optional; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.eventsourcing.AggregateId; import org.apache.james.eventsourcing.Event; diff --git a/event-sourcing/event-store-postgres/src/main/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreDAO.java b/event-sourcing/event-store-postgres/src/main/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreDAO.java index 8cb2afb5863..cd5f8257a84 100644 --- a/event-sourcing/event-store-postgres/src/main/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreDAO.java +++ b/event-sourcing/event-store-postgres/src/main/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreDAO.java @@ -28,7 +28,7 @@ import java.util.List; import java.util.Optional; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.eventsourcing.AggregateId; diff --git a/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/DeletedMessageVaultDeletionCallback.java b/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/DeletedMessageVaultDeletionCallback.java index 36eb516a376..2c1267b5fc7 100644 --- a/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/DeletedMessageVaultDeletionCallback.java +++ b/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/DeletedMessageVaultDeletionCallback.java @@ -28,7 +28,7 @@ import java.util.Optional; import java.util.Set; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.blob.api.BlobStore; import org.apache.james.core.MailAddress; diff --git a/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/PostgresDeletedMessageMetadataVault.java b/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/PostgresDeletedMessageMetadataVault.java index 70bbd254761..7df316dc87e 100644 --- a/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/PostgresDeletedMessageMetadataVault.java +++ b/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/PostgresDeletedMessageMetadataVault.java @@ -30,7 +30,7 @@ import java.util.function.Function; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.blob.api.BlobId; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java index 22826c0cbfe..c6fc63c8684 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/DeleteMessageListener.java @@ -22,7 +22,7 @@ import java.util.Set; import java.util.function.Function; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.blob.api.BlobStore; import org.apache.james.core.Username; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxManager.java index ad9cbff57ef..bce2f957de9 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxManager.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxManager.java @@ -22,7 +22,7 @@ import java.time.Clock; import java.util.EnumSet; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.events.EventBus; import org.apache.james.mailbox.MailboxSession; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java index f2a76091205..ada3421abd9 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java @@ -20,7 +20,7 @@ import java.time.Clock; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.blob.api.BlobId; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresThreadIdGuessingAlgorithm.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresThreadIdGuessingAlgorithm.java index 5b61ab73f5e..77419e98fc1 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresThreadIdGuessingAlgorithm.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresThreadIdGuessingAlgorithm.java @@ -25,7 +25,7 @@ import java.util.Set; import java.util.stream.Collectors; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.commons.lang3.tuple.Pair; import org.apache.james.mailbox.MailboxSession; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapper.java index 867f24fdf4a..c58498be1f5 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapper.java @@ -22,7 +22,7 @@ import java.util.List; import java.util.Set; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.mailbox.model.MailboxAnnotation; import org.apache.james.mailbox.model.MailboxAnnotationKey; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentBlobReferenceSource.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentBlobReferenceSource.java index 79bc7547076..b6eae71ae29 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentBlobReferenceSource.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentBlobReferenceSource.java @@ -19,9 +19,9 @@ package org.apache.james.mailbox.postgres.mail; -import javax.inject.Inject; -import javax.inject.Named; -import javax.inject.Singleton; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.inject.Singleton; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.blob.api.BlobId; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java index a1da33a11e9..8d0c0d52662 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java @@ -21,7 +21,7 @@ import java.util.function.Function; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.core.Username; import org.apache.james.mailbox.acl.ACLDiff; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageBlobReferenceSource.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageBlobReferenceSource.java index d4136a081e5..3ea9032b298 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageBlobReferenceSource.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageBlobReferenceSource.java @@ -19,7 +19,7 @@ package org.apache.james.mailbox.postgres.mail; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.blob.api.BlobId; import org.apache.james.blob.api.BlobReferenceSource; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresAttachmentDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresAttachmentDAO.java index 9f89de648e6..2e7e37aa052 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresAttachmentDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresAttachmentDAO.java @@ -22,8 +22,8 @@ import java.util.Collection; import java.util.Optional; -import javax.inject.Inject; -import javax.inject.Singleton; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; import org.apache.commons.lang3.tuple.Pair; import org.apache.james.backends.postgres.utils.PostgresExecutor; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java index b1a403012da..59fda1c21dc 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxMessageDAO.java @@ -55,9 +55,8 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; -import javax.inject.Inject; -import javax.inject.Singleton; - +import jakarta.inject.Inject; +import jakarta.inject.Singleton; import jakarta.mail.Flags; import org.apache.commons.lang3.tuple.Pair; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java index b572d9b3ccb..8aaa3a66a11 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java @@ -46,9 +46,9 @@ import java.time.LocalDateTime; import java.util.Optional; -import javax.inject.Inject; -import javax.inject.Named; -import javax.inject.Singleton; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.inject.Singleton; import org.apache.commons.io.IOUtils; import org.apache.james.backends.postgres.utils.PostgresExecutor; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresThreadDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresThreadDAO.java index 3f5da754b1b..e561dc61941 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresThreadDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresThreadDAO.java @@ -32,8 +32,8 @@ import java.util.Set; import java.util.function.Function; -import javax.inject.Inject; -import javax.inject.Singleton; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; import org.apache.commons.lang3.tuple.Pair; import org.apache.james.backends.postgres.utils.PostgresExecutor; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/PostgresCurrentQuotaManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/PostgresCurrentQuotaManager.java index e18faa6d8bd..8617ca21318 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/PostgresCurrentQuotaManager.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/PostgresCurrentQuotaManager.java @@ -23,7 +23,7 @@ import java.util.Optional; import java.util.function.Predicate; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.backends.postgres.quota.PostgresQuotaCurrentValueDAO; import org.apache.james.core.quota.QuotaComponent; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/PostgresPerUserMaxQuotaManager.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/PostgresPerUserMaxQuotaManager.java index e39ff808e1b..b8953b75e5e 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/PostgresPerUserMaxQuotaManager.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/quota/PostgresPerUserMaxQuotaManager.java @@ -27,7 +27,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.commons.lang3.tuple.Pair; import org.apache.james.backends.postgres.quota.PostgresQuotaLimitDAO; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/AllSearchOverride.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/AllSearchOverride.java index a11a9db3d8d..8d0721805b2 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/AllSearchOverride.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/AllSearchOverride.java @@ -19,7 +19,7 @@ package org.apache.james.mailbox.postgres.search; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.mailbox.MailboxSession; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverride.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverride.java index e5354c2887e..5b1e1a47577 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverride.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverride.java @@ -19,8 +19,7 @@ package org.apache.james.mailbox.postgres.search; -import javax.inject.Inject; - +import jakarta.inject.Inject; import jakarta.mail.Flags; import org.apache.james.backends.postgres.utils.PostgresExecutor; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverride.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverride.java index ac18e038ed1..cb9710c0b5a 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverride.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverride.java @@ -19,8 +19,7 @@ package org.apache.james.mailbox.postgres.search; -import javax.inject.Inject; - +import jakarta.inject.Inject; import jakarta.mail.Flags; import org.apache.james.backends.postgres.utils.PostgresExecutor; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverride.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverride.java index 18cd8259b93..cc752d53b75 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverride.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverride.java @@ -19,8 +19,7 @@ package org.apache.james.mailbox.postgres.search; -import javax.inject.Inject; - +import jakarta.inject.Inject; import jakarta.mail.Flags; import org.apache.james.backends.postgres.utils.PostgresExecutor; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UidSearchOverride.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UidSearchOverride.java index 12e2e73e7d0..e2ea13e19d7 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UidSearchOverride.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UidSearchOverride.java @@ -19,7 +19,7 @@ package org.apache.james.mailbox.postgres.search; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.mailbox.MailboxSession; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverride.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverride.java index ede176dfd70..1ad25baafdf 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverride.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverride.java @@ -19,11 +19,9 @@ package org.apache.james.mailbox.postgres.search; - import java.util.Optional; -import javax.inject.Inject; - +import jakarta.inject.Inject; import jakarta.mail.Flags; import org.apache.james.backends.postgres.utils.PostgresExecutor; diff --git a/server/blob/blob-postgres/src/main/java/org/apache/james/blob/postgres/PostgresBlobStoreDAO.java b/server/blob/blob-postgres/src/main/java/org/apache/james/blob/postgres/PostgresBlobStoreDAO.java index 2c44b6f78a4..710bc7ab26d 100644 --- a/server/blob/blob-postgres/src/main/java/org/apache/james/blob/postgres/PostgresBlobStoreDAO.java +++ b/server/blob/blob-postgres/src/main/java/org/apache/james/blob/postgres/PostgresBlobStoreDAO.java @@ -30,7 +30,7 @@ import java.io.InputStream; import java.util.Collection; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.commons.io.IOUtils; import org.apache.james.backends.postgres.utils.PostgresExecutor; diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java index 649df147623..ca45e2f04e7 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresMailboxModule.java @@ -20,7 +20,7 @@ import static org.apache.james.modules.Names.MAILBOXMANAGER_NAME; -import javax.inject.Singleton; +import jakarta.inject.Singleton; import org.apache.james.adapter.mailbox.ACLUsernameChangeTaskStep; import org.apache.james.adapter.mailbox.DelegationStoreAuthorizator; diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/task/DistributedTaskManagerModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/task/DistributedTaskManagerModule.java index 12dd7f896a5..694158b409e 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/task/DistributedTaskManagerModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/task/DistributedTaskManagerModule.java @@ -23,7 +23,7 @@ import java.io.FileNotFoundException; -import javax.inject.Singleton; +import jakarta.inject.Singleton; import org.apache.commons.configuration2.Configuration; import org.apache.commons.configuration2.ex.ConfigurationException; diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeRepository.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeRepository.java index 0689b270510..94afb643b30 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeRepository.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeRepository.java @@ -21,8 +21,8 @@ import java.util.Optional; -import javax.inject.Inject; -import javax.inject.Named; +import jakarta.inject.Inject; +import jakarta.inject.Named; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.Username; diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeRepository.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeRepository.java index db1d2913b5a..84d586ea9cd 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeRepository.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeRepository.java @@ -21,8 +21,8 @@ import java.util.Optional; -import javax.inject.Inject; -import javax.inject.Named; +import jakarta.inject.Inject; +import jakarta.inject.Named; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.Username; diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/filtering/PostgresFilteringProjection.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/filtering/PostgresFilteringProjection.java index 9404d2626a0..0628ab78a89 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/filtering/PostgresFilteringProjection.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/filtering/PostgresFilteringProjection.java @@ -21,7 +21,7 @@ import java.util.Optional; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.core.Username; import org.apache.james.eventsourcing.Event; diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/filtering/PostgresFilteringProjectionDAO.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/filtering/PostgresFilteringProjectionDAO.java index b7dc907d5c9..adba057b250 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/filtering/PostgresFilteringProjectionDAO.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/filtering/PostgresFilteringProjectionDAO.java @@ -26,7 +26,7 @@ import java.util.List; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.Username; diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/identity/PostgresCustomIdentityDAO.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/identity/PostgresCustomIdentityDAO.java index be24e724d23..490bfcdecba 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/identity/PostgresCustomIdentityDAO.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/identity/PostgresCustomIdentityDAO.java @@ -34,7 +34,7 @@ import java.util.List; import java.util.Optional; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.MailAddress; diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryView.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryView.java index cf00a306d7f..0f801feecfe 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryView.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryView.java @@ -21,7 +21,7 @@ import java.time.ZonedDateTime; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.jmap.api.projections.EmailQueryView; import org.apache.james.mailbox.model.MailboxId; diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewDAO.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewDAO.java index e13e7247ca2..de01286d41d 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewDAO.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewDAO.java @@ -28,8 +28,8 @@ import java.time.ZonedDateTime; -import javax.inject.Inject; -import javax.inject.Named; +import jakarta.inject.Inject; +import jakarta.inject.Named; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.mailbox.model.MessageId; diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewManager.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewManager.java index 1ca2c84f80e..3095d530587 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewManager.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewManager.java @@ -19,7 +19,7 @@ package org.apache.james.jmap.postgres.projections; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.Username; diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresMessageFastViewProjection.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresMessageFastViewProjection.java index 255020333fe..68d173c31cd 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresMessageFastViewProjection.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresMessageFastViewProjection.java @@ -24,7 +24,7 @@ import static org.apache.james.jmap.postgres.projections.PostgresMessageFastViewProjectionModule.MessageFastViewProjectionTable.PREVIEW; import static org.apache.james.jmap.postgres.projections.PostgresMessageFastViewProjectionModule.MessageFastViewProjectionTable.TABLE_NAME; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.jmap.api.model.Preview; diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionRepository.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionRepository.java index 4f81c8237d1..9e48a2d421a 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionRepository.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/pushsubscription/PostgresPushSubscriptionRepository.java @@ -28,8 +28,8 @@ import java.util.Optional; import java.util.Set; -import javax.inject.Inject; -import javax.inject.Singleton; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.Username; diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadDAO.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadDAO.java index fc24129e108..70b480764c2 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadDAO.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadDAO.java @@ -25,9 +25,9 @@ import java.time.LocalDateTime; import java.util.Optional; -import javax.inject.Inject; -import javax.inject.Named; -import javax.inject.Singleton; +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.inject.Singleton; import org.apache.commons.lang3.tuple.Pair; import org.apache.james.backends.postgres.PostgresCommons; diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java index 88a69136480..ac240ee155c 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java @@ -28,8 +28,8 @@ import java.time.Duration; import java.time.LocalDateTime; -import javax.inject.Inject; -import javax.inject.Singleton; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; import org.apache.james.blob.api.BlobStore; import org.apache.james.blob.api.BucketName; diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadUsageRepository.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadUsageRepository.java index 5f0f600fe8b..58993e1ec5c 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadUsageRepository.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadUsageRepository.java @@ -19,8 +19,8 @@ package org.apache.james.jmap.postgres.upload; -import javax.inject.Inject; -import javax.inject.Singleton; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; import org.apache.james.backends.postgres.quota.PostgresQuotaCurrentValueDAO; import org.apache.james.core.Username; diff --git a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/projections/DefaultEmailQueryViewManager.java b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/projections/DefaultEmailQueryViewManager.java index 4fa6831e63b..2870158c3a9 100644 --- a/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/projections/DefaultEmailQueryViewManager.java +++ b/server/data/data-jmap/src/main/java/org/apache/james/jmap/api/projections/DefaultEmailQueryViewManager.java @@ -19,7 +19,7 @@ package org.apache.james.jmap.api.projections; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.core.Username; diff --git a/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainList.java b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainList.java index 9defb6ef2a5..dcdfb3e2a31 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainList.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/domainlist/postgres/PostgresDomainList.java @@ -24,8 +24,8 @@ import java.util.List; -import javax.inject.Inject; -import javax.inject.Named; +import jakarta.inject.Inject; +import jakarta.inject.Named; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.Domain; diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepository.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepository.java index 0be66454099..cc640377a19 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepository.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepository.java @@ -22,8 +22,7 @@ import java.util.Collection; import java.util.Iterator; -import javax.inject.Inject; - +import jakarta.inject.Inject; import jakarta.mail.MessagingException; import org.apache.james.mailrepository.api.MailKey; diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryBlobReferenceSource.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryBlobReferenceSource.java index bd5a39f8f34..f287d0fcde4 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryBlobReferenceSource.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryBlobReferenceSource.java @@ -19,7 +19,7 @@ package org.apache.james.mailrepository.postgres; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.blob.api.BlobId; import org.apache.james.blob.api.BlobReferenceSource; diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryContentDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryContentDAO.java index 91232051c30..6050fcf2cb0 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryContentDAO.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryContentDAO.java @@ -48,8 +48,7 @@ import java.util.function.Consumer; import java.util.stream.Stream; -import javax.inject.Inject; - +import jakarta.inject.Inject; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryFactory.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryFactory.java index 5b85e7b0432..f0b3894368e 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryFactory.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryFactory.java @@ -19,7 +19,7 @@ package org.apache.james.mailrepository.postgres; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.blob.api.BlobId; diff --git a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryUrlStore.java b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryUrlStore.java index 58525b1a494..a01691f9a92 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryUrlStore.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryUrlStore.java @@ -25,8 +25,8 @@ import java.util.stream.Stream; -import javax.inject.Inject; -import javax.inject.Named; +import jakarta.inject.Inject; +import jakarta.inject.Named; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.mailrepository.api.MailRepositoryUrl; diff --git a/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTable.java b/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTable.java index 862e19de407..f0ff1bfc0e8 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTable.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTable.java @@ -23,7 +23,7 @@ import java.util.function.Predicate; import java.util.stream.Stream; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.commons.lang3.tuple.Pair; import org.apache.james.core.Domain; diff --git a/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableDAO.java index c5bbf9d1c30..3e354d42e4b 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableDAO.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableDAO.java @@ -25,7 +25,7 @@ import static org.apache.james.rrt.postgres.PostgresRecipientRewriteTableModule.PostgresRecipientRewriteTableTable.TARGET_ADDRESS; import static org.apache.james.rrt.postgres.PostgresRecipientRewriteTableModule.PostgresRecipientRewriteTableTable.USERNAME; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.commons.lang3.tuple.Pair; import org.apache.james.backends.postgres.utils.PostgresExecutor; diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveQuotaDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveQuotaDAO.java index dd894cb9114..647b0de2313 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveQuotaDAO.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveQuotaDAO.java @@ -23,7 +23,7 @@ import java.util.Optional; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.backends.postgres.quota.PostgresQuotaCurrentValueDAO; import org.apache.james.backends.postgres.quota.PostgresQuotaLimitDAO; diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveRepository.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveRepository.java index 0fb63a018fa..2fc9a33ba40 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveRepository.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveRepository.java @@ -27,7 +27,7 @@ import java.util.List; import java.util.Optional; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.commons.io.IOUtils; import org.apache.james.core.Username; diff --git a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveScriptDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveScriptDAO.java index 92e81ce3476..61274a36760 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveScriptDAO.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/sieve/postgres/PostgresSieveScriptDAO.java @@ -32,8 +32,8 @@ import java.time.OffsetDateTime; import java.util.function.Function; -import javax.inject.Inject; -import javax.inject.Named; +import jakarta.inject.Inject; +import jakarta.inject.Named; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.Username; diff --git a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresDelegationStore.java b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresDelegationStore.java index 0eb4fe4a117..2b9df60e00c 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresDelegationStore.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresDelegationStore.java @@ -19,7 +19,7 @@ package org.apache.james.user.postgres; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.core.Username; import org.apache.james.user.api.DelegationStore; diff --git a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java index e936c8cb80a..04c53d38a23 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersDAO.java @@ -34,8 +34,8 @@ import java.util.Optional; import java.util.function.Function; -import javax.inject.Inject; -import javax.inject.Named; +import jakarta.inject.Inject; +import jakarta.inject.Named; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.Username; diff --git a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersRepository.java b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersRepository.java index 610dc905293..472bb277af2 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersRepository.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/user/postgres/PostgresUsersRepository.java @@ -19,7 +19,7 @@ package org.apache.james.user.postgres; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.domainlist.api.DomainList; import org.apache.james.user.lib.UsersRepositoryImpl; diff --git a/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresNotificationRegistry.java b/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresNotificationRegistry.java index 7dd3238ac81..d03ceff00f2 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresNotificationRegistry.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresNotificationRegistry.java @@ -22,7 +22,7 @@ import java.time.ZonedDateTime; import java.util.Optional; -import javax.inject.Inject; +import jakarta.inject.Inject; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.Username; diff --git a/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationRepository.java b/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationRepository.java index 6f859c7d973..82bb0ced556 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationRepository.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/vacation/postgres/PostgresVacationRepository.java @@ -19,8 +19,8 @@ package org.apache.james.vacation.postgres; -import javax.inject.Inject; -import javax.inject.Singleton; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.Username; diff --git a/server/task/task-postgres/src/main/scala/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjection.scala b/server/task/task-postgres/src/main/scala/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjection.scala index 999ea770d44..57271eb7d8e 100644 --- a/server/task/task-postgres/src/main/scala/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjection.scala +++ b/server/task/task-postgres/src/main/scala/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjection.scala @@ -21,7 +21,7 @@ package org.apache.james.task.eventsourcing.postgres import java.time.Instant -import javax.inject.Inject +import jakarta.inject.Inject import org.apache.james.task.eventsourcing.TaskExecutionDetailsProjection import org.apache.james.task.{TaskExecutionDetails, TaskId} import org.reactivestreams.Publisher diff --git a/server/task/task-postgres/src/main/scala/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionDAO.scala b/server/task/task-postgres/src/main/scala/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionDAO.scala index 5ed08bc536d..a938485a721 100644 --- a/server/task/task-postgres/src/main/scala/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionDAO.scala +++ b/server/task/task-postgres/src/main/scala/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionDAO.scala @@ -23,7 +23,7 @@ import java.time.{Instant, LocalDateTime} import java.util.Optional import com.google.common.collect.ImmutableMap -import javax.inject.Inject +import jakarta.inject.Inject import org.apache.james.backends.postgres.PostgresCommons.{LOCAL_DATE_TIME_ZONED_DATE_TIME_FUNCTION, ZONED_DATE_TIME_TO_LOCAL_DATE_TIME, INSTANT_TO_LOCAL_DATE_TIME} import org.apache.james.backends.postgres.utils.PostgresExecutor import org.apache.james.server.task.json.JsonTaskAdditionalInformationSerializer From 5b96ab0a5818690c9f8305ed48f44bfbb08a763c Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 15 Apr 2024 09:44:25 +0700 Subject: [PATCH 297/341] [BUILD] Jenkinsfile - add module server/blob/blob-postgres --- Jenkinsfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Jenkinsfile b/Jenkinsfile index 1299da2b917..8dce5e1b270 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -41,6 +41,7 @@ pipeline { POSTGRES_MODULES = 'backends-common/postgres,' + 'mailbox/postgres,' + + 'server/blob/blob-postgres,' + 'server/data/data-postgres,' + 'server/data/data-jmap-postgres,' + 'server/container/guice/postgres-common,' + From 55c086f625fa3fc09714ec643909e200482b9e35 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 15 Apr 2024 09:45:05 +0700 Subject: [PATCH 298/341] JAMES-2586 Disable some unstable tests of PostgresBlobStoreDAOTest --- .../james/blob/postgres/PostgresBlobStoreDAOTest.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/server/blob/blob-postgres/src/test/java/org/apache/james/blob/postgres/PostgresBlobStoreDAOTest.java b/server/blob/blob-postgres/src/test/java/org/apache/james/blob/postgres/PostgresBlobStoreDAOTest.java index 7ef69a03906..1c2e8bcbefe 100644 --- a/server/blob/blob-postgres/src/test/java/org/apache/james/blob/postgres/PostgresBlobStoreDAOTest.java +++ b/server/blob/blob-postgres/src/test/java/org/apache/james/blob/postgres/PostgresBlobStoreDAOTest.java @@ -68,5 +68,14 @@ public void concurrentSaveBytesShouldReturnConsistentValues(String description, public void mixingSaveReadAndDeleteShouldReturnConsistentState() { } + @Override + @Disabled("The test is not valid because the upload parallelism with big blobs takes time and the test does not waiting for the end of the upload") + public void readShouldNotReadPartiallyWhenDeletingConcurrentlyBigBlob() { + } + + @Override + @Disabled("The test is not valid because the upload parallelism with big blobs takes time and the test does not waiting for the end of the upload") + public void readBytesShouldNotReadPartiallyWhenDeletingConcurrentlyBigBlob() { + } } From fdfd06a139be5915cdf70d9f772866d15eed5657 Mon Sep 17 00:00:00 2001 From: hung phan Date: Mon, 8 Apr 2024 14:16:46 +0700 Subject: [PATCH 299/341] JAMES-2586 Implement PoolBackedPostgresConnectionFactory --- backends-common/postgres/pom.xml | 5 + .../postgres/PostgresTableManager.java | 106 ++++++++------- .../DomainImplPostgresConnectionFactory.java | 18 ++- .../utils/JamesPostgresConnectionFactory.java | 4 + .../PoolBackedPostgresConnectionFactory.java | 92 +++++++++++++ .../postgres/utils/PostgresExecutor.java | 122 +++++++++++------- .../SinglePostgresConnectionFactory.java | 10 ++ ...mainImplPostgresConnectionFactoryTest.java | 2 +- ...olBackedPostgresConnectionFactoryTest.java | 34 +++++ 9 files changed, 294 insertions(+), 99 deletions(-) create mode 100644 backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java create mode 100644 backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PoolBackedPostgresConnectionFactoryTest.java diff --git a/backends-common/postgres/pom.xml b/backends-common/postgres/pom.xml index 6d256a2733a..e969e220259 100644 --- a/backends-common/postgres/pom.xml +++ b/backends-common/postgres/pom.xml @@ -61,6 +61,11 @@ testing-base test + + io.r2dbc + r2dbc-pool + 1.0.1.RELEASE + jakarta.annotation jakarta.annotation-api diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java index 5947579da1f..2bff154c6c1 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java @@ -20,6 +20,7 @@ package org.apache.james.backends.postgres; import java.util.List; +import java.util.Optional; import jakarta.inject.Inject; @@ -33,6 +34,7 @@ import com.google.common.annotations.VisibleForTesting; +import io.r2dbc.spi.Connection; import io.r2dbc.spi.Result; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -68,37 +70,43 @@ public void initPostgres() { } public Mono initializePostgresExtension() { - return postgresExecutor.connection() - .flatMapMany(connection -> connection.createStatement("CREATE EXTENSION IF NOT EXISTS hstore") - .execute()) - .flatMap(Result::getRowsUpdated) - .then(); + return Mono.usingWhen(postgresExecutor.connectionFactory().getConnection(Optional.empty()), + connection -> Mono.just(connection) + .flatMapMany(pgConnection -> pgConnection.createStatement("CREATE EXTENSION IF NOT EXISTS hstore") + .execute()) + .flatMap(Result::getRowsUpdated) + .then(), + connection -> postgresExecutor.connectionFactory().closeConnection(connection)); } public Mono initializeTables() { - return postgresExecutor.dslContext() - .flatMapMany(dsl -> listExistTables() - .flatMapMany(existTables -> Flux.fromIterable(module.tables()) - .filter(table -> !existTables.contains(table.getName())) - .flatMap(table -> createAndAlterTable(table, dsl)))) - .then(); + return Mono.usingWhen(postgresExecutor.connectionFactory().getConnection(Optional.empty()), + connection -> postgresExecutor.dslContext(connection) + .flatMapMany(dsl -> listExistTables() + .flatMapMany(existTables -> Flux.fromIterable(module.tables()) + .filter(table -> !existTables.contains(table.getName())) + .flatMap(table -> createAndAlterTable(table, dsl, connection)))) + .then(), + connection -> postgresExecutor.connectionFactory().closeConnection(connection)); } - private Mono createAndAlterTable(PostgresTable table, DSLContext dsl) { + private Mono createAndAlterTable(PostgresTable table, DSLContext dsl, Connection connection) { return Mono.from(table.getCreateTableStepFunction().apply(dsl)) - .then(alterTableIfNeeded(table)) + .then(alterTableIfNeeded(table, connection)) .doOnSuccess(any -> LOGGER.info("Table {} created", table.getName())) .onErrorResume(exception -> handleTableCreationException(table, exception)); } public Mono> listExistTables() { - return postgresExecutor.dslContext() - .flatMapMany(d -> Flux.from(d.select(DSL.field("tablename")) - .from("pg_tables") - .where(DSL.field("schemaname") - .eq(DSL.currentSchema())))) - .map(r -> r.get(0, String.class)) - .collectList(); + return Mono.usingWhen(postgresExecutor.connectionFactory().getConnection(Optional.empty()), + connection -> postgresExecutor.dslContext(connection) + .flatMapMany(d -> Flux.from(d.select(DSL.field("tablename")) + .from("pg_tables") + .where(DSL.field("schemaname") + .eq(DSL.currentSchema())))) + .map(r -> r.get(0, String.class)) + .collectList(), + connection -> postgresExecutor.connectionFactory().closeConnection(connection)); } private Mono handleTableCreationException(PostgresTable table, Throwable e) { @@ -109,15 +117,15 @@ private Mono handleTableCreationException(PostgresTable table, Throwable e return Mono.error(e); } - private Mono alterTableIfNeeded(PostgresTable table) { - return executeAdditionalAlterQueries(table) - .then(enableRLSIfNeeded(table)); + private Mono alterTableIfNeeded(PostgresTable table, Connection connection) { + return executeAdditionalAlterQueries(table, connection) + .then(enableRLSIfNeeded(table, connection)); } - private Mono executeAdditionalAlterQueries(PostgresTable table) { + private Mono executeAdditionalAlterQueries(PostgresTable table, Connection connection) { return Flux.fromIterable(table.getAdditionalAlterQueries()) - .concatMap(alterSQLQuery -> postgresExecutor.connection() - .flatMapMany(connection -> connection.createStatement(alterSQLQuery) + .concatMap(alterSQLQuery -> Mono.just(connection) + .flatMapMany(pgConnection -> pgConnection.createStatement(alterSQLQuery) .execute()) .flatMap(Result::getRowsUpdated) .then() @@ -131,16 +139,16 @@ private Mono executeAdditionalAlterQueries(PostgresTable table) { .then(); } - private Mono enableRLSIfNeeded(PostgresTable table) { + private Mono enableRLSIfNeeded(PostgresTable table, Connection connection) { if (rowLevelSecurityEnabled && table.supportsRowLevelSecurity()) { - return alterTableEnableRLS(table); + return alterTableEnableRLS(table, connection); } return Mono.empty(); } - public Mono alterTableEnableRLS(PostgresTable table) { - return postgresExecutor.connection() - .flatMapMany(connection -> connection.createStatement(rowLevelSecurityAlterStatement(table.getName())) + private Mono alterTableEnableRLS(PostgresTable table, Connection connection) { + return Mono.just(connection) + .flatMapMany(pgConnection -> pgConnection.createStatement(rowLevelSecurityAlterStatement(table.getName())) .execute()) .flatMap(Result::getRowsUpdated) .then(); @@ -158,25 +166,29 @@ private String rowLevelSecurityAlterStatement(String tableName) { } public Mono truncate() { - return postgresExecutor.dslContext() - .flatMap(dsl -> Flux.fromIterable(module.tables()) - .flatMap(table -> Mono.from(dsl.truncateTable(table.getName())) - .doOnSuccess(any -> LOGGER.info("Table {} truncated", table.getName())) - .doOnError(e -> LOGGER.error("Error while truncating table {}", table.getName(), e))) - .then()); + return Mono.usingWhen(postgresExecutor.connectionFactory().getConnection(Optional.empty()), + connection -> postgresExecutor.dslContext(connection) + .flatMap(dsl -> Flux.fromIterable(module.tables()) + .flatMap(table -> Mono.from(dsl.truncateTable(table.getName())) + .doOnSuccess(any -> LOGGER.info("Table {} truncated", table.getName())) + .doOnError(e -> LOGGER.error("Error while truncating table {}", table.getName(), e))) + .then()), + connection -> postgresExecutor.connectionFactory().closeConnection(connection)); } public Mono initializeTableIndexes() { - return postgresExecutor.dslContext() - .flatMapMany(dsl -> listExistIndexes() - .flatMapMany(existIndexes -> Flux.fromIterable(module.tableIndexes()) - .filter(index -> !existIndexes.contains(index.getName())) - .flatMap(index -> createTableIndex(index, dsl)))) - .then(); - } - - public Mono> listExistIndexes() { - return postgresExecutor.dslContext() + return Mono.usingWhen(postgresExecutor.connectionFactory().getConnection(Optional.empty()), + connection -> postgresExecutor.dslContext(connection) + .flatMapMany(dsl -> listExistIndexes(dsl) + .flatMapMany(existIndexes -> Flux.fromIterable(module.tableIndexes()) + .filter(index -> !existIndexes.contains(index.getName())) + .flatMap(index -> createTableIndex(index, dsl)))) + .then(), + connection -> postgresExecutor.connectionFactory().closeConnection(connection)); + } + + private Mono> listExistIndexes(DSLContext dslContext) { + return Mono.just(dslContext) .flatMapMany(dsl -> Flux.from(dsl.select(DSL.field("indexname")) .from("pg_indexes") .where(DSL.field("schemaname") diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/DomainImplPostgresConnectionFactory.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/DomainImplPostgresConnectionFactory.java index f988b4fcb11..f69dd775694 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/DomainImplPostgresConnectionFactory.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/DomainImplPostgresConnectionFactory.java @@ -31,6 +31,7 @@ import io.r2dbc.spi.Connection; import io.r2dbc.spi.ConnectionFactory; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; public class DomainImplPostgresConnectionFactory implements JamesPostgresConnectionFactory { @@ -46,11 +47,24 @@ public DomainImplPostgresConnectionFactory(ConnectionFactory connectionFactory) this.connectionFactory = connectionFactory; } + @Override public Mono getConnection(Optional maybeDomain) { return maybeDomain.map(this::getConnectionForDomain) .orElse(getConnectionForDomain(DEFAULT)); } + @Override + public Mono closeConnection(Connection connection) { + return Mono.empty(); + } + + @Override + public Mono close() { + return Flux.fromIterable(mapDomainToConnection.values()) + .flatMap(connection -> Mono.from(connection.close())) + .then(); + } + private Mono getConnectionForDomain(Domain domain) { return Mono.just(domain) .flatMap(domainValue -> Mono.fromCallable(() -> mapDomainToConnection.get(domainValue)) @@ -74,14 +88,14 @@ private Mono getAndSetConnection(Domain domain, Connection newConnec }).switchIfEmpty(setDomainAttributeForConnection(domain, newConnection)); } - private static Mono setDomainAttributeForConnection(Domain domain, Connection newConnection) { + private Mono setDomainAttributeForConnection(Domain domain, Connection newConnection) { return Mono.from(newConnection.createStatement("SET " + DOMAIN_ATTRIBUTE + " TO '" + getDomainAttributeValue(domain) + "'") // It should be set value via Bind, but it doesn't work .execute()) .doOnError(e -> LOGGER.error("Error while setting domain attribute for domain {}", domain, e)) .then(Mono.just(newConnection)); } - private static String getDomainAttributeValue(Domain domain) { + private String getDomainAttributeValue(Domain domain) { if (DEFAULT.equals(domain)) { return DEFAULT_DOMAIN_ATTRIBUTE_VALUE; } else { diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/JamesPostgresConnectionFactory.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/JamesPostgresConnectionFactory.java index c196f806429..7a4fede8a94 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/JamesPostgresConnectionFactory.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/JamesPostgresConnectionFactory.java @@ -35,4 +35,8 @@ default Mono getConnection(Domain domain) { } Mono getConnection(Optional domain); + + Mono closeConnection(Connection connection); + + Mono close(); } diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java new file mode 100644 index 00000000000..5441a1f4fe6 --- /dev/null +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java @@ -0,0 +1,92 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres.utils; + +import java.time.Duration; +import java.util.Optional; + +import javax.inject.Inject; + +import org.apache.james.core.Domain; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.r2dbc.pool.ConnectionPool; +import io.r2dbc.pool.ConnectionPoolConfiguration; +import io.r2dbc.spi.Connection; +import io.r2dbc.spi.ConnectionFactory; +import reactor.core.publisher.Mono; + +public class PoolBackedPostgresConnectionFactory implements JamesPostgresConnectionFactory { + private static final Logger LOGGER = LoggerFactory.getLogger(PoolBackedPostgresConnectionFactory.class); + private static final Domain DEFAULT = Domain.of("default"); + private static final String DEFAULT_DOMAIN_ATTRIBUTE_VALUE = ""; + private static final int INITIAL_SIZE = 10; + private static final int MAX_SIZE = 50; + private static final Duration MAX_IDLE_TIME = Duration.ofMillis(5000); + + private final boolean rowLevelSecurityEnabled; + private final ConnectionPool pool; + + @Inject + public PoolBackedPostgresConnectionFactory(boolean rowLevelSecurityEnabled, ConnectionFactory connectionFactory) { + this.rowLevelSecurityEnabled = rowLevelSecurityEnabled; + final ConnectionPoolConfiguration configuration = ConnectionPoolConfiguration.builder(connectionFactory) + .maxIdleTime(MAX_IDLE_TIME) + .initialSize(INITIAL_SIZE) + .maxSize(MAX_SIZE) + .build(); + pool = new ConnectionPool(configuration); + } + + @Override + public Mono getConnection(Optional domain) { + if (rowLevelSecurityEnabled) { + return pool.create().flatMap(connection -> setDomainAttributeForConnection(domain.orElse(DEFAULT), connection)); + } else { + return pool.create(); + } + } + + @Override + public Mono closeConnection(Connection connection) { + return Mono.from(connection.close()); + } + + @Override + public Mono close() { + return pool.close(); + } + + private Mono setDomainAttributeForConnection(Domain domain, Connection connection) { + return Mono.from(connection.createStatement("SET " + DOMAIN_ATTRIBUTE + " TO '" + getDomainAttributeValue(domain) + "'") // It should be set value via Bind, but it doesn't work + .execute()) + .doOnError(e -> LOGGER.error("Error while setting domain attribute for domain {}", domain, e)) + .then(Mono.just(connection)); + } + + private String getDomainAttributeValue(Domain domain) { + if (DEFAULT.equals(domain)) { + return DEFAULT_DOMAIN_ATTRIBUTE_VALUE; + } else { + return domain.asString(); + } + } +} diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index 27b81cce40e..195606844ba 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -79,7 +79,7 @@ public Factory(JamesPostgresConnectionFactory jamesPostgresConnectionFactory, } public PostgresExecutor create(Optional domain) { - return new PostgresExecutor(jamesPostgresConnectionFactory.getConnection(domain), postgresConfiguration, metricFactory); + return new PostgresExecutor(domain, jamesPostgresConnectionFactory, postgresConfiguration, metricFactory); } public PostgresExecutor create() { @@ -92,65 +92,81 @@ public PostgresExecutor create() { .withRenderFormatted(true) .withStatementType(StatementType.PREPARED_STATEMENT); - private final Mono connection; + private final Optional domain; + private final JamesPostgresConnectionFactory jamesPostgresConnectionFactory; private final PostgresConfiguration postgresConfiguration; private final MetricFactory metricFactory; - private PostgresExecutor(Mono connection, + private PostgresExecutor(Optional domain, + JamesPostgresConnectionFactory jamesPostgresConnectionFactory, PostgresConfiguration postgresConfiguration, MetricFactory metricFactory) { - this.connection = connection; + this.domain = domain; + this.jamesPostgresConnectionFactory = jamesPostgresConnectionFactory; this.postgresConfiguration = postgresConfiguration; this.metricFactory = metricFactory; } public Mono dslContext() { - return connection.map(con -> DSL.using(con, PGSQL_DIALECT, SETTINGS)); + return jamesPostgresConnectionFactory.getConnection(domain) + .map(con -> DSL.using(con, PGSQL_DIALECT, SETTINGS)); + } + + public Mono dslContext(Connection connection) { + return Mono.fromCallable(() -> DSL.using(connection, PGSQL_DIALECT, SETTINGS)); } public Mono executeVoid(Function> queryFunction) { return Mono.from(metricFactory.decoratePublisherWithTimerMetric("postgres-execution", - dslContext() - .flatMap(queryFunction) - .timeout(postgresConfiguration.getJooqReactiveTimeout()) - .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) - .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) - .filter(preparedStatementConflictException())) - .then())); + Mono.usingWhen(jamesPostgresConnectionFactory.getConnection(domain), + connection -> dslContext(connection) + .flatMap(queryFunction) + .timeout(postgresConfiguration.getJooqReactiveTimeout()) + .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) + .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) + .filter(preparedStatementConflictException())) + .then(), + jamesPostgresConnectionFactory::closeConnection))); } public Flux executeRows(Function> queryFunction) { return Flux.from(metricFactory.decoratePublisherWithTimerMetric("postgres-execution", - dslContext() - .flatMapMany(queryFunction) - .timeout(postgresConfiguration.getJooqReactiveTimeout()) - .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) - .collectList() - .flatMapIterable(list -> list) // Mitigation fix for https://github.com/jOOQ/jOOQ/issues/16556 - .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) - .filter(preparedStatementConflictException())))); + Flux.usingWhen(jamesPostgresConnectionFactory.getConnection(domain), + connection -> dslContext(connection) + .flatMapMany(queryFunction) + .timeout(postgresConfiguration.getJooqReactiveTimeout()) + .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) + .collectList() + .flatMapIterable(list -> list) // Mitigation fix for https://github.com/jOOQ/jOOQ/issues/16556 + .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) + .filter(preparedStatementConflictException())), + jamesPostgresConnectionFactory::closeConnection))); } public Flux executeDeleteAndReturnList(Function> queryFunction) { return Flux.from(metricFactory.decoratePublisherWithTimerMetric("postgres-execution", - dslContext() - .flatMapMany(queryFunction) - .timeout(postgresConfiguration.getJooqReactiveTimeout()) - .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) - .collectList() - .flatMapIterable(list -> list) // The convert Flux -> Mono -> Flux to avoid a hanging issue. See: https://github.com/jOOQ/jOOQ/issues/16055 - .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) - .filter(preparedStatementConflictException())))); + Flux.usingWhen(jamesPostgresConnectionFactory.getConnection(domain), + connection -> dslContext(connection) + .flatMapMany(queryFunction) + .timeout(postgresConfiguration.getJooqReactiveTimeout()) + .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) + .collectList() + .flatMapIterable(list -> list) // The convert Flux -> Mono -> Flux to avoid a hanging issue. See: https://github.com/jOOQ/jOOQ/issues/16055 + .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) + .filter(preparedStatementConflictException())), + jamesPostgresConnectionFactory::closeConnection))); } public Mono executeRow(Function> queryFunction) { return Mono.from(metricFactory.decoratePublisherWithTimerMetric("postgres-execution", - dslContext() - .flatMap(queryFunction.andThen(Mono::from)) - .timeout(postgresConfiguration.getJooqReactiveTimeout()) - .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) - .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) - .filter(preparedStatementConflictException())))); + Mono.usingWhen(jamesPostgresConnectionFactory.getConnection(domain), + connection -> dslContext(connection) + .flatMap(queryFunction.andThen(Mono::from)) + .timeout(postgresConfiguration.getJooqReactiveTimeout()) + .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) + .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) + .filter(preparedStatementConflictException())), + jamesPostgresConnectionFactory::closeConnection))); } public Mono> executeSingleRowOptional(Function> queryFunction) { @@ -161,13 +177,15 @@ public Mono> executeSingleRowOptional(Function executeCount(Function>> queryFunction) { return Mono.from(metricFactory.decoratePublisherWithTimerMetric("postgres-execution", - dslContext() - .flatMap(queryFunction) - .timeout(postgresConfiguration.getJooqReactiveTimeout()) - .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) - .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) - .filter(preparedStatementConflictException())) - .map(Record1::value1))); + Mono.usingWhen(jamesPostgresConnectionFactory.getConnection(domain), + connection -> dslContext(connection) + .flatMap(queryFunction) + .timeout(postgresConfiguration.getJooqReactiveTimeout()) + .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) + .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) + .filter(preparedStatementConflictException())) + .map(Record1::value1), + jamesPostgresConnectionFactory::closeConnection))); } public Mono executeExists(Function> queryFunction) { @@ -177,21 +195,27 @@ public Mono executeExists(Function> public Mono executeReturnAffectedRowsCount(Function> queryFunction) { return Mono.from(metricFactory.decoratePublisherWithTimerMetric("postgres-execution", - dslContext() - .flatMap(queryFunction) - .timeout(postgresConfiguration.getJooqReactiveTimeout()) - .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) - .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) - .filter(preparedStatementConflictException())))); + Mono.usingWhen(jamesPostgresConnectionFactory.getConnection(domain), + connection -> dslContext(connection) + .flatMap(queryFunction) + .timeout(postgresConfiguration.getJooqReactiveTimeout()) + .doOnError(TimeoutException.class, e -> LOGGER.error(JOOQ_TIMEOUT_ERROR_LOG, e)) + .retryWhen(Retry.backoff(MAX_RETRY_ATTEMPTS, MIN_BACKOFF) + .filter(preparedStatementConflictException())), + jamesPostgresConnectionFactory::closeConnection))); + } + + public JamesPostgresConnectionFactory connectionFactory() { + return jamesPostgresConnectionFactory; } public Mono connection() { - return connection; + return jamesPostgresConnectionFactory.getConnection(domain); } @VisibleForTesting public Mono dispose() { - return connection.flatMap(con -> Mono.from(con.close())); + return jamesPostgresConnectionFactory.close(); } private Predicate preparedStatementConflictException() { diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SinglePostgresConnectionFactory.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SinglePostgresConnectionFactory.java index 58f1dc72f83..3972a27dbda 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SinglePostgresConnectionFactory.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SinglePostgresConnectionFactory.java @@ -37,4 +37,14 @@ public SinglePostgresConnectionFactory(Connection connection) { public Mono getConnection(Optional domain) { return Mono.just(connection); } + + @Override + public Mono closeConnection(Connection connection) { + return Mono.empty(); + } + + @Override + public Mono close() { + return Mono.from(connection.close()); + } } diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/DomainImplPostgresConnectionFactoryTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/DomainImplPostgresConnectionFactoryTest.java index dc4b3209539..ec79ba3d23f 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/DomainImplPostgresConnectionFactoryTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/DomainImplPostgresConnectionFactoryTest.java @@ -28,8 +28,8 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; import org.apache.james.core.Domain; import org.apache.james.util.concurrency.ConcurrentTestRunner; import org.jetbrains.annotations.Nullable; diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PoolBackedPostgresConnectionFactoryTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PoolBackedPostgresConnectionFactoryTest.java new file mode 100644 index 00000000000..31bd7afc469 --- /dev/null +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PoolBackedPostgresConnectionFactoryTest.java @@ -0,0 +1,34 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres; + +import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.PoolBackedPostgresConnectionFactory; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class PoolBackedPostgresConnectionFactoryTest extends JamesPostgresConnectionFactoryTest { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.empty(); + + @Override + JamesPostgresConnectionFactory jamesPostgresConnectionFactory() { + return new PoolBackedPostgresConnectionFactory(true, postgresExtension.getConnectionFactory()); + } +} From a3af7027e42095e0e8d4eb4cab1406f1cd6264e7 Mon Sep 17 00:00:00 2001 From: hung phan Date: Thu, 11 Apr 2024 11:44:05 +0700 Subject: [PATCH 300/341] JAMES-2586 Update PostgresCommonModule to use PoolBackedPostgresConnectionFactory --- .../utils/PoolBackedPostgresConnectionFactory.java | 13 +++++++------ .../james/modules/data/PostgresCommonModule.java | 12 ++++++------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java index 5441a1f4fe6..7ad10839bf1 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java @@ -22,8 +22,6 @@ import java.time.Duration; import java.util.Optional; -import javax.inject.Inject; - import org.apache.james.core.Domain; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -39,23 +37,26 @@ public class PoolBackedPostgresConnectionFactory implements JamesPostgresConnect private static final Domain DEFAULT = Domain.of("default"); private static final String DEFAULT_DOMAIN_ATTRIBUTE_VALUE = ""; private static final int INITIAL_SIZE = 10; - private static final int MAX_SIZE = 50; + private static final int MAX_SIZE = 20; private static final Duration MAX_IDLE_TIME = Duration.ofMillis(5000); private final boolean rowLevelSecurityEnabled; private final ConnectionPool pool; - @Inject - public PoolBackedPostgresConnectionFactory(boolean rowLevelSecurityEnabled, ConnectionFactory connectionFactory) { + public PoolBackedPostgresConnectionFactory(boolean rowLevelSecurityEnabled, Optional maxSize, ConnectionFactory connectionFactory) { this.rowLevelSecurityEnabled = rowLevelSecurityEnabled; final ConnectionPoolConfiguration configuration = ConnectionPoolConfiguration.builder(connectionFactory) .maxIdleTime(MAX_IDLE_TIME) .initialSize(INITIAL_SIZE) - .maxSize(MAX_SIZE) + .maxSize(maxSize.orElse(MAX_SIZE)) .build(); pool = new ConnectionPool(configuration); } + public PoolBackedPostgresConnectionFactory(boolean rowLevelSecurityEnabled, ConnectionFactory connectionFactory) { + this(rowLevelSecurityEnabled, Optional.empty(), connectionFactory); + } + @Override public Mono getConnection(Optional domain) { if (rowLevelSecurityEnabled) { diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index a2e882f1e75..cc5214df17e 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -28,8 +28,8 @@ import org.apache.james.backends.postgres.PostgresConfiguration; import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.backends.postgres.PostgresTableManager; -import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.PoolBackedPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.backends.postgres.utils.PostgresHealthCheck; import org.apache.james.backends.postgres.utils.SinglePostgresConnectionFactory; @@ -76,14 +76,14 @@ PostgresConfiguration provideConfiguration(PropertiesProvider propertiesProvider @Singleton JamesPostgresConnectionFactory provideJamesPostgresConnectionFactory(PostgresConfiguration postgresConfiguration, ConnectionFactory connectionFactory, - @Named(JamesPostgresConnectionFactory.NON_RLS_INJECT) JamesPostgresConnectionFactory singlePostgresConnectionFactory) { + @Named(JamesPostgresConnectionFactory.NON_RLS_INJECT) JamesPostgresConnectionFactory jamesPostgresConnectionFactory) { if (postgresConfiguration.rowLevelSecurityEnabled()) { LOGGER.info("PostgreSQL row level security enabled"); - LOGGER.info("Implementation for PostgreSQL connection factory: {}", DomainImplPostgresConnectionFactory.class.getName()); - return new DomainImplPostgresConnectionFactory(connectionFactory); + LOGGER.info("Implementation for PostgreSQL connection factory: {}", PoolBackedPostgresConnectionFactory.class.getName()); + return new PoolBackedPostgresConnectionFactory(true, connectionFactory); } - LOGGER.info("Implementation for PostgreSQL connection factory: {}", SinglePostgresConnectionFactory.class.getName()); - return singlePostgresConnectionFactory; + LOGGER.info("Implementation for PostgreSQL connection factory: {}", PoolBackedPostgresConnectionFactory.class.getName()); + return new PoolBackedPostgresConnectionFactory(false, connectionFactory); } @Provides From 8aa8194e511d402d7bf9c44bf39aaa7fdbc1637f Mon Sep 17 00:00:00 2001 From: hung phan Date: Wed, 17 Apr 2024 11:08:27 +0700 Subject: [PATCH 301/341] JAMES-2586 Add connection pool config to PostgresConfiguration --- .../postgres/PostgresConfiguration.java | 45 ++++++++++++++++++- .../PoolBackedPostgresConnectionFactory.java | 20 ++++----- .../sample-configuration/postgres.properties | 6 +++ .../modules/data/PostgresCommonModule.java | 14 +++--- 4 files changed, 66 insertions(+), 19 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java index e00c803fd30..bfffc4ddeef 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java @@ -45,6 +45,8 @@ public class PostgresConfiguration { public static final String NON_RLS_USERNAME = "database.non-rls.username"; public static final String NON_RLS_PASSWORD = "database.non-rls.password"; public static final String RLS_ENABLED = "row.level.security.enabled"; + public static final String POOL_INITIAL_SIZE = "pool.initial.size"; + public static final String POOL_MAX_SIZE = "pool.max.size"; public static final String SSL_MODE = "ssl.mode"; public static final String SSL_MODE_DEFAULT_VALUE = "allow"; public static final String JOOQ_REACTIVE_TIMEOUT = "jooq.reactive.timeout"; @@ -79,6 +81,8 @@ public static class Builder { private Optional nonRLSUser = Optional.empty(); private Optional nonRLSPassword = Optional.empty(); private Optional rowLevelSecurityEnabled = Optional.empty(); + private Optional poolInitialSize = Optional.empty(); + private Optional poolMaxSize = Optional.empty(); private Optional sslMode = Optional.empty(); private Optional jooqReactiveTimeout = Optional.empty(); @@ -172,6 +176,26 @@ public Builder rowLevelSecurityEnabled() { return this; } + public Builder poolInitialSize(Optional poolInitialSize) { + this.poolInitialSize = poolInitialSize; + return this; + } + + public Builder poolInitialSize(Integer poolInitialSize) { + this.poolInitialSize = Optional.of(poolInitialSize); + return this; + } + + public Builder poolMaxSize(Optional poolMaxSize) { + this.poolMaxSize = poolMaxSize; + return this; + } + + public Builder poolMaxSize(Integer poolMaxSize) { + this.poolMaxSize = Optional.of(poolMaxSize); + return this; + } + public Builder sslMode(Optional sslMode) { this.sslMode = sslMode; return this; @@ -203,6 +227,8 @@ public PostgresConfiguration build() { new Credential(username.get(), password.get()), new Credential(nonRLSUser.orElse(username.get()), nonRLSPassword.orElse(password.get())), rowLevelSecurityEnabled.orElse(false), + poolInitialSize, + poolMaxSize, SSLMode.fromValue(sslMode.orElse(SSL_MODE_DEFAULT_VALUE)), jooqReactiveTimeout.orElse(JOOQ_REACTIVE_TIMEOUT_DEFAULT_VALUE)); } @@ -223,6 +249,8 @@ public static PostgresConfiguration from(Configuration propertiesConfiguration) .nonRLSUser(Optional.ofNullable(propertiesConfiguration.getString(NON_RLS_USERNAME))) .nonRLSPassword(Optional.ofNullable(propertiesConfiguration.getString(NON_RLS_PASSWORD))) .rowLevelSecurityEnabled(propertiesConfiguration.getBoolean(RLS_ENABLED, false)) + .poolInitialSize(Optional.ofNullable(propertiesConfiguration.getInteger(POOL_INITIAL_SIZE, null))) + .poolMaxSize(Optional.ofNullable(propertiesConfiguration.getInteger(POOL_MAX_SIZE, null))) .sslMode(Optional.ofNullable(propertiesConfiguration.getString(SSL_MODE))) .jooqReactiveTimeout(Optional.ofNullable(propertiesConfiguration.getString(JOOQ_REACTIVE_TIMEOUT)) .map(value -> DurationParser.parse(value, ChronoUnit.SECONDS))) @@ -236,11 +264,14 @@ public static PostgresConfiguration from(Configuration propertiesConfiguration) private final Credential credential; private final Credential nonRLSCredential; private final boolean rowLevelSecurityEnabled; + private final Optional poolInitialSize; + private final Optional poolMaxSize; private final SSLMode sslMode; private final Duration jooqReactiveTimeout; private PostgresConfiguration(String host, int port, String databaseName, String databaseSchema, Credential credential, Credential nonRLSCredential, boolean rowLevelSecurityEnabled, + Optional poolInitialSize, Optional poolMaxSize, SSLMode sslMode, Duration jooqReactiveTimeout) { this.host = host; this.port = port; @@ -249,6 +280,8 @@ private PostgresConfiguration(String host, int port, String databaseName, String this.credential = credential; this.nonRLSCredential = nonRLSCredential; this.rowLevelSecurityEnabled = rowLevelSecurityEnabled; + this.poolInitialSize = poolInitialSize; + this.poolMaxSize = poolMaxSize; this.sslMode = sslMode; this.jooqReactiveTimeout = jooqReactiveTimeout; } @@ -281,6 +314,14 @@ public boolean rowLevelSecurityEnabled() { return rowLevelSecurityEnabled; } + public Optional poolInitialSize() { + return poolInitialSize; + } + + public Optional poolMaxSize() { + return poolMaxSize; + } + public SSLMode getSslMode() { return sslMode; } @@ -291,7 +332,7 @@ public Duration getJooqReactiveTimeout() { @Override public final int hashCode() { - return Objects.hash(host, port, databaseName, databaseSchema, credential, nonRLSCredential, rowLevelSecurityEnabled, sslMode, jooqReactiveTimeout); + return Objects.hash(host, port, databaseName, databaseSchema, credential, nonRLSCredential, rowLevelSecurityEnabled, poolInitialSize, poolMaxSize, sslMode, jooqReactiveTimeout); } @Override @@ -306,6 +347,8 @@ public final boolean equals(Object o) { && Objects.equals(this.nonRLSCredential, that.nonRLSCredential) && Objects.equals(this.databaseName, that.databaseName) && Objects.equals(this.databaseSchema, that.databaseSchema) + && Objects.equals(this.poolInitialSize, that.poolInitialSize) + && Objects.equals(this.poolMaxSize, that.poolMaxSize) && Objects.equals(this.sslMode, that.sslMode) && Objects.equals(this.jooqReactiveTimeout, that.jooqReactiveTimeout); } diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java index 7ad10839bf1..ea97e706273 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java @@ -19,7 +19,6 @@ package org.apache.james.backends.postgres.utils; -import java.time.Duration; import java.util.Optional; import org.apache.james.core.Domain; @@ -36,25 +35,26 @@ public class PoolBackedPostgresConnectionFactory implements JamesPostgresConnect private static final Logger LOGGER = LoggerFactory.getLogger(PoolBackedPostgresConnectionFactory.class); private static final Domain DEFAULT = Domain.of("default"); private static final String DEFAULT_DOMAIN_ATTRIBUTE_VALUE = ""; - private static final int INITIAL_SIZE = 10; - private static final int MAX_SIZE = 20; - private static final Duration MAX_IDLE_TIME = Duration.ofMillis(5000); + private static final int DEFAULT_INITIAL_SIZE = 10; + private static final int DEFAULT_MAX_SIZE = 20; private final boolean rowLevelSecurityEnabled; private final ConnectionPool pool; - public PoolBackedPostgresConnectionFactory(boolean rowLevelSecurityEnabled, Optional maxSize, ConnectionFactory connectionFactory) { + public PoolBackedPostgresConnectionFactory(boolean rowLevelSecurityEnabled, Optional maybeInitialSize, Optional maybeMaxSize, ConnectionFactory connectionFactory) { this.rowLevelSecurityEnabled = rowLevelSecurityEnabled; - final ConnectionPoolConfiguration configuration = ConnectionPoolConfiguration.builder(connectionFactory) - .maxIdleTime(MAX_IDLE_TIME) - .initialSize(INITIAL_SIZE) - .maxSize(maxSize.orElse(MAX_SIZE)) + int initialSize = maybeInitialSize.orElse(DEFAULT_INITIAL_SIZE); + int maxSize = maybeMaxSize.orElse(DEFAULT_MAX_SIZE); + ConnectionPoolConfiguration configuration = ConnectionPoolConfiguration.builder(connectionFactory) + .initialSize(initialSize) + .maxSize(maxSize) .build(); + LOGGER.info("Creating new postgres ConnectionPool with initialSize {} and maxSize {}", initialSize, maxSize); pool = new ConnectionPool(configuration); } public PoolBackedPostgresConnectionFactory(boolean rowLevelSecurityEnabled, ConnectionFactory connectionFactory) { - this(rowLevelSecurityEnabled, Optional.empty(), connectionFactory); + this(rowLevelSecurityEnabled, Optional.empty(), Optional.empty(), connectionFactory); } @Override diff --git a/server/apps/postgres-app/sample-configuration/postgres.properties b/server/apps/postgres-app/sample-configuration/postgres.properties index 8710adb814b..a8780c51337 100644 --- a/server/apps/postgres-app/sample-configuration/postgres.properties +++ b/server/apps/postgres-app/sample-configuration/postgres.properties @@ -25,6 +25,12 @@ row.level.security.enabled=false # String. It is required when row.level.security.enabled is true. Database password of non-rls user. #database.non-rls.password=secret1 +# Integer. Optional, default to 5432. Database connection pool initial size. +pool.initial.size=10 + +# Integer. Optional, default to 5432. Database connection pool max size. +pool.max.size=20 + # String. Optional, defaults to allow. SSLMode required to connect to the Postgresql db server. # Check https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-PROTECTION for a list of supported SSLModes. ssl.mode=allow diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index cc5214df17e..9bcb3b31963 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -75,15 +75,13 @@ PostgresConfiguration provideConfiguration(PropertiesProvider propertiesProvider @Provides @Singleton JamesPostgresConnectionFactory provideJamesPostgresConnectionFactory(PostgresConfiguration postgresConfiguration, - ConnectionFactory connectionFactory, - @Named(JamesPostgresConnectionFactory.NON_RLS_INJECT) JamesPostgresConnectionFactory jamesPostgresConnectionFactory) { - if (postgresConfiguration.rowLevelSecurityEnabled()) { - LOGGER.info("PostgreSQL row level security enabled"); - LOGGER.info("Implementation for PostgreSQL connection factory: {}", PoolBackedPostgresConnectionFactory.class.getName()); - return new PoolBackedPostgresConnectionFactory(true, connectionFactory); - } + ConnectionFactory connectionFactory) { + LOGGER.info("Is PostgreSQL row level security enabled? {}", postgresConfiguration.rowLevelSecurityEnabled()); LOGGER.info("Implementation for PostgreSQL connection factory: {}", PoolBackedPostgresConnectionFactory.class.getName()); - return new PoolBackedPostgresConnectionFactory(false, connectionFactory); + return new PoolBackedPostgresConnectionFactory(postgresConfiguration.rowLevelSecurityEnabled(), + postgresConfiguration.poolInitialSize(), + postgresConfiguration.poolMaxSize(), + connectionFactory); } @Provides From 89df4b308bbce51bc79dee582092b75b0de69f3e Mon Sep 17 00:00:00 2001 From: hung phan Date: Fri, 26 Apr 2024 11:23:57 +0700 Subject: [PATCH 302/341] JAMES-2586 Close postgres connections when the app shutdown --- .../utils/PostgresConnectionClosure.java | 45 +++++++++++++++++++ .../backends/postgres/PostgresExtension.java | 14 ------ .../modules/data/PostgresCommonModule.java | 3 ++ 3 files changed, 48 insertions(+), 14 deletions(-) create mode 100644 backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresConnectionClosure.java diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresConnectionClosure.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresConnectionClosure.java new file mode 100644 index 00000000000..eb80556a583 --- /dev/null +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresConnectionClosure.java @@ -0,0 +1,45 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres.utils; + +import jakarta.annotation.PreDestroy; +import jakarta.inject.Inject; +import jakarta.inject.Named; + +import org.apache.james.lifecycle.api.Disposable; + +public class PostgresConnectionClosure implements Disposable { + private final JamesPostgresConnectionFactory factory; + private final JamesPostgresConnectionFactory nonRLSFactory; + + @Inject + public PostgresConnectionClosure(JamesPostgresConnectionFactory factory, + @Named(JamesPostgresConnectionFactory.NON_RLS_INJECT) JamesPostgresConnectionFactory nonRLSFactory) { + this.factory = factory; + this.nonRLSFactory = nonRLSFactory; + } + + @PreDestroy + @Override + public void dispose() { + factory.close().block(); + nonRLSFactory.close().block(); + } +} diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index 35a4e9b33ba..28c0f51a929 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -182,10 +182,6 @@ public void beforeEach(ExtensionContext extensionContext) { @Override public void afterEach(ExtensionContext extensionContext) { resetSchema(); - - if (!rlsEnabled) { - dropAllConnections(); - } } public void restartContainer() { @@ -253,14 +249,4 @@ private void dropTables(List tables) { .then() .block(); } - - private void dropAllConnections() { - postgresExecutor.connection() - .flatMapMany(connection -> connection.createStatement(String.format("SELECT pg_terminate_backend(pid) " + - "FROM pg_stat_activity " + - "WHERE datname = '%s' AND pid != pg_backend_pid();", selectedDatabase.dbName())) - .execute()) - .then() - .block(); - } } diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index 9bcb3b31963..245748a6d16 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -30,6 +30,7 @@ import org.apache.james.backends.postgres.PostgresTableManager; import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.PoolBackedPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.PostgresConnectionClosure; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.backends.postgres.utils.PostgresHealthCheck; import org.apache.james.backends.postgres.utils.SinglePostgresConnectionFactory; @@ -60,7 +61,9 @@ public class PostgresCommonModule extends AbstractModule { @Override public void configure() { Multibinder.newSetBinder(binder(), PostgresModule.class); + bind(PostgresExecutor.Factory.class).in(Scopes.SINGLETON); + bind(PostgresConnectionClosure.class).asEagerSingleton(); Multibinder.newSetBinder(binder(), HealthCheck.class) .addBinding().to(PostgresHealthCheck.class); From 6e15af35776390df5c922c14687bd7ca494ae7bb Mon Sep 17 00:00:00 2001 From: hung phan Date: Fri, 26 Apr 2024 12:00:14 +0700 Subject: [PATCH 303/341] JAMES-2586 Fix some disabled tests in PostgresBlobStoreDAOTest by using connection pool --- .../postgres/utils/PostgresExecutor.java | 13 +--- .../backends/postgres/PostgresExtension.java | 63 ++++++++++++++++--- .../postgres/PostgresBlobStoreDAOTest.java | 33 +--------- 3 files changed, 56 insertions(+), 53 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index 195606844ba..aaa0b1f205b 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -107,11 +107,6 @@ private PostgresExecutor(Optional domain, this.metricFactory = metricFactory; } - public Mono dslContext() { - return jamesPostgresConnectionFactory.getConnection(domain) - .map(con -> DSL.using(con, PGSQL_DIALECT, SETTINGS)); - } - public Mono dslContext(Connection connection) { return Mono.fromCallable(() -> DSL.using(connection, PGSQL_DIALECT, SETTINGS)); } @@ -209,13 +204,9 @@ public JamesPostgresConnectionFactory connectionFactory() { return jamesPostgresConnectionFactory; } - public Mono connection() { - return jamesPostgresConnectionFactory.getConnection(domain); - } - @VisibleForTesting - public Mono dispose() { - return jamesPostgresConnectionFactory.close(); + public void dispose() { + jamesPostgresConnectionFactory.close().block(); } private Predicate preparedStatementConflictException() { diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index 28c0f51a929..8166d71d12c 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -24,10 +24,12 @@ import java.io.IOException; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; import org.apache.james.GuiceModuleTestExtension; import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.PoolBackedPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.backends.postgres.utils.SinglePostgresConnectionFactory; import org.apache.james.metrics.tests.RecordingMetricFactory; @@ -42,9 +44,31 @@ import io.r2dbc.postgresql.PostgresqlConnectionFactory; import io.r2dbc.spi.Connection; import io.r2dbc.spi.ConnectionFactory; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; public class PostgresExtension implements GuiceModuleTestExtension { + public enum PoolSize { + SMALL(1, 2), + LARGE(10, 20); + + private final int min; + private final int max; + + PoolSize(int min, int max) { + this.min = min; + this.max = max; + } + + public int getMin() { + return min; + } + + public int getMax() { + return max; + } + } + private static final boolean ROW_LEVEL_SECURITY_ENABLED = true; public static PostgresExtension withRowLevelSecurity(PostgresModule module) { @@ -52,21 +76,28 @@ public static PostgresExtension withRowLevelSecurity(PostgresModule module) { } public static PostgresExtension withoutRowLevelSecurity(PostgresModule module) { - return new PostgresExtension(module, !ROW_LEVEL_SECURITY_ENABLED); + return withoutRowLevelSecurity(module, PoolSize.SMALL); + } + + public static PostgresExtension withoutRowLevelSecurity(PostgresModule module, PoolSize poolSize) { + return new PostgresExtension(module, !ROW_LEVEL_SECURITY_ENABLED, Optional.of(poolSize)); } public static PostgresExtension empty() { return withoutRowLevelSecurity(PostgresModule.EMPTY_MODULE); } + public static final PoolSize DEFAULT_POOL_SIZE = PoolSize.SMALL; public static PostgreSQLContainer PG_CONTAINER = DockerPostgresSingleton.SINGLETON; private final PostgresModule postgresModule; private final boolean rlsEnabled; private final PostgresFixture.Database selectedDatabase; + private PoolSize poolSize; private PostgresConfiguration postgresConfiguration; private PostgresExecutor postgresExecutor; private PostgresExecutor nonRLSPostgresExecutor; private PostgresqlConnectionFactory connectionFactory; + private Connection superConnection; private PostgresExecutor.Factory executorFactory; private PostgresTableManager postgresTableManager; @@ -81,12 +112,17 @@ public void unpause() { } private PostgresExtension(PostgresModule postgresModule, boolean rlsEnabled) { + this(postgresModule, rlsEnabled, Optional.empty()); + } + + private PostgresExtension(PostgresModule postgresModule, boolean rlsEnabled, Optional maybePoolSize) { this.postgresModule = postgresModule; this.rlsEnabled = rlsEnabled; if (rlsEnabled) { this.selectedDatabase = PostgresFixture.Database.ROW_LEVEL_SECURITY_DATABASE; } else { this.selectedDatabase = DEFAULT_DATABASE; + this.poolSize = maybePoolSize.orElse(DEFAULT_POOL_SIZE); } } @@ -139,12 +175,16 @@ private void initPostgresSession() { .password(postgresConfiguration.getCredential().getPassword()) .build()); + superConnection = connectionFactory.create().block(); + if (rlsEnabled) { executorFactory = new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(connectionFactory), postgresConfiguration, new RecordingMetricFactory()); } else { - executorFactory = new PostgresExecutor.Factory(new SinglePostgresConnectionFactory(connectionFactory.create() - .cache() - .cast(Connection.class).block()), + executorFactory = new PostgresExecutor.Factory( + new PoolBackedPostgresConnectionFactory(false, + Optional.of(poolSize.getMin()), + Optional.of(poolSize.getMax()), + connectionFactory), postgresConfiguration, new RecordingMetricFactory()); } @@ -162,7 +202,9 @@ private void initPostgresSession() { nonRLSPostgresExecutor = postgresExecutor; } - this.postgresTableManager = new PostgresTableManager(postgresExecutor, postgresModule, postgresConfiguration); + this.postgresTableManager = new PostgresTableManager(new PostgresExecutor.Factory(new SinglePostgresConnectionFactory(superConnection), postgresConfiguration, new RecordingMetricFactory()).create(), + postgresModule, + postgresConfiguration); } @Override @@ -171,7 +213,9 @@ public void afterAll(ExtensionContext extensionContext) { } private void disposePostgresSession() { - postgresExecutor.dispose().block(); + postgresExecutor.dispose(); + nonRLSPostgresExecutor.dispose(); + superConnection.close(); } @Override @@ -205,7 +249,7 @@ public Integer getMappedPort() { } public Mono getConnection() { - return postgresExecutor.connection(); + return Mono.just(superConnection); } public PostgresExecutor getPostgresExecutor() { @@ -243,9 +287,8 @@ private void dropTables(List tables) { .map(tableName -> "\"" + tableName + "\"") .collect(Collectors.joining(", ")); - postgresExecutor.connection() - .flatMapMany(connection -> connection.createStatement(String.format("DROP table if exists %s cascade;", tablesToDelete)) - .execute()) + Flux.from(superConnection.createStatement(String.format("DROP table if exists %s cascade;", tablesToDelete)) + .execute()) .then() .block(); } diff --git a/server/blob/blob-postgres/src/test/java/org/apache/james/blob/postgres/PostgresBlobStoreDAOTest.java b/server/blob/blob-postgres/src/test/java/org/apache/james/blob/postgres/PostgresBlobStoreDAOTest.java index 1c2e8bcbefe..9744d90528d 100644 --- a/server/blob/blob-postgres/src/test/java/org/apache/james/blob/postgres/PostgresBlobStoreDAOTest.java +++ b/server/blob/blob-postgres/src/test/java/org/apache/james/blob/postgres/PostgresBlobStoreDAOTest.java @@ -29,7 +29,7 @@ class PostgresBlobStoreDAOTest implements BlobStoreDAOContract { @RegisterExtension - static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresBlobStorageModule.MODULE); + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresBlobStorageModule.MODULE, PostgresExtension.PoolSize.LARGE); private PostgresBlobStoreDAO blobStore; @@ -47,35 +47,4 @@ public BlobStoreDAO testee() { @Disabled("Not supported") public void listBucketsShouldReturnBucketsWithNoBlob() { } - - @Override - @Disabled("The test is not valid because the upload parallelism with big blobs takes time and the test does not waiting for the end of the upload") - public void concurrentSaveByteSourceShouldReturnConsistentValues(String description, byte[] bytes) { - } - - @Override - @Disabled("The test is not valid because the upload parallelism with big blobs takes time and the test does not waiting for the end of the upload") - public void concurrentSaveInputStreamShouldReturnConsistentValues(String description, byte[] bytes) { - } - - @Override - @Disabled("The test is not valid because the upload parallelism with big blobs takes time and the test does not waiting for the end of the upload") - public void concurrentSaveBytesShouldReturnConsistentValues(String description, byte[] bytes) { - } - - @Override - @Disabled("The test is not valid because the upload parallelism with big blobs takes time and the test does not waiting for the end of the upload") - public void mixingSaveReadAndDeleteShouldReturnConsistentState() { - } - - @Override - @Disabled("The test is not valid because the upload parallelism with big blobs takes time and the test does not waiting for the end of the upload") - public void readShouldNotReadPartiallyWhenDeletingConcurrentlyBigBlob() { - } - - @Override - @Disabled("The test is not valid because the upload parallelism with big blobs takes time and the test does not waiting for the end of the upload") - public void readBytesShouldNotReadPartiallyWhenDeletingConcurrentlyBigBlob() { - } - } From e3b4f2c215533c953cb9d23ab59986f9f0e1b2d4 Mon Sep 17 00:00:00 2001 From: hung phan Date: Thu, 2 May 2024 13:54:46 +0700 Subject: [PATCH 304/341] JAMES-2586 Create rls-bypass instance for PoolBackedPostgresConnectionFactory --- .../postgres/PostgresConfiguration.java | 59 ++++++++++++++++--- .../PoolBackedPostgresConnectionFactory.java | 6 +- .../backends/postgres/PostgresExtension.java | 4 +- .../sample-configuration/postgres.properties | 12 +++- .../modules/data/PostgresCommonModule.java | 15 +++-- 5 files changed, 74 insertions(+), 22 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java index bfffc4ddeef..9a5e2fa63c8 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java @@ -46,7 +46,13 @@ public class PostgresConfiguration { public static final String NON_RLS_PASSWORD = "database.non-rls.password"; public static final String RLS_ENABLED = "row.level.security.enabled"; public static final String POOL_INITIAL_SIZE = "pool.initial.size"; + public static final int POOL_INITIAL_SIZE_DEFAULT_VALUE = 10; public static final String POOL_MAX_SIZE = "pool.max.size"; + public static final int POOL_MAX_SIZE_DEFAULT_VALUE = 15; + public static final String NON_RLS_POOL_INITIAL_SIZE = "non-rls.pool.initial.size"; + public static final int NON_RLS_POOL_INITIAL_SIZE_DEFAULT_VALUE = 5; + public static final String NON_RLS_POOL_MAX_SIZE = "non-rls.pool.max.size"; + public static final int NON_RLS_POOL_MAX_SIZE_DEFAULT_VALUE = 10; public static final String SSL_MODE = "ssl.mode"; public static final String SSL_MODE_DEFAULT_VALUE = "allow"; public static final String JOOQ_REACTIVE_TIMEOUT = "jooq.reactive.timeout"; @@ -83,6 +89,8 @@ public static class Builder { private Optional rowLevelSecurityEnabled = Optional.empty(); private Optional poolInitialSize = Optional.empty(); private Optional poolMaxSize = Optional.empty(); + private Optional nonRLSPoolInitialSize = Optional.empty(); + private Optional nonRLSPoolMaxSize = Optional.empty(); private Optional sslMode = Optional.empty(); private Optional jooqReactiveTimeout = Optional.empty(); @@ -196,6 +204,26 @@ public Builder poolMaxSize(Integer poolMaxSize) { return this; } + public Builder nonRLSPoolInitialSize(Optional nonRLSPoolInitialSize) { + this.nonRLSPoolInitialSize = nonRLSPoolInitialSize; + return this; + } + + public Builder nonRLSPoolInitialSize(Integer nonRLSPoolInitialSize) { + this.nonRLSPoolInitialSize = Optional.of(nonRLSPoolInitialSize); + return this; + } + + public Builder nonRLSPoolMaxSize(Optional nonRLSPoolMaxSize) { + this.nonRLSPoolMaxSize = nonRLSPoolMaxSize; + return this; + } + + public Builder nonRLSPoolMaxSize(Integer nonRLSPoolMaxSize) { + this.nonRLSPoolMaxSize = Optional.of(nonRLSPoolMaxSize); + return this; + } + public Builder sslMode(Optional sslMode) { this.sslMode = sslMode; return this; @@ -227,8 +255,10 @@ public PostgresConfiguration build() { new Credential(username.get(), password.get()), new Credential(nonRLSUser.orElse(username.get()), nonRLSPassword.orElse(password.get())), rowLevelSecurityEnabled.orElse(false), - poolInitialSize, - poolMaxSize, + poolInitialSize.orElse(POOL_INITIAL_SIZE_DEFAULT_VALUE), + poolMaxSize.orElse(POOL_MAX_SIZE_DEFAULT_VALUE), + nonRLSPoolInitialSize.orElse(NON_RLS_POOL_INITIAL_SIZE_DEFAULT_VALUE), + nonRLSPoolMaxSize.orElse(NON_RLS_POOL_MAX_SIZE_DEFAULT_VALUE), SSLMode.fromValue(sslMode.orElse(SSL_MODE_DEFAULT_VALUE)), jooqReactiveTimeout.orElse(JOOQ_REACTIVE_TIMEOUT_DEFAULT_VALUE)); } @@ -251,6 +281,8 @@ public static PostgresConfiguration from(Configuration propertiesConfiguration) .rowLevelSecurityEnabled(propertiesConfiguration.getBoolean(RLS_ENABLED, false)) .poolInitialSize(Optional.ofNullable(propertiesConfiguration.getInteger(POOL_INITIAL_SIZE, null))) .poolMaxSize(Optional.ofNullable(propertiesConfiguration.getInteger(POOL_MAX_SIZE, null))) + .nonRLSPoolInitialSize(Optional.ofNullable(propertiesConfiguration.getInteger(NON_RLS_POOL_INITIAL_SIZE, null))) + .nonRLSPoolMaxSize(Optional.ofNullable(propertiesConfiguration.getInteger(NON_RLS_POOL_MAX_SIZE, null))) .sslMode(Optional.ofNullable(propertiesConfiguration.getString(SSL_MODE))) .jooqReactiveTimeout(Optional.ofNullable(propertiesConfiguration.getString(JOOQ_REACTIVE_TIMEOUT)) .map(value -> DurationParser.parse(value, ChronoUnit.SECONDS))) @@ -264,14 +296,17 @@ public static PostgresConfiguration from(Configuration propertiesConfiguration) private final Credential credential; private final Credential nonRLSCredential; private final boolean rowLevelSecurityEnabled; - private final Optional poolInitialSize; - private final Optional poolMaxSize; + private final Integer poolInitialSize; + private final Integer poolMaxSize; + private final Integer nonRLSPoolInitialSize; + private final Integer nonRLSPoolMaxSize; private final SSLMode sslMode; private final Duration jooqReactiveTimeout; private PostgresConfiguration(String host, int port, String databaseName, String databaseSchema, Credential credential, Credential nonRLSCredential, boolean rowLevelSecurityEnabled, - Optional poolInitialSize, Optional poolMaxSize, + Integer poolInitialSize, Integer poolMaxSize, + Integer nonRLSPoolInitialSize, Integer nonRLSPoolMaxSize, SSLMode sslMode, Duration jooqReactiveTimeout) { this.host = host; this.port = port; @@ -282,6 +317,8 @@ private PostgresConfiguration(String host, int port, String databaseName, String this.rowLevelSecurityEnabled = rowLevelSecurityEnabled; this.poolInitialSize = poolInitialSize; this.poolMaxSize = poolMaxSize; + this.nonRLSPoolInitialSize = nonRLSPoolInitialSize; + this.nonRLSPoolMaxSize = nonRLSPoolMaxSize; this.sslMode = sslMode; this.jooqReactiveTimeout = jooqReactiveTimeout; } @@ -314,14 +351,22 @@ public boolean rowLevelSecurityEnabled() { return rowLevelSecurityEnabled; } - public Optional poolInitialSize() { + public Integer poolInitialSize() { return poolInitialSize; } - public Optional poolMaxSize() { + public Integer poolMaxSize() { return poolMaxSize; } + public Integer nonRLSPoolInitialSize() { + return nonRLSPoolInitialSize; + } + + public Integer nonRLSPoolMaxSize() { + return nonRLSPoolMaxSize; + } + public SSLMode getSslMode() { return sslMode; } diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java index ea97e706273..ba558495b32 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java @@ -41,10 +41,8 @@ public class PoolBackedPostgresConnectionFactory implements JamesPostgresConnect private final boolean rowLevelSecurityEnabled; private final ConnectionPool pool; - public PoolBackedPostgresConnectionFactory(boolean rowLevelSecurityEnabled, Optional maybeInitialSize, Optional maybeMaxSize, ConnectionFactory connectionFactory) { + public PoolBackedPostgresConnectionFactory(boolean rowLevelSecurityEnabled, int initialSize, int maxSize, ConnectionFactory connectionFactory) { this.rowLevelSecurityEnabled = rowLevelSecurityEnabled; - int initialSize = maybeInitialSize.orElse(DEFAULT_INITIAL_SIZE); - int maxSize = maybeMaxSize.orElse(DEFAULT_MAX_SIZE); ConnectionPoolConfiguration configuration = ConnectionPoolConfiguration.builder(connectionFactory) .initialSize(initialSize) .maxSize(maxSize) @@ -54,7 +52,7 @@ public PoolBackedPostgresConnectionFactory(boolean rowLevelSecurityEnabled, Opti } public PoolBackedPostgresConnectionFactory(boolean rowLevelSecurityEnabled, ConnectionFactory connectionFactory) { - this(rowLevelSecurityEnabled, Optional.empty(), Optional.empty(), connectionFactory); + this(rowLevelSecurityEnabled, DEFAULT_INITIAL_SIZE, DEFAULT_MAX_SIZE, connectionFactory); } @Override diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index 8166d71d12c..bfb5c3daf2f 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -182,8 +182,8 @@ private void initPostgresSession() { } else { executorFactory = new PostgresExecutor.Factory( new PoolBackedPostgresConnectionFactory(false, - Optional.of(poolSize.getMin()), - Optional.of(poolSize.getMax()), + poolSize.getMin(), + poolSize.getMax(), connectionFactory), postgresConfiguration, new RecordingMetricFactory()); diff --git a/server/apps/postgres-app/sample-configuration/postgres.properties b/server/apps/postgres-app/sample-configuration/postgres.properties index a8780c51337..85cefb1ba3a 100644 --- a/server/apps/postgres-app/sample-configuration/postgres.properties +++ b/server/apps/postgres-app/sample-configuration/postgres.properties @@ -25,11 +25,17 @@ row.level.security.enabled=false # String. It is required when row.level.security.enabled is true. Database password of non-rls user. #database.non-rls.password=secret1 -# Integer. Optional, default to 5432. Database connection pool initial size. +# Integer. Optional, default to 10. Database connection pool initial size. pool.initial.size=10 -# Integer. Optional, default to 5432. Database connection pool max size. -pool.max.size=20 +# Integer. Optional, default to 15. Database connection pool max size. +pool.max.size=15 + +# Integer. Optional, default to 5. rls-bypass database connection pool initial size. +non-rls.pool.initial.size=5 + +# Integer. Optional, default to 10. rls-bypass database connection pool max size. +non-rls.pool.max.size=10 # String. Optional, defaults to allow. SSLMode required to connect to the Postgresql db server. # Check https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-PROTECTION for a list of supported SSLModes. diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index 245748a6d16..592879b75e1 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -33,7 +33,6 @@ import org.apache.james.backends.postgres.utils.PostgresConnectionClosure; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.backends.postgres.utils.PostgresHealthCheck; -import org.apache.james.backends.postgres.utils.SinglePostgresConnectionFactory; import org.apache.james.core.healthcheck.HealthCheck; import org.apache.james.metrics.api.MetricFactory; import org.apache.james.utils.InitializationOperation; @@ -53,10 +52,10 @@ import io.r2dbc.postgresql.PostgresqlConnectionConfiguration; import io.r2dbc.postgresql.PostgresqlConnectionFactory; import io.r2dbc.spi.ConnectionFactory; -import reactor.core.publisher.Mono; public class PostgresCommonModule extends AbstractModule { private static final Logger LOGGER = LoggerFactory.getLogger("POSTGRES"); + private static final boolean DISABLED_ROW_LEVEL_SECURITY = false; @Override public void configure() { @@ -79,8 +78,6 @@ PostgresConfiguration provideConfiguration(PropertiesProvider propertiesProvider @Singleton JamesPostgresConnectionFactory provideJamesPostgresConnectionFactory(PostgresConfiguration postgresConfiguration, ConnectionFactory connectionFactory) { - LOGGER.info("Is PostgreSQL row level security enabled? {}", postgresConfiguration.rowLevelSecurityEnabled()); - LOGGER.info("Implementation for PostgreSQL connection factory: {}", PoolBackedPostgresConnectionFactory.class.getName()); return new PoolBackedPostgresConnectionFactory(postgresConfiguration.rowLevelSecurityEnabled(), postgresConfiguration.poolInitialSize(), postgresConfiguration.poolMaxSize(), @@ -91,9 +88,15 @@ JamesPostgresConnectionFactory provideJamesPostgresConnectionFactory(PostgresCon @Named(JamesPostgresConnectionFactory.NON_RLS_INJECT) @Singleton JamesPostgresConnectionFactory provideJamesPostgresConnectionFactoryWithRLSBypass(PostgresConfiguration postgresConfiguration, + JamesPostgresConnectionFactory jamesPostgresConnectionFactory, @Named(JamesPostgresConnectionFactory.NON_RLS_INJECT) ConnectionFactory connectionFactory) { - LOGGER.info("Implementation for PostgresSQL connection factory: {}", SinglePostgresConnectionFactory.class.getName()); - return new SinglePostgresConnectionFactory(Mono.from(connectionFactory.create()).block()); + if (!postgresConfiguration.rowLevelSecurityEnabled()) { + return jamesPostgresConnectionFactory; + } + return new PoolBackedPostgresConnectionFactory(DISABLED_ROW_LEVEL_SECURITY, + postgresConfiguration.nonRLSPoolInitialSize(), + postgresConfiguration.nonRLSPoolMaxSize(), + connectionFactory); } @Provides From c8ed44b3476e390c91dd3606353a2c2a90ec7d40 Mon Sep 17 00:00:00 2001 From: hung phan Date: Fri, 17 May 2024 16:40:24 +0700 Subject: [PATCH 305/341] JAMES-2586 Update PoolBackedPostgresConnectionFactory to avoid running set-domain command in case of empty domain --- .../PoolBackedPostgresConnectionFactory.java | 17 ++++------------- .../JamesPostgresConnectionFactoryTest.java | 16 ---------------- 2 files changed, 4 insertions(+), 29 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java index ba558495b32..441af557e04 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java @@ -33,8 +33,6 @@ public class PoolBackedPostgresConnectionFactory implements JamesPostgresConnectionFactory { private static final Logger LOGGER = LoggerFactory.getLogger(PoolBackedPostgresConnectionFactory.class); - private static final Domain DEFAULT = Domain.of("default"); - private static final String DEFAULT_DOMAIN_ATTRIBUTE_VALUE = ""; private static final int DEFAULT_INITIAL_SIZE = 10; private static final int DEFAULT_MAX_SIZE = 20; @@ -56,9 +54,10 @@ public PoolBackedPostgresConnectionFactory(boolean rowLevelSecurityEnabled, Conn } @Override - public Mono getConnection(Optional domain) { + public Mono getConnection(Optional maybeDomain) { if (rowLevelSecurityEnabled) { - return pool.create().flatMap(connection -> setDomainAttributeForConnection(domain.orElse(DEFAULT), connection)); + return pool.create().flatMap(connection -> maybeDomain.map(domain -> setDomainAttributeForConnection(domain, connection)) + .orElse(Mono.just(connection))); } else { return pool.create(); } @@ -75,17 +74,9 @@ public Mono close() { } private Mono setDomainAttributeForConnection(Domain domain, Connection connection) { - return Mono.from(connection.createStatement("SET " + DOMAIN_ATTRIBUTE + " TO '" + getDomainAttributeValue(domain) + "'") // It should be set value via Bind, but it doesn't work + return Mono.from(connection.createStatement("SET " + DOMAIN_ATTRIBUTE + " TO '" + domain.asString() + "'") // It should be set value via Bind, but it doesn't work .execute()) .doOnError(e -> LOGGER.error("Error while setting domain attribute for domain {}", domain, e)) .then(Mono.just(connection)); } - - private String getDomainAttributeValue(Domain domain) { - if (DEFAULT.equals(domain)) { - return DEFAULT_DOMAIN_ATTRIBUTE_VALUE; - } else { - return domain.asString(); - } - } } diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/JamesPostgresConnectionFactoryTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/JamesPostgresConnectionFactoryTest.java index 98fb54de436..4c42b0144c8 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/JamesPostgresConnectionFactoryTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/JamesPostgresConnectionFactoryTest.java @@ -31,7 +31,6 @@ import io.r2dbc.spi.Connection; import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; public abstract class JamesPostgresConnectionFactoryTest { @@ -70,21 +69,6 @@ void getConnectionShouldSetCurrentDomainAttribute() { assertThat(actual).isEqualTo(domain.asString()); } - @Test - void getConnectionWithoutDomainShouldReturnEmptyAttribute() { - Connection connection = jamesPostgresConnectionFactory().getConnection(Optional.empty()).block(); - - String message = Flux.from(connection.createStatement("show " + JamesPostgresConnectionFactory.DOMAIN_ATTRIBUTE) - .execute()) - .flatMap(result -> result.map((row, rowMetadata) -> row.get(0, String.class))) - .collect(ImmutableList.toImmutableList()) - .map(strings -> "") - .onErrorResume(throwable -> Mono.just(throwable.getMessage())) - .block(); - - assertThat(message).isEqualTo(""); - } - String getDomainAttributeValue(Connection connection) { return Flux.from(connection.createStatement("show " + JamesPostgresConnectionFactory.DOMAIN_ATTRIBUTE) .execute()) From 2ecf03026f269e6381d173804d5a3720c4643931 Mon Sep 17 00:00:00 2001 From: Rene Cordier Date: Fri, 24 May 2024 16:49:02 +0700 Subject: [PATCH 306/341] JAMES-2586 Fix sequential issue with updating flags in the reactive pipeline --- .../postgres/mail/PostgresMessageMapper.java | 3 +- .../store/mail/model/MessageMapperTest.java | 46 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java index ff00ee4e2f4..5112324b10f 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapper.java @@ -20,6 +20,7 @@ package org.apache.james.mailbox.postgres.mail; import static org.apache.james.blob.api.BlobStore.StoragePolicy.LOW_COST; +import static org.apache.james.util.ReactorUtils.DEFAULT_CONCURRENCY; import java.io.IOException; import java.io.InputStream; @@ -290,7 +291,7 @@ private Flux updatedFlags(List list FlagsUpdateCalculator flagsUpdateCalculator) { return modSeqProvider.nextModSeqReactive(mailbox.getMailboxId()) .flatMapMany(newModSeq -> Flux.fromIterable(listMessagesMetaData) - .flatMap(messageMetaData -> updateFlags(messageMetaData, flagsUpdateCalculator, newModSeq))); + .flatMapSequential(messageMetaData -> updateFlags(messageMetaData, flagsUpdateCalculator, newModSeq), DEFAULT_CONCURRENCY)); } private Mono updateFlags(ComposedMessageIdWithMetaData currentMetaData, diff --git a/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MessageMapperTest.java b/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MessageMapperTest.java index 469b74e0b5f..ae012ea0e62 100644 --- a/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MessageMapperTest.java +++ b/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MessageMapperTest.java @@ -60,6 +60,7 @@ import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; import org.apache.james.util.concurrency.ConcurrentTestRunner; +import org.apache.james.util.streams.Iterators; import org.apache.james.utils.UpdatableTickingClock; import org.junit.Assume; import org.junit.jupiter.api.BeforeEach; @@ -850,6 +851,51 @@ void updateFlagsWithRangeAllRangeShouldAffectAllMessages() throws MailboxExcepti .hasSize(5); } + @Test + void updateFlagsOnRangeShouldReturnUpdatedFlagsWithUidOrderAsc() throws MailboxException { + saveMessages(); + + Iterator it = messageMapper.updateFlags(benwaInboxMailbox, + new FlagsUpdateCalculator(new Flags(Flags.Flag.SEEN), FlagsUpdateMode.REPLACE), + MessageRange.range(message1.getUid(), message3.getUid())); + List updatedFlagsUids = Iterators.toStream(it) + .map(UpdatedFlags::getUid) + .collect(ImmutableList.toImmutableList()); + + assertThat(updatedFlagsUids) + .containsExactly(message1.getUid(), message2.getUid(), message3.getUid()); + } + + @Test + void updateFlagsWithRangeFromShouldReturnUpdatedFlagsWithUidOrderAsc() throws MailboxException { + saveMessages(); + + Iterator it = messageMapper.updateFlags(benwaInboxMailbox, + new FlagsUpdateCalculator(new Flags(Flags.Flag.SEEN), FlagsUpdateMode.REPLACE), + MessageRange.from(message3.getUid())); + List updatedFlagsUids = Iterators.toStream(it) + .map(UpdatedFlags::getUid) + .collect(ImmutableList.toImmutableList()); + + assertThat(updatedFlagsUids) + .containsExactly(message3.getUid(), message4.getUid(), message5.getUid()); + } + + @Test + void updateFlagsWithRangeAllRangeShouldReturnUpdatedFlagsWithUidOrderAsc() throws MailboxException { + saveMessages(); + + Iterator it = messageMapper.updateFlags(benwaInboxMailbox, + new FlagsUpdateCalculator(new Flags(Flags.Flag.SEEN), FlagsUpdateMode.REPLACE), + MessageRange.all()); + List updatedFlagsUids = Iterators.toStream(it) + .map(UpdatedFlags::getUid) + .collect(ImmutableList.toImmutableList()); + + assertThat(updatedFlagsUids) + .containsExactly(message1.getUid(), message2.getUid(), message3.getUid(), message4.getUid(), message5.getUid()); + } + @Test void messagePropertiesShouldBeStored() throws Exception { PropertyBuilder propBuilder = new PropertyBuilder(); From 8494d584048dd716c1d9ca367c6767e0f731f165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20H=E1=BB=93ng=20Qu=C3=A2n?= <55171818+quantranhong1999@users.noreply.github.com> Date: Thu, 6 Jun 2024 13:52:30 +0700 Subject: [PATCH 307/341] JAMES-2586 Postgres app should use Java 21 base image (#2277) --- server/apps/postgres-app/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/apps/postgres-app/pom.xml b/server/apps/postgres-app/pom.xml index cc5c8feeff4..5259a08dcd0 100644 --- a/server/apps/postgres-app/pom.xml +++ b/server/apps/postgres-app/pom.xml @@ -346,7 +346,7 @@ jib-maven-plugin - eclipse-temurin:11-jre-jammy + eclipse-temurin:21-jre-jammy apache/james From 20e1c8f77a8fd32ef28f1865fb3e6bbaa07a8914 Mon Sep 17 00:00:00 2001 From: vttran Date: Thu, 6 Jun 2024 13:53:14 +0700 Subject: [PATCH 308/341] JAMES-2586 - Rename class DeletedMessageVaultDeletionCallback -> PostgresDeletedMessageVaultDeletionCallback (#2280) To fixing exception: java.lang.ClassCastException: class org.apache.james.vault.metadata.DeletedMessageVaultDeletionCallback cannot be cast to class org.apache.james.mailbox.postgres.DeleteMessageListener$DeletionCallback (org.apache.james.vault.metadata.DeletedMessageVaultDeletionCallback and org.apache.james.mailbox.postgres.DeleteMessageListener$DeletionCallback are in unnamed module of loader 'app') --- ...ava => PostgresDeletedMessageVaultDeletionCallback.java} | 6 +++--- .../modules/mailbox/PostgresDeletedMessageVaultModule.java | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) rename mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/{DeletedMessageVaultDeletionCallback.java => PostgresDeletedMessageVaultDeletionCallback.java} (94%) diff --git a/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/DeletedMessageVaultDeletionCallback.java b/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/PostgresDeletedMessageVaultDeletionCallback.java similarity index 94% rename from mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/DeletedMessageVaultDeletionCallback.java rename to mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/PostgresDeletedMessageVaultDeletionCallback.java index 2c1267b5fc7..224d2a492ed 100644 --- a/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/DeletedMessageVaultDeletionCallback.java +++ b/mailbox/plugin/deleted-messages-vault-postgres/src/main/java/org/apache/james/vault/metadata/PostgresDeletedMessageVaultDeletionCallback.java @@ -55,15 +55,15 @@ import reactor.core.publisher.Mono; -public class DeletedMessageVaultDeletionCallback implements DeleteMessageListener.DeletionCallback { - private static final Logger LOGGER = LoggerFactory.getLogger(DeletedMessageVaultDeletionCallback.class); +public class PostgresDeletedMessageVaultDeletionCallback implements DeleteMessageListener.DeletionCallback { + private static final Logger LOGGER = LoggerFactory.getLogger(PostgresDeletedMessageVaultDeletionCallback.class); private final DeletedMessageVault deletedMessageVault; private final BlobStore blobStore; private final Clock clock; @Inject - public DeletedMessageVaultDeletionCallback(DeletedMessageVault deletedMessageVault, BlobStore blobStore, Clock clock) { + public PostgresDeletedMessageVaultDeletionCallback(DeletedMessageVault deletedMessageVault, BlobStore blobStore, Clock clock) { this.deletedMessageVault = deletedMessageVault; this.blobStore = blobStore; this.clock = clock; diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresDeletedMessageVaultModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresDeletedMessageVaultModule.java index 607776982f5..d444bc4f1f8 100644 --- a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresDeletedMessageVaultModule.java +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/PostgresDeletedMessageVaultModule.java @@ -23,9 +23,9 @@ import org.apache.james.mailbox.postgres.DeleteMessageListener; import org.apache.james.modules.vault.DeletedMessageVaultModule; import org.apache.james.vault.metadata.DeletedMessageMetadataVault; -import org.apache.james.vault.metadata.DeletedMessageVaultDeletionCallback; import org.apache.james.vault.metadata.PostgresDeletedMessageMetadataModule; import org.apache.james.vault.metadata.PostgresDeletedMessageMetadataVault; +import org.apache.james.vault.metadata.PostgresDeletedMessageVaultDeletionCallback; import com.google.inject.AbstractModule; import com.google.inject.Scopes; @@ -45,6 +45,6 @@ protected void configure() { Multibinder.newSetBinder(binder(), DeleteMessageListener.DeletionCallback.class) .addBinding() - .to(DeletedMessageVaultDeletionCallback.class); + .to(PostgresDeletedMessageVaultDeletionCallback.class); } } From c00789956102e77da8bea6f2d9c8061461fc2281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20H=E1=BB=93ng=20Qu=C3=A2n?= <55171818+quantranhong1999@users.noreply.github.com> Date: Mon, 17 Jun 2024 19:40:58 +0700 Subject: [PATCH 309/341] [BUILD] Increase jOOQ reactive timeout for testing (#2301) * [BUILD] Increase jOOQ reactive timeout for testing After Apache James CI issue with infra recently, the tests are somehow unstable. PostgresBlobStoreDAOTest.concurrentSaveInputStreamShouldReturnConsistentValues: ``` 18:39:45.087 [ERROR] o.a.j.b.p.u.PostgresExecutor - Time out executing Postgres query. May need to check either jOOQ reactive issue or Postgres DB performance. java.util.concurrent.TimeoutException: Did not observe any item or terminal signal within 10000ms in 'flatMap' (and no fallback has been configured) ``` Note that the failure rate locally is really low though... * JAMES-2586 - Override duration timeout for concurrent test in PostgresBlobStoreDAOTest Co-authored-by: Tung Van TRAN --------- Co-authored-by: Tung Van TRAN --- .../backends/postgres/PostgresExtension.java | 2 + .../postgres/PostgresBlobStoreDAOTest.java | 48 ++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index bfb5c3daf2f..a48fd8295cd 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -23,6 +23,7 @@ import static org.apache.james.backends.postgres.PostgresFixture.Database.ROW_LEVEL_SECURITY_DATABASE; import java.io.IOException; +import java.time.Duration; import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -162,6 +163,7 @@ private void initPostgresSession() { .nonRLSUser(DEFAULT_DATABASE.dbUser()) .nonRLSPassword(DEFAULT_DATABASE.dbPassword()) .rowLevelSecurityEnabled(rlsEnabled) + .jooqReactiveTimeout(Optional.of(Duration.ofSeconds(20L))) .build(); PostgresqlConnectionConfiguration.Builder connectionBaseBuilder = PostgresqlConnectionConfiguration.builder() diff --git a/server/blob/blob-postgres/src/test/java/org/apache/james/blob/postgres/PostgresBlobStoreDAOTest.java b/server/blob/blob-postgres/src/test/java/org/apache/james/blob/postgres/PostgresBlobStoreDAOTest.java index 9744d90528d..8562be38d79 100644 --- a/server/blob/blob-postgres/src/test/java/org/apache/james/blob/postgres/PostgresBlobStoreDAOTest.java +++ b/server/blob/blob-postgres/src/test/java/org/apache/james/blob/postgres/PostgresBlobStoreDAOTest.java @@ -19,15 +19,31 @@ package org.apache.james.blob.postgres; +import static org.apache.james.blob.api.BlobStoreDAOFixture.TEST_BLOB_ID; +import static org.apache.james.blob.api.BlobStoreDAOFixture.TEST_BUCKET_NAME; + +import java.io.ByteArrayInputStream; +import java.time.Duration; +import java.util.concurrent.ExecutionException; + import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.blob.api.BlobStoreDAO; import org.apache.james.blob.api.BlobStoreDAOContract; import org.apache.james.blob.api.HashBlobId; +import org.apache.james.util.concurrency.ConcurrentTestRunner; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import com.google.common.io.ByteSource; + +import reactor.core.publisher.Mono; class PostgresBlobStoreDAOTest implements BlobStoreDAOContract { + static Duration CONCURRENT_TEST_DURATION = Duration.ofMinutes(5); + @RegisterExtension static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresBlobStorageModule.MODULE, PostgresExtension.PoolSize.LARGE); @@ -47,4 +63,34 @@ public BlobStoreDAO testee() { @Disabled("Not supported") public void listBucketsShouldReturnBucketsWithNoBlob() { } -} + + @Override + @ParameterizedTest(name = "[{index}] {0}") + @MethodSource("blobs") + public void concurrentSaveByteSourceShouldReturnConsistentValues(String description, byte[] bytes) throws ExecutionException, InterruptedException { + Mono.from(testee().save(TEST_BUCKET_NAME, TEST_BLOB_ID, bytes)).block(); + ConcurrentTestRunner.builder() + .randomlyDistributedReactorOperations( + (threadNumber, step) -> testee().save(TEST_BUCKET_NAME, TEST_BLOB_ID, ByteSource.wrap(bytes)), + (threadNumber, step) -> checkConcurrentSaveOperation(bytes) + ) + .threadCount(10) + .operationCount(20) + .runSuccessfullyWithin(CONCURRENT_TEST_DURATION); + } + + @Override + @ParameterizedTest(name = "[{index}] {0}") + @MethodSource("blobs") + public void concurrentSaveInputStreamShouldReturnConsistentValues(String description, byte[] bytes) throws ExecutionException, InterruptedException { + Mono.from(testee().save(TEST_BUCKET_NAME, TEST_BLOB_ID, bytes)).block(); + ConcurrentTestRunner.builder() + .randomlyDistributedReactorOperations( + (threadNumber, step) -> testee().save(TEST_BUCKET_NAME, TEST_BLOB_ID, new ByteArrayInputStream(bytes)), + (threadNumber, step) -> checkConcurrentSaveOperation(bytes) + ) + .threadCount(10) + .operationCount(20) + .runSuccessfullyWithin(CONCURRENT_TEST_DURATION); + } +} \ No newline at end of file From 8f89620141f2bb4cd0d9e96aa59465b3bf9c71a0 Mon Sep 17 00:00:00 2001 From: Maksim <85022218+Maxxx873@users.noreply.github.com> Date: Tue, 18 Jun 2024 09:45:59 +0300 Subject: [PATCH 310/341] JAMES-3946 Add a DropLists postgresql backend (#2290) --- .../sample-configuration/droplists.properties | 3 + .../james/PostgresJamesConfiguration.java | 27 +++- .../apache/james/PostgresJamesServerMain.java | 17 ++- .../modules/data/PostgresDropListsModule.java | 33 +++++ .../droplists/postgres/PostgresDropList.java | 127 ++++++++++++++++++ .../postgres/PostgresDropListModule.java | 68 ++++++++++ .../postgres/PostgresDropListsTest.java | 43 ++++++ 7 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 server/apps/postgres-app/sample-configuration/droplists.properties create mode 100644 server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDropListsModule.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/droplists/postgres/PostgresDropList.java create mode 100644 server/data/data-postgres/src/main/java/org/apache/james/droplists/postgres/PostgresDropListModule.java create mode 100644 server/data/data-postgres/src/test/java/org/apache/james/droplists/postgres/PostgresDropListsTest.java diff --git a/server/apps/postgres-app/sample-configuration/droplists.properties b/server/apps/postgres-app/sample-configuration/droplists.properties new file mode 100644 index 00000000000..bbc27568cbc --- /dev/null +++ b/server/apps/postgres-app/sample-configuration/droplists.properties @@ -0,0 +1,3 @@ +# Configuration file for DropLists + +enabled=false \ No newline at end of file diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java index c4af2fdb497..4bf98c55ead 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java @@ -74,6 +74,7 @@ public static class Builder { private Optional eventBusImpl; private Optional deletedMessageVaultConfiguration; private Optional jmapEnabled; + private Optional dropListsEnabled; private Builder() { searchConfiguration = Optional.empty(); @@ -84,6 +85,7 @@ private Builder() { eventBusImpl = Optional.empty(); deletedMessageVaultConfiguration = Optional.empty(); jmapEnabled = Optional.empty(); + dropListsEnabled = Optional.empty(); } public Builder workingDirectory(String path) { @@ -144,6 +146,11 @@ public Builder jmapEnabled(Optional jmapEnabled) { return this; } + public Builder enableDropLists() { + this.dropListsEnabled = Optional.of(true); + return this; + } + public PostgresJamesConfiguration build() { ConfigurationPath configurationPath = this.configurationPath.orElse(new ConfigurationPath(FileSystem.FILE_PROTOCOL_AND_CONF)); JamesServerResourceLoader directories = new JamesServerResourceLoader(rootDirectory @@ -189,6 +196,14 @@ public PostgresJamesConfiguration build() { } }); + boolean dropListsEnabled = this.dropListsEnabled.orElseGet(() -> { + try { + return configurationProvider.getConfiguration("droplists").getBoolean("enabled", false); + } catch (ConfigurationException e) { + return false; + } + }); + LOGGER.info("BlobStore configuration {}", blobStoreConfiguration); return new PostgresJamesConfiguration( configurationPath, @@ -198,7 +213,8 @@ public PostgresJamesConfiguration build() { blobStoreConfiguration, eventBusImpl, deletedMessageVaultConfiguration, - jmapEnabled); + jmapEnabled, + dropListsEnabled); } } @@ -214,6 +230,7 @@ public static Builder builder() { private final EventBusImpl eventBusImpl; private final VaultConfiguration deletedMessageVaultConfiguration; private final boolean jmapEnabled; + private final boolean dropListsEnabled; private PostgresJamesConfiguration(ConfigurationPath configurationPath, JamesDirectoriesProvider directories, @@ -222,7 +239,8 @@ private PostgresJamesConfiguration(ConfigurationPath configurationPath, BlobStoreConfiguration blobStoreConfiguration, EventBusImpl eventBusImpl, VaultConfiguration deletedMessageVaultConfiguration, - boolean jmapEnabled) { + boolean jmapEnabled, + boolean dropListsEnabled) { this.configurationPath = configurationPath; this.directories = directories; this.searchConfiguration = searchConfiguration; @@ -231,6 +249,7 @@ private PostgresJamesConfiguration(ConfigurationPath configurationPath, this.eventBusImpl = eventBusImpl; this.deletedMessageVaultConfiguration = deletedMessageVaultConfiguration; this.jmapEnabled = jmapEnabled; + this.dropListsEnabled = dropListsEnabled; } @Override @@ -266,4 +285,8 @@ public VaultConfiguration getDeletedMessageVaultConfiguration() { public boolean isJmapEnabled() { return jmapEnabled; } + + public boolean isDropListsEnabled() { + return dropListsEnabled; + } } diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index cbd48190686..774d68705a5 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -40,6 +40,7 @@ import org.apache.james.modules.data.PostgresDataJmapModule; import org.apache.james.modules.data.PostgresDataModule; import org.apache.james.modules.data.PostgresDelegationStoreModule; +import org.apache.james.modules.data.PostgresDropListsModule; import org.apache.james.modules.data.PostgresEventStoreModule; import org.apache.james.modules.data.PostgresUsersRepositoryModule; import org.apache.james.modules.data.PostgresVacationModule; @@ -67,6 +68,7 @@ import org.apache.james.modules.server.DKIMMailetModule; import org.apache.james.modules.server.DLPRoutesModule; import org.apache.james.modules.server.DataRoutesModules; +import org.apache.james.modules.server.DropListsRoutesModule; import org.apache.james.modules.server.InconsistencyQuotasSolvingRoutesModule; import org.apache.james.modules.server.JMXServerModule; import org.apache.james.modules.server.JmapTasksModule; @@ -98,7 +100,8 @@ public class PostgresJamesServerMain implements JamesServerMain { private static final Module EVENT_STORE_JSON_SERIALIZATION_DEFAULT_MODULE = binder -> - binder.bind(new TypeLiteral>>() {}).annotatedWith(Names.named(EventNestedTypes.EVENT_NESTED_TYPES_INJECTION_NAME)) + binder.bind(new TypeLiteral>>() { + }).annotatedWith(Names.named(EventNestedTypes.EVENT_NESTED_TYPES_INJECTION_NAME)) .toInstance(ImmutableSet.of()); private static final Module WEBADMIN = Modules.combine( @@ -185,7 +188,8 @@ public static GuiceJamesServer createServer(PostgresJamesConfiguration configura .combineWith(chooseBlobStoreModules(configuration)) .combineWith(chooseDeletedMessageVaultModules(configuration.getDeletedMessageVaultConfiguration())) .overrideWith(chooseJmapModules(configuration)) - .overrideWith(chooseTaskManagerModules(configuration)); + .overrideWith(chooseTaskManagerModules(configuration)) + .overrideWith(chooseDropListsModule(configuration)); } private static List chooseUsersRepositoryModule(PostgresJamesConfiguration configuration) { @@ -247,4 +251,13 @@ private static Module chooseJmapModules(PostgresJamesConfiguration configuration return binder -> { }; } + + private static Module chooseDropListsModule(PostgresJamesConfiguration configuration) { + if (configuration.isDropListsEnabled()) { + return Modules.combine(new PostgresDropListsModule(), new DropListsRoutesModule()); + } + return binder -> { + + }; + } } diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDropListsModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDropListsModule.java new file mode 100644 index 00000000000..d2f4397295b --- /dev/null +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresDropListsModule.java @@ -0,0 +1,33 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.data; + +import org.apache.james.droplists.api.DropList; +import org.apache.james.droplists.postgres.PostgresDropList; + +import com.google.inject.AbstractModule; +import com.google.inject.Scopes; + +public class PostgresDropListsModule extends AbstractModule { + @Override + protected void configure() { + bind(DropList.class).to(PostgresDropList.class).in(Scopes.SINGLETON); + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/droplists/postgres/PostgresDropList.java b/server/data/data-postgres/src/main/java/org/apache/james/droplists/postgres/PostgresDropList.java new file mode 100644 index 00000000000..ff46ee735c5 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/droplists/postgres/PostgresDropList.java @@ -0,0 +1,127 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.droplists.postgres; + +import static org.apache.james.backends.postgres.utils.PostgresExecutor.DEFAULT_INJECT; +import static org.apache.james.droplists.api.DeniedEntityType.DOMAIN; +import static org.apache.james.droplists.postgres.PostgresDropListModule.PostgresDropListsTable.DENIED_ENTITY; +import static org.apache.james.droplists.postgres.PostgresDropListModule.PostgresDropListsTable.DENIED_ENTITY_TYPE; +import static org.apache.james.droplists.postgres.PostgresDropListModule.PostgresDropListsTable.DROPLIST_ID; +import static org.apache.james.droplists.postgres.PostgresDropListModule.PostgresDropListsTable.OWNER; +import static org.apache.james.droplists.postgres.PostgresDropListModule.PostgresDropListsTable.OWNER_SCOPE; +import static org.apache.james.droplists.postgres.PostgresDropListModule.PostgresDropListsTable.TABLE_NAME; + +import java.util.List; +import java.util.UUID; + +import jakarta.inject.Inject; +import jakarta.inject.Named; +import jakarta.mail.internet.AddressException; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.core.Domain; +import org.apache.james.core.MailAddress; +import org.apache.james.droplists.api.DropList; +import org.apache.james.droplists.api.DropListEntry; +import org.apache.james.droplists.api.OwnerScope; +import org.jooq.Record; + +import com.google.common.base.Preconditions; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresDropList implements DropList { + private final PostgresExecutor postgresExecutor; + + @Inject + public PostgresDropList(@Named(DEFAULT_INJECT) PostgresExecutor postgresExecutor) { + this.postgresExecutor = postgresExecutor; + } + + @Override + public Mono add(DropListEntry entry) { + Preconditions.checkArgument(entry != null); + String specifiedOwner = entry.getOwnerScope().equals(OwnerScope.GLOBAL) ? "" : entry.getOwner(); + return postgresExecutor.executeVoid(dslContext -> + Mono.from(dslContext.insertInto(TABLE_NAME, DROPLIST_ID, OWNER_SCOPE, OWNER, DENIED_ENTITY_TYPE, DENIED_ENTITY) + .values(UUID.randomUUID(), + entry.getOwnerScope().name(), + specifiedOwner, + entry.getDeniedEntityType().name(), + entry.getDeniedEntity()) + ) + ); + } + + @Override + public Mono remove(DropListEntry entry) { + Preconditions.checkArgument(entry != null); + return postgresExecutor.executeVoid(dsl -> Mono.from(dsl.deleteFrom(TABLE_NAME) + .where(OWNER_SCOPE.eq(entry.getOwnerScope().name())) + .and(OWNER.eq(entry.getOwner())) + .and(DENIED_ENTITY.eq(entry.getDeniedEntity())))); + } + + @Override + public Flux list(OwnerScope ownerScope, String owner) { + Preconditions.checkArgument(ownerScope != null); + Preconditions.checkArgument(owner != null); + return postgresExecutor.executeRows(dsl -> Flux.from(dsl.selectFrom(TABLE_NAME) + .where(OWNER_SCOPE.eq(ownerScope.name())) + .and(OWNER.eq(owner)))) + .map(PostgresDropList::mapRecordToDropListEntry); + } + + @Override + public Mono query(OwnerScope ownerScope, String owner, MailAddress sender) { + Preconditions.checkArgument(ownerScope != null); + Preconditions.checkArgument(owner != null); + Preconditions.checkArgument(sender != null); + String specifiedOwner = ownerScope.equals(OwnerScope.GLOBAL) ? "" : owner; + return postgresExecutor.executeExists(dsl -> dsl.selectOne().from(TABLE_NAME) + .where(OWNER_SCOPE.eq(ownerScope.name())) + .and(OWNER.eq(specifiedOwner)) + .and(DENIED_ENTITY.in(List.of(sender.asString(), sender.getDomain().asString())))) + .map(isExist -> Boolean.TRUE.equals(isExist) ? DropList.Status.BLOCKED : DropList.Status.ALLOWED); + } + + private static DropListEntry mapRecordToDropListEntry(Record dropListRecord) { + String deniedEntity = dropListRecord.get(DENIED_ENTITY); + String deniedEntityType = dropListRecord.get(DENIED_ENTITY_TYPE); + OwnerScope ownerScope = OwnerScope.valueOf(dropListRecord.get(OWNER_SCOPE)); + try { + DropListEntry.Builder builder = DropListEntry.builder(); + switch (ownerScope) { + case USER -> builder.userOwner(new MailAddress(dropListRecord.get(OWNER))); + case DOMAIN -> builder.domainOwner(Domain.of(dropListRecord.get(OWNER))); + case GLOBAL -> builder.forAll(); + } + if (DOMAIN.name().equals(deniedEntityType)) { + builder.denyDomain(Domain.of(deniedEntity)); + } else { + builder.denyAddress(new MailAddress(deniedEntity)); + } + return builder.build(); + } catch (AddressException e) { + throw new IllegalArgumentException("Entity could not be parsed as a MailAddress", e); + } + } +} diff --git a/server/data/data-postgres/src/main/java/org/apache/james/droplists/postgres/PostgresDropListModule.java b/server/data/data-postgres/src/main/java/org/apache/james/droplists/postgres/PostgresDropListModule.java new file mode 100644 index 00000000000..6d1d50a7521 --- /dev/null +++ b/server/data/data-postgres/src/main/java/org/apache/james/droplists/postgres/PostgresDropListModule.java @@ -0,0 +1,68 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.droplists.postgres; + +import java.util.UUID; + +import org.apache.james.backends.postgres.PostgresIndex; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresDropListModule { + interface PostgresDropListsTable { + Table TABLE_NAME = DSL.table("droplist"); + + Field DROPLIST_ID = DSL.field("droplist_id", SQLDataType.UUID.notNull()); + Field OWNER_SCOPE = DSL.field("owner_scope", SQLDataType.VARCHAR); + Field OWNER = DSL.field("owner", SQLDataType.VARCHAR); + Field DENIED_ENTITY_TYPE = DSL.field("denied_entity_type", SQLDataType.VARCHAR); + Field DENIED_ENTITY = DSL.field("denied_entity", SQLDataType.VARCHAR); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(DROPLIST_ID) + .column(OWNER_SCOPE) + .column(OWNER) + .column(DENIED_ENTITY_TYPE) + .column(DENIED_ENTITY) + .constraint(DSL.primaryKey(DROPLIST_ID)))) + .disableRowLevelSecurity() + .build(); + + PostgresIndex IDX_OWNER_SCOPE_OWNER = PostgresIndex.name("idx_owner_scope_owner") + .createIndexStep((dslContext, indexName) -> dslContext.createIndexIfNotExists(indexName) + .on(TABLE_NAME, OWNER_SCOPE, OWNER)); + + PostgresIndex IDX_OWNER_SCOPE_OWNER_DENIED_ENTITY = PostgresIndex.name("idx_owner_scope_owner_denied_entity") + .createIndexStep((dslContext, indexName) -> dslContext.createIndexIfNotExists(indexName) + .on(TABLE_NAME, OWNER_SCOPE, OWNER, DENIED_ENTITY)); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(PostgresDropListsTable.TABLE) + .addIndex(PostgresDropListsTable.IDX_OWNER_SCOPE_OWNER) + .addIndex(PostgresDropListsTable.IDX_OWNER_SCOPE_OWNER_DENIED_ENTITY) + .build(); +} diff --git a/server/data/data-postgres/src/test/java/org/apache/james/droplists/postgres/PostgresDropListsTest.java b/server/data/data-postgres/src/test/java/org/apache/james/droplists/postgres/PostgresDropListsTest.java new file mode 100644 index 00000000000..99c5dc7b5bf --- /dev/null +++ b/server/data/data-postgres/src/test/java/org/apache/james/droplists/postgres/PostgresDropListsTest.java @@ -0,0 +1,43 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.droplists.postgres; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.droplists.api.DropList; +import org.apache.james.droplists.api.DropListContract; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; + +class PostgresDropListsTest implements DropListContract { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresDropListModule.MODULE); + + PostgresDropList dropList; + + @BeforeEach + void setup() { + dropList = new PostgresDropList(postgresExtension.getPostgresExecutor()); + } + + @Override + public DropList dropList() { + return dropList; + } +} \ No newline at end of file From be4c28007f379a93e9e07ae09ad9450ddffbec47 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 10 Jun 2024 10:04:38 +0700 Subject: [PATCH 311/341] JAMES-2586 Clean/Refactor PostgresExtension - Using PoolBackedPostgresConnectionFactory for all factory. Make it similar with prod code --- .../backends/postgres/PostgresExtension.java | 72 +++++++++---------- 1 file changed, 35 insertions(+), 37 deletions(-) diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index a48fd8295cd..4e0debe6058 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -26,13 +26,13 @@ import java.time.Duration; import java.util.List; import java.util.Optional; +import java.util.function.Function; import java.util.stream.Collectors; import org.apache.james.GuiceModuleTestExtension; -import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; +import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.PoolBackedPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.PostgresExecutor; -import org.apache.james.backends.postgres.utils.SinglePostgresConnectionFactory; import org.apache.james.metrics.tests.RecordingMetricFactory; import org.junit.jupiter.api.extension.ExtensionContext; import org.testcontainers.containers.PostgreSQLContainer; @@ -123,8 +123,8 @@ private PostgresExtension(PostgresModule postgresModule, boolean rlsEnabled, Opt this.selectedDatabase = PostgresFixture.Database.ROW_LEVEL_SECURITY_DATABASE; } else { this.selectedDatabase = DEFAULT_DATABASE; - this.poolSize = maybePoolSize.orElse(DEFAULT_POOL_SIZE); } + this.poolSize = maybePoolSize.orElse(DEFAULT_POOL_SIZE); } @Override @@ -166,47 +166,37 @@ private void initPostgresSession() { .jooqReactiveTimeout(Optional.of(Duration.ofSeconds(20L))) .build(); - PostgresqlConnectionConfiguration.Builder connectionBaseBuilder = PostgresqlConnectionConfiguration.builder() - .host(postgresConfiguration.getHost()) - .port(postgresConfiguration.getPort()) - .database(postgresConfiguration.getDatabaseName()) - .schema(postgresConfiguration.getDatabaseSchema()); + Function postgresqlConnectionConfigurationFunction = credential -> + PostgresqlConnectionConfiguration.builder() + .host(postgresConfiguration.getHost()) + .port(postgresConfiguration.getPort()) + .database(postgresConfiguration.getDatabaseName()) + .schema(postgresConfiguration.getDatabaseSchema()) + .username(credential.getUsername()) + .password(credential.getPassword()) + .build(); - connectionFactory = new PostgresqlConnectionFactory(connectionBaseBuilder - .username(postgresConfiguration.getCredential().getUsername()) - .password(postgresConfiguration.getCredential().getPassword()) - .build()); + RecordingMetricFactory metricFactory = new RecordingMetricFactory(); + connectionFactory = new PostgresqlConnectionFactory(postgresqlConnectionConfigurationFunction.apply(postgresConfiguration.getCredential())); superConnection = connectionFactory.create().block(); - if (rlsEnabled) { - executorFactory = new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(connectionFactory), postgresConfiguration, new RecordingMetricFactory()); - } else { - executorFactory = new PostgresExecutor.Factory( - new PoolBackedPostgresConnectionFactory(false, - poolSize.getMin(), - poolSize.getMax(), - connectionFactory), - postgresConfiguration, - new RecordingMetricFactory()); - } + executorFactory = new PostgresExecutor.Factory( + getJamesPostgresConnectionFactory(rlsEnabled, connectionFactory), + postgresConfiguration, + metricFactory); postgresExecutor = executorFactory.create(); - if (rlsEnabled) { - nonRLSPostgresExecutor = Mono.just(connectionBaseBuilder - .username(postgresConfiguration.getNonRLSCredential().getUsername()) - .password(postgresConfiguration.getNonRLSCredential().getPassword()) - .build()) - .flatMap(configuration -> new PostgresqlConnectionFactory(configuration).create().cache()) - .map(connection -> new PostgresExecutor.Factory(new SinglePostgresConnectionFactory(connection), postgresConfiguration, new RecordingMetricFactory()).create()) - .block(); - } else { - nonRLSPostgresExecutor = postgresExecutor; - } - this.postgresTableManager = new PostgresTableManager(new PostgresExecutor.Factory(new SinglePostgresConnectionFactory(superConnection), postgresConfiguration, new RecordingMetricFactory()).create(), - postgresModule, - postgresConfiguration); + PostgresqlConnectionFactory nonRLSConnectionFactory = new PostgresqlConnectionFactory(postgresqlConnectionConfigurationFunction.apply(postgresConfiguration.getNonRLSCredential())); + + nonRLSPostgresExecutor = new PostgresExecutor.Factory( + getJamesPostgresConnectionFactory(false, nonRLSConnectionFactory), + postgresConfiguration, + metricFactory) + .create(); + + this.postgresTableManager = new PostgresTableManager(postgresExecutor, postgresModule, rlsEnabled); } @Override @@ -294,4 +284,12 @@ private void dropTables(List tables) { .then() .block(); } + + private JamesPostgresConnectionFactory getJamesPostgresConnectionFactory(boolean rlsEnabled, PostgresqlConnectionFactory connectionFactory) { + return new PoolBackedPostgresConnectionFactory( + rlsEnabled, + poolSize.getMin(), + poolSize.getMax(), + connectionFactory); + } } From 570aff94b4aa03b2242821d4c46a4dd8d9641120 Mon Sep 17 00:00:00 2001 From: vttran Date: Thu, 13 Jun 2024 13:34:12 +0700 Subject: [PATCH 312/341] JAMES-2586 Drop SinglePostgresConnectionFactory --- .../SinglePostgresConnectionFactory.java | 50 ------------------- 1 file changed, 50 deletions(-) delete mode 100644 backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SinglePostgresConnectionFactory.java diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SinglePostgresConnectionFactory.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SinglePostgresConnectionFactory.java deleted file mode 100644 index 3972a27dbda..00000000000 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/SinglePostgresConnectionFactory.java +++ /dev/null @@ -1,50 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.backends.postgres.utils; - -import java.util.Optional; - -import org.apache.james.core.Domain; - -import io.r2dbc.spi.Connection; -import reactor.core.publisher.Mono; - -public class SinglePostgresConnectionFactory implements JamesPostgresConnectionFactory { - private final Connection connection; - - public SinglePostgresConnectionFactory(Connection connection) { - this.connection = connection; - } - - @Override - public Mono getConnection(Optional domain) { - return Mono.just(connection); - } - - @Override - public Mono closeConnection(Connection connection) { - return Mono.empty(); - } - - @Override - public Mono close() { - return Mono.from(connection.close()); - } -} From 152f924bf9818ff440e7d6c942b1fcee5046f3e6 Mon Sep 17 00:00:00 2001 From: vttran Date: Thu, 13 Jun 2024 13:38:46 +0700 Subject: [PATCH 313/341] JAMES-2586 Drop DomainImplPostgresConnectionFactory --- .../DomainImplPostgresConnectionFactory.java | 105 ------------- ...mainImplPostgresConnectionFactoryTest.java | 148 ------------------ ...sAnnotationMapperRowLevelSecurityTest.java | 10 +- ...gresMailboxMapperRowLevelSecurityTest.java | 6 +- ...gresMessageMapperRowLevelSecurityTest.java | 6 +- ...ubscriptionMapperRowLevelSecurityTest.java | 5 +- .../host/PostgresHostSystemExtension.java | 2 +- .../upload/PostgresUploadRepositoryTest.java | 2 +- .../upload/PostgresUploadServiceTest.java | 4 +- 9 files changed, 11 insertions(+), 277 deletions(-) delete mode 100644 backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/DomainImplPostgresConnectionFactory.java delete mode 100644 backends-common/postgres/src/test/java/org/apache/james/backends/postgres/DomainImplPostgresConnectionFactoryTest.java diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/DomainImplPostgresConnectionFactory.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/DomainImplPostgresConnectionFactory.java deleted file mode 100644 index f69dd775694..00000000000 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/DomainImplPostgresConnectionFactory.java +++ /dev/null @@ -1,105 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.backends.postgres.utils; - -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; - -import jakarta.inject.Inject; - -import org.apache.james.core.Domain; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import io.r2dbc.spi.Connection; -import io.r2dbc.spi.ConnectionFactory; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - -public class DomainImplPostgresConnectionFactory implements JamesPostgresConnectionFactory { - private static final Logger LOGGER = LoggerFactory.getLogger(DomainImplPostgresConnectionFactory.class); - private static final Domain DEFAULT = Domain.of("default"); - private static final String DEFAULT_DOMAIN_ATTRIBUTE_VALUE = ""; - - private final ConnectionFactory connectionFactory; - private final Map mapDomainToConnection = new ConcurrentHashMap<>(); - - @Inject - public DomainImplPostgresConnectionFactory(ConnectionFactory connectionFactory) { - this.connectionFactory = connectionFactory; - } - - @Override - public Mono getConnection(Optional maybeDomain) { - return maybeDomain.map(this::getConnectionForDomain) - .orElse(getConnectionForDomain(DEFAULT)); - } - - @Override - public Mono closeConnection(Connection connection) { - return Mono.empty(); - } - - @Override - public Mono close() { - return Flux.fromIterable(mapDomainToConnection.values()) - .flatMap(connection -> Mono.from(connection.close())) - .then(); - } - - private Mono getConnectionForDomain(Domain domain) { - return Mono.just(domain) - .flatMap(domainValue -> Mono.fromCallable(() -> mapDomainToConnection.get(domainValue)) - .switchIfEmpty(create(domainValue))); - } - - private Mono create(Domain domain) { - return Mono.from(connectionFactory.create()) - .doOnError(e -> LOGGER.error("Error while creating connection for domain {}", domain, e)) - .flatMap(newConnection -> getAndSetConnection(domain, newConnection)); - } - - private Mono getAndSetConnection(Domain domain, Connection newConnection) { - return Mono.fromCallable(() -> mapDomainToConnection.putIfAbsent(domain, newConnection)) - .map(postgresqlConnection -> { - //close redundant connection - Mono.from(newConnection.close()) - .doOnError(e -> LOGGER.error("Error while closing connection for domain {}", domain, e)) - .subscribe(); - return postgresqlConnection; - }).switchIfEmpty(setDomainAttributeForConnection(domain, newConnection)); - } - - private Mono setDomainAttributeForConnection(Domain domain, Connection newConnection) { - return Mono.from(newConnection.createStatement("SET " + DOMAIN_ATTRIBUTE + " TO '" + getDomainAttributeValue(domain) + "'") // It should be set value via Bind, but it doesn't work - .execute()) - .doOnError(e -> LOGGER.error("Error while setting domain attribute for domain {}", domain, e)) - .then(Mono.just(newConnection)); - } - - private String getDomainAttributeValue(Domain domain) { - if (DEFAULT.equals(domain)) { - return DEFAULT_DOMAIN_ATTRIBUTE_VALUE; - } else { - return domain.asString(); - } - } -} diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/DomainImplPostgresConnectionFactoryTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/DomainImplPostgresConnectionFactoryTest.java deleted file mode 100644 index ec79ba3d23f..00000000000 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/DomainImplPostgresConnectionFactoryTest.java +++ /dev/null @@ -1,148 +0,0 @@ -/**************************************************************** - * Licensed to the Apache Software Foundation (ASF) under one * - * or more contributor license agreements. See the NOTICE file * - * distributed with this work for additional information * - * regarding copyright ownership. The ASF licenses this file * - * to you under the Apache License, Version 2.0 (the * - * "License"); you may not use this file except in compliance * - * with the License. You may obtain a copy of the License at * - * * - * http://www.apache.org/licenses/LICENSE-2.0 * - * * - * Unless required by applicable law or agreed to in writing, * - * software distributed under the License is distributed on an * - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * - * KIND, either express or implied. See the License for the * - * specific language governing permissions and limitations * - * under the License. * - ****************************************************************/ - -package org.apache.james.backends.postgres; - -import static org.apache.james.backends.postgres.PostgresFixture.Database.DEFAULT_DATABASE; -import static org.assertj.core.api.Assertions.assertThat; - -import java.net.URISyntaxException; -import java.time.Duration; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; - -import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; -import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; -import org.apache.james.core.Domain; -import org.apache.james.util.concurrency.ConcurrentTestRunner; -import org.jetbrains.annotations.Nullable; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; - -import com.google.common.collect.ImmutableList; - -import io.r2dbc.postgresql.api.PostgresqlConnection; -import io.r2dbc.spi.Connection; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; - -public class DomainImplPostgresConnectionFactoryTest extends JamesPostgresConnectionFactoryTest { - @RegisterExtension - static PostgresExtension postgresExtension = PostgresExtension.empty(); - - private PostgresqlConnection postgresqlConnection; - private DomainImplPostgresConnectionFactory jamesPostgresConnectionFactory; - - JamesPostgresConnectionFactory jamesPostgresConnectionFactory() { - return jamesPostgresConnectionFactory; - } - - @BeforeEach - void beforeEach() { - jamesPostgresConnectionFactory = new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory()); - postgresqlConnection = (PostgresqlConnection) postgresExtension.getConnection().block(); - } - - @AfterEach - void afterEach() throws URISyntaxException { - postgresExtension.restartContainer(); - } - - @Test - void factoryShouldCreateCorrectNumberOfConnections() { - Integer previousDbActiveNumberOfConnections = getNumberOfConnections(); - - // create 50 connections - Flux.range(1, 50) - .flatMap(i -> jamesPostgresConnectionFactory.getConnection(Domain.of("james" + i))) - .last() - .block(); - - Integer dbActiveNumberOfConnections = getNumberOfConnections(); - - assertThat(dbActiveNumberOfConnections - previousDbActiveNumberOfConnections).isEqualTo(50); - } - - @Nullable - private Integer getNumberOfConnections() { - return Mono.from(postgresqlConnection.createStatement("SELECT count(*) from pg_stat_activity where usename = $1;") - .bind("$1", DEFAULT_DATABASE.dbUser()) - .execute()).flatMap(result -> Mono.from(result.map((row, rowMetadata) -> row.get(0, Integer.class)))).block(); - } - - @Test - void factoryShouldNotCreateNewConnectionWhenDomainsAreTheSame() { - Domain domain = Domain.of("james"); - Connection connectionOne = jamesPostgresConnectionFactory.getConnection(domain).block(); - Connection connectionTwo = jamesPostgresConnectionFactory.getConnection(domain).block(); - - assertThat(connectionOne == connectionTwo).isTrue(); - } - - @Test - void factoryShouldCreateNewConnectionWhenDomainsAreDifferent() { - Connection connectionOne = jamesPostgresConnectionFactory.getConnection(Domain.of("james")).block(); - Connection connectionTwo = jamesPostgresConnectionFactory.getConnection(Domain.of("lin")).block(); - - String domainOne = getDomainAttributeValue(connectionOne); - - String domainTwo = Flux.from(connectionTwo.createStatement("show " + JamesPostgresConnectionFactory.DOMAIN_ATTRIBUTE) - .execute()) - .flatMap(result -> result.map((row, rowMetadata) -> row.get(0, String.class))) - .collect(ImmutableList.toImmutableList()) - .block().get(0); - - assertThat(connectionOne).isNotEqualTo(connectionTwo); - assertThat(domainOne).isNotEqualTo(domainTwo); - } - - @Test - void factoryShouldNotCreateNewConnectionWhenDomainsAreTheSameAndRequestsAreFromDifferentThreads() throws Exception { - Set connectionSet = ConcurrentHashMap.newKeySet(); - - ConcurrentTestRunner.builder() - .reactorOperation((threadNumber, step) -> jamesPostgresConnectionFactory.getConnection(Domain.of("james")) - .doOnNext(connectionSet::add) - .then()) - .threadCount(50) - .operationCount(10) - .runSuccessfullyWithin(Duration.ofMinutes(1)); - - assertThat(connectionSet).hasSize(1); - } - - @Test - void factoryShouldCreateOnlyOneDefaultConnection() throws Exception { - Set connectionSet = ConcurrentHashMap.newKeySet(); - - ConcurrentTestRunner.builder() - .reactorOperation((threadNumber, step) -> jamesPostgresConnectionFactory.getConnection(Optional.empty()) - .doOnNext(connectionSet::add) - .then()) - .threadCount(50) - .operationCount(10) - .runSuccessfullyWithin(Duration.ofMinutes(1)); - - assertThat(connectionSet).hasSize(1); - } - -} diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperRowLevelSecurityTest.java index f23a8c031f8..f75f4ecaac6 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperRowLevelSecurityTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperRowLevelSecurityTest.java @@ -24,7 +24,6 @@ import java.time.Instant; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.blob.api.BlobId; import org.apache.james.blob.api.BucketName; @@ -42,7 +41,6 @@ import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; import org.apache.james.mailbox.store.mail.MailboxMapper; -import org.apache.james.metrics.tests.RecordingMetricFactory; import org.apache.james.server.blob.deduplication.DeDuplicationBlobStore; import org.apache.james.utils.UpdatableTickingClock; import org.junit.jupiter.api.BeforeEach; @@ -51,7 +49,7 @@ public class PostgresAnnotationMapperRowLevelSecurityTest { private static final UidValidity UID_VALIDITY = UidValidity.of(42); - private static final Username BENWA = Username.of("benwa"); + private static final Username BENWA = Username.of("benwa@localhost"); protected static final MailboxPath benwaInboxPath = MailboxPath.forUser(BENWA, "INBOX"); private static final MailboxSession aliceSession = MailboxSessionUtil.create(Username.of("alice@domain1")); private static final MailboxSession bobSession = MailboxSessionUtil.create(Username.of("bob@domain1")); @@ -66,15 +64,15 @@ public class PostgresAnnotationMapperRowLevelSecurityTest { private MailboxId mailboxId; private MailboxId generateMailboxId() { - MailboxMapper mailboxMapper = new PostgresMailboxMapper(new PostgresMailboxDAO(postgresExtension.getPostgresExecutor())); + PostgresExecutor postgresExecutor = postgresExtension.getExecutorFactory().create(BENWA.getDomainPart()); + MailboxMapper mailboxMapper = new PostgresMailboxMapper(new PostgresMailboxDAO(postgresExecutor)); return mailboxMapper.create(benwaInboxPath, UID_VALIDITY).block().getMailboxId(); } @BeforeEach public void setUp() { BlobId.Factory blobIdFactory = new HashBlobId.Factory(); - postgresMailboxSessionMapperFactory = new PostgresMailboxSessionMapperFactory(new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory()), - postgresExtension.getPostgresConfiguration(), new RecordingMetricFactory()), + postgresMailboxSessionMapperFactory = new PostgresMailboxSessionMapperFactory(postgresExtension.getExecutorFactory(), new UpdatableTickingClock(Instant.now()), new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory), blobIdFactory); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperRowLevelSecurityTest.java index cfc7dcc1d16..0d841de5782 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperRowLevelSecurityTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperRowLevelSecurityTest.java @@ -22,7 +22,6 @@ import static org.assertj.core.api.Assertions.assertThat; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.Username; import org.apache.james.mailbox.MailboxSession; @@ -31,7 +30,6 @@ import org.apache.james.mailbox.model.UidValidity; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; import org.apache.james.mailbox.store.mail.MailboxMapperFactory; -import org.apache.james.metrics.tests.RecordingMetricFactory; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -45,9 +43,7 @@ public class PostgresMailboxMapperRowLevelSecurityTest { @BeforeEach public void setUp() { - PostgresExecutor.Factory executorFactory = new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory()), - postgresExtension.getPostgresConfiguration(), - new RecordingMetricFactory()); + PostgresExecutor.Factory executorFactory = postgresExtension.getExecutorFactory(); mailboxMapperFactory = session -> new PostgresMailboxMapper(new PostgresMailboxDAO(executorFactory.create(session.getUser().getDomainPart()))); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java index 55743bafb6f..1b248de56ca 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java @@ -27,8 +27,6 @@ import jakarta.mail.Flags; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; -import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.blob.api.BlobId; import org.apache.james.blob.api.BucketName; import org.apache.james.blob.api.HashBlobId; @@ -50,7 +48,6 @@ import org.apache.james.mailbox.store.mail.model.MailboxMessage; import org.apache.james.mailbox.store.mail.model.impl.PropertyBuilder; import org.apache.james.mailbox.store.mail.model.impl.SimpleMailboxMessage; -import org.apache.james.metrics.tests.RecordingMetricFactory; import org.apache.james.server.blob.deduplication.DeDuplicationBlobStore; import org.apache.james.utils.UpdatableTickingClock; import org.junit.jupiter.api.BeforeEach; @@ -80,8 +77,7 @@ private Mailbox generateMailbox() { @BeforeEach public void setUp() { BlobId.Factory blobIdFactory = new HashBlobId.Factory(); - postgresMailboxSessionMapperFactory = new PostgresMailboxSessionMapperFactory(new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory()), - postgresExtension.getPostgresConfiguration(), new RecordingMetricFactory()), + postgresMailboxSessionMapperFactory = new PostgresMailboxSessionMapperFactory(postgresExtension.getExecutorFactory(), new UpdatableTickingClock(Instant.now()), new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory), blobIdFactory); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java index a28bd34087f..a1db7adcd14 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperRowLevelSecurityTest.java @@ -22,14 +22,12 @@ import static org.assertj.core.api.Assertions.assertThat; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.backends.postgres.utils.DomainImplPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.core.Username; import org.apache.james.mailbox.MailboxSession; import org.apache.james.mailbox.MailboxSessionUtil; import org.apache.james.mailbox.store.user.SubscriptionMapperFactory; import org.apache.james.mailbox.store.user.model.Subscription; -import org.apache.james.metrics.tests.RecordingMetricFactory; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -42,8 +40,7 @@ public class PostgresSubscriptionMapperRowLevelSecurityTest { @BeforeEach public void setUp() { - PostgresExecutor.Factory executorFactory = new PostgresExecutor.Factory(new DomainImplPostgresConnectionFactory(postgresExtension.getConnectionFactory()), - postgresExtension.getPostgresConfiguration(), new RecordingMetricFactory()); + PostgresExecutor.Factory executorFactory = postgresExtension.getExecutorFactory(); subscriptionMapperFactory = session -> new PostgresSubscriptionMapper(new PostgresSubscriptionDAO(executorFactory.create(session.getUser().getDomainPart()))); } diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystemExtension.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystemExtension.java index c3f3f163608..9e53890c032 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystemExtension.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystemExtension.java @@ -38,7 +38,7 @@ public class PostgresHostSystemExtension implements BeforeEachCallback, AfterEac private final PostgresExtension postgresExtension; public PostgresHostSystemExtension() { - this.postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresModule.aggregateModules( + this.postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresModule.aggregateModules( PostgresMailboxAggregateModule.MODULE, PostgresQuotaModule.MODULE)); try { diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepositoryTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepositoryTest.java index e8738bcb8ce..c9876fe4234 100644 --- a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepositoryTest.java +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepositoryTest.java @@ -37,7 +37,7 @@ class PostgresUploadRepositoryTest implements UploadRepositoryContract { @RegisterExtension - static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity( + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity( PostgresModule.aggregateModules(PostgresUploadModule.MODULE)); private UploadRepository testee; private UpdatableTickingClock clock; diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadServiceTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadServiceTest.java index 2884acd333e..86a16084855 100644 --- a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadServiceTest.java +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadServiceTest.java @@ -41,7 +41,7 @@ public class PostgresUploadServiceTest implements UploadServiceContract { @RegisterExtension - static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity( + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity( PostgresModule.aggregateModules(PostgresUploadModule.MODULE, PostgresQuotaModule.MODULE)); private PostgresUploadRepository uploadRepository; @@ -54,7 +54,7 @@ void setUp() { BlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory); PostgresUploadDAO uploadDAO = new PostgresUploadDAO(postgresExtension.getNonRLSPostgresExecutor(), blobIdFactory); PostgresUploadDAO.Factory uploadFactory = new PostgresUploadDAO.Factory(blobIdFactory, postgresExtension.getExecutorFactory()); - uploadRepository = new PostgresUploadRepository( blobStore, Clock.systemUTC(),uploadFactory, uploadDAO); + uploadRepository = new PostgresUploadRepository(blobStore, Clock.systemUTC(), uploadFactory, uploadDAO); uploadUsageRepository = new PostgresUploadUsageRepository(new PostgresQuotaCurrentValueDAO(postgresExtension.getPostgresExecutor())); testee = new UploadServiceDefaultImpl(uploadRepository, uploadUsageRepository, UploadServiceContract.TEST_CONFIGURATION()); } From b3dbdc88bf72ff3a505fcb7302ad897fea09508a Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Thu, 13 Jun 2024 14:44:54 +0700 Subject: [PATCH 314/341] JAMES-2586 Refactor JamesPostgresConnectionFactory: distinctly getConnection api --- .../postgres/PostgresTableManager.java | 11 +++++------ .../utils/JamesPostgresConnectionFactory.java | 8 ++------ .../PoolBackedPostgresConnectionFactory.java | 19 ++++++++++--------- .../postgres/utils/PostgresExecutor.java | 17 +++++++++++------ .../JamesPostgresConnectionFactoryTest.java | 2 +- 5 files changed, 29 insertions(+), 28 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java index 2bff154c6c1..fcc0175c0ac 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java @@ -20,7 +20,6 @@ package org.apache.james.backends.postgres; import java.util.List; -import java.util.Optional; import jakarta.inject.Inject; @@ -70,7 +69,7 @@ public void initPostgres() { } public Mono initializePostgresExtension() { - return Mono.usingWhen(postgresExecutor.connectionFactory().getConnection(Optional.empty()), + return Mono.usingWhen(postgresExecutor.connectionFactory().getConnection(), connection -> Mono.just(connection) .flatMapMany(pgConnection -> pgConnection.createStatement("CREATE EXTENSION IF NOT EXISTS hstore") .execute()) @@ -80,7 +79,7 @@ public Mono initializePostgresExtension() { } public Mono initializeTables() { - return Mono.usingWhen(postgresExecutor.connectionFactory().getConnection(Optional.empty()), + return Mono.usingWhen(postgresExecutor.connectionFactory().getConnection(), connection -> postgresExecutor.dslContext(connection) .flatMapMany(dsl -> listExistTables() .flatMapMany(existTables -> Flux.fromIterable(module.tables()) @@ -98,7 +97,7 @@ private Mono createAndAlterTable(PostgresTable table, DSLContext dsl, Conn } public Mono> listExistTables() { - return Mono.usingWhen(postgresExecutor.connectionFactory().getConnection(Optional.empty()), + return Mono.usingWhen(postgresExecutor.connectionFactory().getConnection(), connection -> postgresExecutor.dslContext(connection) .flatMapMany(d -> Flux.from(d.select(DSL.field("tablename")) .from("pg_tables") @@ -166,7 +165,7 @@ private String rowLevelSecurityAlterStatement(String tableName) { } public Mono truncate() { - return Mono.usingWhen(postgresExecutor.connectionFactory().getConnection(Optional.empty()), + return Mono.usingWhen(postgresExecutor.connectionFactory().getConnection(), connection -> postgresExecutor.dslContext(connection) .flatMap(dsl -> Flux.fromIterable(module.tables()) .flatMap(table -> Mono.from(dsl.truncateTable(table.getName())) @@ -177,7 +176,7 @@ public Mono truncate() { } public Mono initializeTableIndexes() { - return Mono.usingWhen(postgresExecutor.connectionFactory().getConnection(Optional.empty()), + return Mono.usingWhen(postgresExecutor.connectionFactory().getConnection(), connection -> postgresExecutor.dslContext(connection) .flatMapMany(dsl -> listExistIndexes(dsl) .flatMapMany(existIndexes -> Flux.fromIterable(module.tableIndexes()) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/JamesPostgresConnectionFactory.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/JamesPostgresConnectionFactory.java index 7a4fede8a94..29fc1edd2c8 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/JamesPostgresConnectionFactory.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/JamesPostgresConnectionFactory.java @@ -19,8 +19,6 @@ package org.apache.james.backends.postgres.utils; -import java.util.Optional; - import org.apache.james.core.Domain; import io.r2dbc.spi.Connection; @@ -30,11 +28,9 @@ public interface JamesPostgresConnectionFactory { String DOMAIN_ATTRIBUTE = "app.current_domain"; String NON_RLS_INJECT = "non_rls"; - default Mono getConnection(Domain domain) { - return getConnection(Optional.ofNullable(domain)); - } + Mono getConnection(Domain domain); - Mono getConnection(Optional domain); + Mono getConnection(); Mono closeConnection(Connection connection); diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java index 441af557e04..02af49b468c 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java @@ -19,8 +19,6 @@ package org.apache.james.backends.postgres.utils; -import java.util.Optional; - import org.apache.james.core.Domain; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -35,7 +33,6 @@ public class PoolBackedPostgresConnectionFactory implements JamesPostgresConnect private static final Logger LOGGER = LoggerFactory.getLogger(PoolBackedPostgresConnectionFactory.class); private static final int DEFAULT_INITIAL_SIZE = 10; private static final int DEFAULT_MAX_SIZE = 20; - private final boolean rowLevelSecurityEnabled; private final ConnectionPool pool; @@ -54,15 +51,19 @@ public PoolBackedPostgresConnectionFactory(boolean rowLevelSecurityEnabled, Conn } @Override - public Mono getConnection(Optional maybeDomain) { + public Mono getConnection(Domain domain) { if (rowLevelSecurityEnabled) { - return pool.create().flatMap(connection -> maybeDomain.map(domain -> setDomainAttributeForConnection(domain, connection)) - .orElse(Mono.just(connection))); + return pool.create().flatMap(connection -> setDomainAttributeForConnection(domain.asString(), connection)); } else { return pool.create(); } } + @Override + public Mono getConnection() { + return pool.create(); + } + @Override public Mono closeConnection(Connection connection) { return Mono.from(connection.close()); @@ -73,10 +74,10 @@ public Mono close() { return pool.close(); } - private Mono setDomainAttributeForConnection(Domain domain, Connection connection) { - return Mono.from(connection.createStatement("SET " + DOMAIN_ATTRIBUTE + " TO '" + domain.asString() + "'") // It should be set value via Bind, but it doesn't work + private Mono setDomainAttributeForConnection(String domainAttribute, Connection connection) { + return Mono.from(connection.createStatement("SET " + DOMAIN_ATTRIBUTE + " TO '" + domainAttribute + "'") // It should be set value via Bind, but it doesn't work .execute()) - .doOnError(e -> LOGGER.error("Error while setting domain attribute for domain {}", domain, e)) + .doOnError(e -> LOGGER.error("Error while setting domain attribute for domain {}", domainAttribute, e)) .then(Mono.just(connection)); } } diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index aaa0b1f205b..5e19af6b642 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -113,7 +113,7 @@ public Mono dslContext(Connection connection) { public Mono executeVoid(Function> queryFunction) { return Mono.from(metricFactory.decoratePublisherWithTimerMetric("postgres-execution", - Mono.usingWhen(jamesPostgresConnectionFactory.getConnection(domain), + Mono.usingWhen(getConnection(domain), connection -> dslContext(connection) .flatMap(queryFunction) .timeout(postgresConfiguration.getJooqReactiveTimeout()) @@ -126,7 +126,7 @@ public Mono executeVoid(Function> queryFunction) { public Flux executeRows(Function> queryFunction) { return Flux.from(metricFactory.decoratePublisherWithTimerMetric("postgres-execution", - Flux.usingWhen(jamesPostgresConnectionFactory.getConnection(domain), + Flux.usingWhen(getConnection(domain), connection -> dslContext(connection) .flatMapMany(queryFunction) .timeout(postgresConfiguration.getJooqReactiveTimeout()) @@ -140,7 +140,7 @@ public Flux executeRows(Function> queryFunction public Flux executeDeleteAndReturnList(Function> queryFunction) { return Flux.from(metricFactory.decoratePublisherWithTimerMetric("postgres-execution", - Flux.usingWhen(jamesPostgresConnectionFactory.getConnection(domain), + Flux.usingWhen(getConnection(domain), connection -> dslContext(connection) .flatMapMany(queryFunction) .timeout(postgresConfiguration.getJooqReactiveTimeout()) @@ -154,7 +154,7 @@ public Flux executeDeleteAndReturnList(Function executeRow(Function> queryFunction) { return Mono.from(metricFactory.decoratePublisherWithTimerMetric("postgres-execution", - Mono.usingWhen(jamesPostgresConnectionFactory.getConnection(domain), + Mono.usingWhen(getConnection(domain), connection -> dslContext(connection) .flatMap(queryFunction.andThen(Mono::from)) .timeout(postgresConfiguration.getJooqReactiveTimeout()) @@ -172,7 +172,7 @@ public Mono> executeSingleRowOptional(Function executeCount(Function>> queryFunction) { return Mono.from(metricFactory.decoratePublisherWithTimerMetric("postgres-execution", - Mono.usingWhen(jamesPostgresConnectionFactory.getConnection(domain), + Mono.usingWhen(getConnection(domain), connection -> dslContext(connection) .flatMap(queryFunction) .timeout(postgresConfiguration.getJooqReactiveTimeout()) @@ -190,7 +190,7 @@ public Mono executeExists(Function> public Mono executeReturnAffectedRowsCount(Function> queryFunction) { return Mono.from(metricFactory.decoratePublisherWithTimerMetric("postgres-execution", - Mono.usingWhen(jamesPostgresConnectionFactory.getConnection(domain), + Mono.usingWhen(getConnection(domain), connection -> dslContext(connection) .flatMap(queryFunction) .timeout(postgresConfiguration.getJooqReactiveTimeout()) @@ -214,4 +214,9 @@ private Predicate preparedStatementConflictException() { && throwable.getMessage().contains("prepared statement") && throwable.getMessage().contains("already exists"); } + + private Mono getConnection(Optional maybeDomain) { + return maybeDomain.map(jamesPostgresConnectionFactory::getConnection) + .orElseGet(jamesPostgresConnectionFactory::getConnection); + } } diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/JamesPostgresConnectionFactoryTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/JamesPostgresConnectionFactoryTest.java index 4c42b0144c8..6d503c6d790 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/JamesPostgresConnectionFactoryTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/JamesPostgresConnectionFactoryTest.java @@ -38,7 +38,7 @@ public abstract class JamesPostgresConnectionFactoryTest { @Test void getConnectionShouldWork() { - Connection connection = jamesPostgresConnectionFactory().getConnection(Optional.empty()).block(); + Connection connection = jamesPostgresConnectionFactory().getConnection().block(); String actual = Flux.from(connection.createStatement("SELECT 1") .execute()) .flatMap(result -> result.map((row, rowMetadata) -> row.get(0, String.class))) From fb9682a189c183e2cc18ffce6f7ef5beeb43a55c Mon Sep 17 00:00:00 2001 From: vttran Date: Thu, 13 Jun 2024 14:24:27 +0700 Subject: [PATCH 315/341] JAMES-2586 Re naming "non-rls" to "by-pass-rls" --- .../postgres/PostgresConfiguration.java | 112 +++++++++--------- .../utils/JamesPostgresConnectionFactory.java | 2 +- .../utils/PostgresConnectionClosure.java | 8 +- .../postgres/utils/PostgresExecutor.java | 2 +- .../postgres/PostgresConfigurationTest.java | 26 ++-- .../PostgresExecutorThreadSafetyTest.java | 2 +- .../backends/postgres/PostgresExtension.java | 43 ++++--- .../postgres/PostgresTableManagerTest.java | 8 +- .../PostgresQuotaCurrentValueDAOTest.java | 2 +- .../quota/PostgresQuotaLimitDaoTest.java | 2 +- .../utils/PostgresHealthCheckTest.java | 7 +- .../events/PostgresEventDeadLettersTest.java | 2 +- .../postgres/PostgresEventStoreExtension.java | 2 +- ...stgresDeletedMessageMetadataVaultTest.java | 2 +- ...PostgresAttachmentBlobReferenceSource.java | 2 +- .../postgres/mail/dao/PostgresMessageDAO.java | 2 +- .../postgres/DeleteMessageListenerTest.java | 8 +- .../postgres/PostgresTestSystemFixture.java | 4 +- .../mail/PostgresAnnotationMapperTest.java | 4 +- ...gresAttachmentBlobReferenceSourceTest.java | 2 +- .../mail/PostgresAttachmentMapperTest.java | 2 +- .../mail/PostgresMailboxMapperACLTest.java | 2 +- .../mail/PostgresMailboxMapperTest.java | 2 +- .../postgres/mail/PostgresMapperProvider.java | 20 ++-- ...ostgresMessageBlobReferenceSourceTest.java | 2 +- ...gresMessageMapperRowLevelSecurityTest.java | 2 +- .../mail/PostgresModSeqProviderTest.java | 2 +- .../mail/PostgresUidProviderTest.java | 2 +- ...gresRecomputeCurrentQuotasServiceTest.java | 4 +- .../PostgresCurrentQuotaManagerTest.java | 2 +- .../PostgresPerUserMaxQuotaManagerTest.java | 2 +- .../search/AllSearchOverrideTest.java | 4 +- .../search/DeletedSearchOverrideTest.java | 4 +- .../DeletedWithRangeSearchOverrideTest.java | 4 +- ...NotDeletedWithRangeSearchOverrideTest.java | 4 +- .../search/UidSearchOverrideTest.java | 4 +- .../search/UnseenSearchOverrideTest.java | 4 +- .../user/PostgresSubscriptionMapperTest.java | 2 +- .../postgres/host/PostgresHostSystem.java | 4 +- .../sample-configuration/postgres.properties | 10 +- .../BodyDeduplicationIntegrationTest.java | 2 +- .../postgres/PostgresBlobStoreDAOTest.java | 2 +- .../modules/data/PostgresCommonModule.java | 26 ++-- .../PostgresEmailQueryViewDAO.java | 2 +- .../postgres/upload/PostgresUploadDAO.java | 2 +- .../upload/PostgresUploadRepository.java | 10 +- ...ngFilteringManagementNoProjectionTest.java | 2 +- ...sEventSourcingFilteringManagementTest.java | 4 +- .../PostgresEmailQueryViewTest.java | 2 +- ...PostgresMessageFastViewProjectionTest.java | 2 +- .../upload/PostgresUploadRepositoryTest.java | 2 +- .../upload/PostgresUploadServiceTest.java | 4 +- .../PostgresUploadUsageRepositoryTest.java | 2 +- .../postgres/PostgresDomainListTest.java | 2 +- .../postgres/PostgresDropListsTest.java | 2 +- ...MailRepositoryBlobReferenceSourceTest.java | 2 +- .../postgres/PostgresMailRepositoryTest.java | 2 +- ...stgresMailRepositoryUrlStoreExtension.java | 2 +- .../PostgresRecipientRewriteTableTest.java | 4 +- .../james/rrt/postgres/PostgresStepdefs.java | 4 +- .../postgres/PostgresSieveQuotaDAOTest.java | 4 +- .../postgres/PostgresSieveRepositoryTest.java | 4 +- .../postgres/PostgresDelegationStoreTest.java | 2 +- .../postgres/PostgresUsersRepositoryTest.java | 2 +- ...TaskExecutionDetailsProjectionDAOTest.java | 2 +- ...resTaskExecutionDetailsProjectionTest.java | 2 +- 66 files changed, 207 insertions(+), 213 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java index 9a5e2fa63c8..ed765ec82d5 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java @@ -42,17 +42,17 @@ public class PostgresConfiguration { public static final int PORT_DEFAULT_VALUE = 5432; public static final String USERNAME = "database.username"; public static final String PASSWORD = "database.password"; - public static final String NON_RLS_USERNAME = "database.non-rls.username"; - public static final String NON_RLS_PASSWORD = "database.non-rls.password"; + public static final String BY_PASS_RLS_USERNAME = "database.by-pass-rls.username"; + public static final String BY_PASS_RLS_PASSWORD = "database.by-pass-rls.password"; public static final String RLS_ENABLED = "row.level.security.enabled"; public static final String POOL_INITIAL_SIZE = "pool.initial.size"; public static final int POOL_INITIAL_SIZE_DEFAULT_VALUE = 10; public static final String POOL_MAX_SIZE = "pool.max.size"; public static final int POOL_MAX_SIZE_DEFAULT_VALUE = 15; - public static final String NON_RLS_POOL_INITIAL_SIZE = "non-rls.pool.initial.size"; - public static final int NON_RLS_POOL_INITIAL_SIZE_DEFAULT_VALUE = 5; - public static final String NON_RLS_POOL_MAX_SIZE = "non-rls.pool.max.size"; - public static final int NON_RLS_POOL_MAX_SIZE_DEFAULT_VALUE = 10; + public static final String BY_PASS_RLS_POOL_INITIAL_SIZE = "by-pass-rls.pool.initial.size"; + public static final int BY_PASS_RLS_POOL_INITIAL_SIZE_DEFAULT_VALUE = 5; + public static final String BY_PASS_RLS_POOL_MAX_SIZE = "by-pass-rls.pool.max.size"; + public static final int BY_PASS_RLS_POOL_MAX_SIZE_DEFAULT_VALUE = 10; public static final String SSL_MODE = "ssl.mode"; public static final String SSL_MODE_DEFAULT_VALUE = "allow"; public static final String JOOQ_REACTIVE_TIMEOUT = "jooq.reactive.timeout"; @@ -84,13 +84,13 @@ public static class Builder { private Optional port = Optional.empty(); private Optional username = Optional.empty(); private Optional password = Optional.empty(); - private Optional nonRLSUser = Optional.empty(); - private Optional nonRLSPassword = Optional.empty(); + private Optional byPassRLSUser = Optional.empty(); + private Optional byPassRLSPassword = Optional.empty(); private Optional rowLevelSecurityEnabled = Optional.empty(); private Optional poolInitialSize = Optional.empty(); private Optional poolMaxSize = Optional.empty(); - private Optional nonRLSPoolInitialSize = Optional.empty(); - private Optional nonRLSPoolMaxSize = Optional.empty(); + private Optional byPassRLSPoolInitialSize = Optional.empty(); + private Optional byPassRLSPoolMaxSize = Optional.empty(); private Optional sslMode = Optional.empty(); private Optional jooqReactiveTimeout = Optional.empty(); @@ -154,23 +154,23 @@ public Builder password(Optional password) { return this; } - public Builder nonRLSUser(String nonRLSUser) { - this.nonRLSUser = Optional.of(nonRLSUser); + public Builder byPassRLSUser(String byPassRLSUser) { + this.byPassRLSUser = Optional.of(byPassRLSUser); return this; } - public Builder nonRLSUser(Optional nonRLSUser) { - this.nonRLSUser = nonRLSUser; + public Builder byPassRLSUser(Optional byPassRLSUser) { + this.byPassRLSUser = byPassRLSUser; return this; } - public Builder nonRLSPassword(String nonRLSPassword) { - this.nonRLSPassword = Optional.of(nonRLSPassword); + public Builder byPassRLSPassword(String byPassRLSPassword) { + this.byPassRLSPassword = Optional.of(byPassRLSPassword); return this; } - public Builder nonRLSPassword(Optional nonRLSPassword) { - this.nonRLSPassword = nonRLSPassword; + public Builder byPassRLSPassword(Optional byPassRLSPassword) { + this.byPassRLSPassword = byPassRLSPassword; return this; } @@ -204,23 +204,23 @@ public Builder poolMaxSize(Integer poolMaxSize) { return this; } - public Builder nonRLSPoolInitialSize(Optional nonRLSPoolInitialSize) { - this.nonRLSPoolInitialSize = nonRLSPoolInitialSize; + public Builder byPassRLSPoolInitialSize(Optional byPassRLSPoolInitialSize) { + this.byPassRLSPoolInitialSize = byPassRLSPoolInitialSize; return this; } - public Builder nonRLSPoolInitialSize(Integer nonRLSPoolInitialSize) { - this.nonRLSPoolInitialSize = Optional.of(nonRLSPoolInitialSize); + public Builder byPassRLSPoolInitialSize(Integer byPassRLSPoolInitialSize) { + this.byPassRLSPoolInitialSize = Optional.of(byPassRLSPoolInitialSize); return this; } - public Builder nonRLSPoolMaxSize(Optional nonRLSPoolMaxSize) { - this.nonRLSPoolMaxSize = nonRLSPoolMaxSize; + public Builder byPassRLSPoolMaxSize(Optional byPassRLSPoolMaxSize) { + this.byPassRLSPoolMaxSize = byPassRLSPoolMaxSize; return this; } - public Builder nonRLSPoolMaxSize(Integer nonRLSPoolMaxSize) { - this.nonRLSPoolMaxSize = Optional.of(nonRLSPoolMaxSize); + public Builder byPassRLSPoolMaxSize(Integer byPassRLSPoolMaxSize) { + this.byPassRLSPoolMaxSize = Optional.of(byPassRLSPoolMaxSize); return this; } @@ -244,8 +244,8 @@ public PostgresConfiguration build() { Preconditions.checkArgument(password.isPresent() && !password.get().isBlank(), "You need to specify password"); if (rowLevelSecurityEnabled.isPresent() && rowLevelSecurityEnabled.get()) { - Preconditions.checkArgument(nonRLSUser.isPresent() && !nonRLSUser.get().isBlank(), "You need to specify nonRLSUser"); - Preconditions.checkArgument(nonRLSPassword.isPresent() && !nonRLSPassword.get().isBlank(), "You need to specify nonRLSPassword"); + Preconditions.checkArgument(byPassRLSUser.isPresent() && !byPassRLSUser.get().isBlank(), "You need to specify byPassRLSUser"); + Preconditions.checkArgument(byPassRLSPassword.isPresent() && !byPassRLSPassword.get().isBlank(), "You need to specify byPassRLSPassword"); } return new PostgresConfiguration(host.orElse(HOST_DEFAULT_VALUE), @@ -253,12 +253,12 @@ public PostgresConfiguration build() { databaseName.orElse(DATABASE_NAME_DEFAULT_VALUE), databaseSchema.orElse(DATABASE_SCHEMA_DEFAULT_VALUE), new Credential(username.get(), password.get()), - new Credential(nonRLSUser.orElse(username.get()), nonRLSPassword.orElse(password.get())), + new Credential(byPassRLSUser.orElse(username.get()), byPassRLSPassword.orElse(password.get())), rowLevelSecurityEnabled.orElse(false), poolInitialSize.orElse(POOL_INITIAL_SIZE_DEFAULT_VALUE), poolMaxSize.orElse(POOL_MAX_SIZE_DEFAULT_VALUE), - nonRLSPoolInitialSize.orElse(NON_RLS_POOL_INITIAL_SIZE_DEFAULT_VALUE), - nonRLSPoolMaxSize.orElse(NON_RLS_POOL_MAX_SIZE_DEFAULT_VALUE), + byPassRLSPoolInitialSize.orElse(BY_PASS_RLS_POOL_INITIAL_SIZE_DEFAULT_VALUE), + byPassRLSPoolMaxSize.orElse(BY_PASS_RLS_POOL_MAX_SIZE_DEFAULT_VALUE), SSLMode.fromValue(sslMode.orElse(SSL_MODE_DEFAULT_VALUE)), jooqReactiveTimeout.orElse(JOOQ_REACTIVE_TIMEOUT_DEFAULT_VALUE)); } @@ -276,13 +276,13 @@ public static PostgresConfiguration from(Configuration propertiesConfiguration) .port(propertiesConfiguration.getInt(PORT, PORT_DEFAULT_VALUE)) .username(Optional.ofNullable(propertiesConfiguration.getString(USERNAME))) .password(Optional.ofNullable(propertiesConfiguration.getString(PASSWORD))) - .nonRLSUser(Optional.ofNullable(propertiesConfiguration.getString(NON_RLS_USERNAME))) - .nonRLSPassword(Optional.ofNullable(propertiesConfiguration.getString(NON_RLS_PASSWORD))) + .byPassRLSUser(Optional.ofNullable(propertiesConfiguration.getString(BY_PASS_RLS_USERNAME))) + .byPassRLSPassword(Optional.ofNullable(propertiesConfiguration.getString(BY_PASS_RLS_PASSWORD))) .rowLevelSecurityEnabled(propertiesConfiguration.getBoolean(RLS_ENABLED, false)) .poolInitialSize(Optional.ofNullable(propertiesConfiguration.getInteger(POOL_INITIAL_SIZE, null))) .poolMaxSize(Optional.ofNullable(propertiesConfiguration.getInteger(POOL_MAX_SIZE, null))) - .nonRLSPoolInitialSize(Optional.ofNullable(propertiesConfiguration.getInteger(NON_RLS_POOL_INITIAL_SIZE, null))) - .nonRLSPoolMaxSize(Optional.ofNullable(propertiesConfiguration.getInteger(NON_RLS_POOL_MAX_SIZE, null))) + .byPassRLSPoolInitialSize(Optional.ofNullable(propertiesConfiguration.getInteger(BY_PASS_RLS_POOL_INITIAL_SIZE, null))) + .byPassRLSPoolMaxSize(Optional.ofNullable(propertiesConfiguration.getInteger(BY_PASS_RLS_POOL_MAX_SIZE, null))) .sslMode(Optional.ofNullable(propertiesConfiguration.getString(SSL_MODE))) .jooqReactiveTimeout(Optional.ofNullable(propertiesConfiguration.getString(JOOQ_REACTIVE_TIMEOUT)) .map(value -> DurationParser.parse(value, ChronoUnit.SECONDS))) @@ -293,32 +293,32 @@ public static PostgresConfiguration from(Configuration propertiesConfiguration) private final int port; private final String databaseName; private final String databaseSchema; - private final Credential credential; - private final Credential nonRLSCredential; + private final Credential defaultCredential; + private final Credential byPassRLSCredential; private final boolean rowLevelSecurityEnabled; private final Integer poolInitialSize; private final Integer poolMaxSize; - private final Integer nonRLSPoolInitialSize; - private final Integer nonRLSPoolMaxSize; + private final Integer byPassRLSPoolInitialSize; + private final Integer byPassRLSPoolMaxSize; private final SSLMode sslMode; private final Duration jooqReactiveTimeout; private PostgresConfiguration(String host, int port, String databaseName, String databaseSchema, - Credential credential, Credential nonRLSCredential, boolean rowLevelSecurityEnabled, + Credential defaultCredential, Credential byPassRLSCredential, boolean rowLevelSecurityEnabled, Integer poolInitialSize, Integer poolMaxSize, - Integer nonRLSPoolInitialSize, Integer nonRLSPoolMaxSize, + Integer byPassRLSPoolInitialSize, Integer byPassRLSPoolMaxSize, SSLMode sslMode, Duration jooqReactiveTimeout) { this.host = host; this.port = port; this.databaseName = databaseName; this.databaseSchema = databaseSchema; - this.credential = credential; - this.nonRLSCredential = nonRLSCredential; + this.defaultCredential = defaultCredential; + this.byPassRLSCredential = byPassRLSCredential; this.rowLevelSecurityEnabled = rowLevelSecurityEnabled; this.poolInitialSize = poolInitialSize; this.poolMaxSize = poolMaxSize; - this.nonRLSPoolInitialSize = nonRLSPoolInitialSize; - this.nonRLSPoolMaxSize = nonRLSPoolMaxSize; + this.byPassRLSPoolInitialSize = byPassRLSPoolInitialSize; + this.byPassRLSPoolMaxSize = byPassRLSPoolMaxSize; this.sslMode = sslMode; this.jooqReactiveTimeout = jooqReactiveTimeout; } @@ -339,12 +339,12 @@ public String getDatabaseSchema() { return databaseSchema; } - public Credential getCredential() { - return credential; + public Credential getDefaultCredential() { + return defaultCredential; } - public Credential getNonRLSCredential() { - return nonRLSCredential; + public Credential getByPassRLSCredential() { + return byPassRLSCredential; } public boolean rowLevelSecurityEnabled() { @@ -359,12 +359,12 @@ public Integer poolMaxSize() { return poolMaxSize; } - public Integer nonRLSPoolInitialSize() { - return nonRLSPoolInitialSize; + public Integer byPassRLSPoolInitialSize() { + return byPassRLSPoolInitialSize; } - public Integer nonRLSPoolMaxSize() { - return nonRLSPoolMaxSize; + public Integer byPassRLSPoolMaxSize() { + return byPassRLSPoolMaxSize; } public SSLMode getSslMode() { @@ -377,7 +377,7 @@ public Duration getJooqReactiveTimeout() { @Override public final int hashCode() { - return Objects.hash(host, port, databaseName, databaseSchema, credential, nonRLSCredential, rowLevelSecurityEnabled, poolInitialSize, poolMaxSize, sslMode, jooqReactiveTimeout); + return Objects.hash(host, port, databaseName, databaseSchema, defaultCredential, byPassRLSCredential, rowLevelSecurityEnabled, poolInitialSize, poolMaxSize, sslMode, jooqReactiveTimeout); } @Override @@ -388,8 +388,8 @@ public final boolean equals(Object o) { return Objects.equals(this.rowLevelSecurityEnabled, that.rowLevelSecurityEnabled) && Objects.equals(this.host, that.host) && Objects.equals(this.port, that.port) - && Objects.equals(this.credential, that.credential) - && Objects.equals(this.nonRLSCredential, that.nonRLSCredential) + && Objects.equals(this.defaultCredential, that.defaultCredential) + && Objects.equals(this.byPassRLSCredential, that.byPassRLSCredential) && Objects.equals(this.databaseName, that.databaseName) && Objects.equals(this.databaseSchema, that.databaseSchema) && Objects.equals(this.poolInitialSize, that.poolInitialSize) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/JamesPostgresConnectionFactory.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/JamesPostgresConnectionFactory.java index 29fc1edd2c8..e1b74faf817 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/JamesPostgresConnectionFactory.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/JamesPostgresConnectionFactory.java @@ -26,7 +26,7 @@ public interface JamesPostgresConnectionFactory { String DOMAIN_ATTRIBUTE = "app.current_domain"; - String NON_RLS_INJECT = "non_rls"; + String BY_PASS_RLS_INJECT = "by_pass_rls"; Mono getConnection(Domain domain); diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresConnectionClosure.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresConnectionClosure.java index eb80556a583..0815177f2e7 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresConnectionClosure.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresConnectionClosure.java @@ -27,19 +27,19 @@ public class PostgresConnectionClosure implements Disposable { private final JamesPostgresConnectionFactory factory; - private final JamesPostgresConnectionFactory nonRLSFactory; + private final JamesPostgresConnectionFactory byPassRLSFactory; @Inject public PostgresConnectionClosure(JamesPostgresConnectionFactory factory, - @Named(JamesPostgresConnectionFactory.NON_RLS_INJECT) JamesPostgresConnectionFactory nonRLSFactory) { + @Named(JamesPostgresConnectionFactory.BY_PASS_RLS_INJECT) JamesPostgresConnectionFactory byPassRLSFactory) { this.factory = factory; - this.nonRLSFactory = nonRLSFactory; + this.byPassRLSFactory = byPassRLSFactory; } @PreDestroy @Override public void dispose() { factory.close().block(); - nonRLSFactory.close().block(); + byPassRLSFactory.close().block(); } } diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java index 5e19af6b642..aaa3fadf614 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PostgresExecutor.java @@ -57,7 +57,7 @@ public class PostgresExecutor { public static final String DEFAULT_INJECT = "default"; - public static final String NON_RLS_INJECT = "non_rls"; + public static final String BY_PASS_RLS_INJECT = "by_pass_rls"; public static final int MAX_RETRY_ATTEMPTS = 5; public static final Duration MIN_BACKOFF = Duration.ofMillis(1); private static final Logger LOGGER = LoggerFactory.getLogger(PostgresExecutor.class); diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java index 2c9c8b3c0d5..75cc50513de 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java @@ -37,8 +37,8 @@ void shouldReturnCorrespondingProperties() { .databaseSchema("sc") .username("james") .password("1") - .nonRLSUser("nonrlsjames") - .nonRLSPassword("2") + .byPassRLSUser("bypassrlsjames") + .byPassRLSPassword("2") .rowLevelSecurityEnabled() .sslMode("require") .build(); @@ -47,10 +47,10 @@ void shouldReturnCorrespondingProperties() { assertThat(configuration.getPort()).isEqualTo(1111); assertThat(configuration.getDatabaseName()).isEqualTo("db"); assertThat(configuration.getDatabaseSchema()).isEqualTo("sc"); - assertThat(configuration.getCredential().getUsername()).isEqualTo("james"); - assertThat(configuration.getCredential().getPassword()).isEqualTo("1"); - assertThat(configuration.getNonRLSCredential().getUsername()).isEqualTo("nonrlsjames"); - assertThat(configuration.getNonRLSCredential().getPassword()).isEqualTo("2"); + assertThat(configuration.getDefaultCredential().getUsername()).isEqualTo("james"); + assertThat(configuration.getDefaultCredential().getPassword()).isEqualTo("1"); + assertThat(configuration.getByPassRLSCredential().getUsername()).isEqualTo("bypassrlsjames"); + assertThat(configuration.getByPassRLSCredential().getPassword()).isEqualTo("2"); assertThat(configuration.rowLevelSecurityEnabled()).isEqualTo(true); assertThat(configuration.getSslMode()).isEqualTo(SSLMode.REQUIRE); } @@ -66,8 +66,8 @@ void shouldUseDefaultValues() { assertThat(configuration.getPort()).isEqualTo(PostgresConfiguration.PORT_DEFAULT_VALUE); assertThat(configuration.getDatabaseName()).isEqualTo(PostgresConfiguration.DATABASE_NAME_DEFAULT_VALUE); assertThat(configuration.getDatabaseSchema()).isEqualTo(PostgresConfiguration.DATABASE_SCHEMA_DEFAULT_VALUE); - assertThat(configuration.getNonRLSCredential().getUsername()).isEqualTo("james"); - assertThat(configuration.getNonRLSCredential().getPassword()).isEqualTo("1"); + assertThat(configuration.getByPassRLSCredential().getUsername()).isEqualTo("james"); + assertThat(configuration.getByPassRLSCredential().getPassword()).isEqualTo("1"); assertThat(configuration.rowLevelSecurityEnabled()).isEqualTo(false); assertThat(configuration.getSslMode()).isEqualTo(SSLMode.ALLOW); } @@ -90,26 +90,26 @@ void shouldThrowWhenMissingPassword() { } @Test - void shouldThrowWhenMissingNonRLSUserAndRLSIsEnabled() { + void shouldThrowWhenMissingByPassRLSUserAndRLSIsEnabled() { assertThatThrownBy(() -> PostgresConfiguration.builder() .username("james") .password("1") .rowLevelSecurityEnabled() .build()) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("You need to specify nonRLSUser"); + .hasMessage("You need to specify byPassRLSUser"); } @Test - void shouldThrowWhenMissingNonRLSPasswordAndRLSIsEnabled() { + void shouldThrowWhenMissingByPassRLSPasswordAndRLSIsEnabled() { assertThatThrownBy(() -> PostgresConfiguration.builder() .username("james") .password("1") - .nonRLSUser("nonrlsjames") + .byPassRLSUser("bypassrlsjames") .rowLevelSecurityEnabled() .build()) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("You need to specify nonRLSPassword"); + .hasMessage("You need to specify byPassRLSPassword"); } @Test diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExecutorThreadSafetyTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExecutorThreadSafetyTest.java index e8c3d6a9f84..da1ada6db15 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExecutorThreadSafetyTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExecutorThreadSafetyTest.java @@ -55,7 +55,7 @@ class PostgresExecutorThreadSafetyTest { @BeforeAll static void beforeAll() { - postgresExecutor = postgresExtension.getPostgresExecutor(); + postgresExecutor = postgresExtension.getDefaultPostgresExecutor(); } @BeforeEach diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index 4e0debe6058..e527ee1925e 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -95,10 +95,10 @@ public static PostgresExtension empty() { private final PostgresFixture.Database selectedDatabase; private PoolSize poolSize; private PostgresConfiguration postgresConfiguration; - private PostgresExecutor postgresExecutor; - private PostgresExecutor nonRLSPostgresExecutor; + private PostgresExecutor defaultPostgresExecutor; + private PostgresExecutor byPassRLSPostgresExecutor; private PostgresqlConnectionFactory connectionFactory; - private Connection superConnection; + private Connection defaultConnection; private PostgresExecutor.Factory executorFactory; private PostgresTableManager postgresTableManager; @@ -160,8 +160,8 @@ private void initPostgresSession() { .port(getMappedPort()) .username(selectedDatabase.dbUser()) .password(selectedDatabase.dbPassword()) - .nonRLSUser(DEFAULT_DATABASE.dbUser()) - .nonRLSPassword(DEFAULT_DATABASE.dbPassword()) + .byPassRLSUser(DEFAULT_DATABASE.dbUser()) + .byPassRLSPassword(DEFAULT_DATABASE.dbPassword()) .rowLevelSecurityEnabled(rlsEnabled) .jooqReactiveTimeout(Optional.of(Duration.ofSeconds(20L))) .build(); @@ -178,25 +178,24 @@ private void initPostgresSession() { RecordingMetricFactory metricFactory = new RecordingMetricFactory(); - connectionFactory = new PostgresqlConnectionFactory(postgresqlConnectionConfigurationFunction.apply(postgresConfiguration.getCredential())); - superConnection = connectionFactory.create().block(); - + connectionFactory = new PostgresqlConnectionFactory(postgresqlConnectionConfigurationFunction.apply(postgresConfiguration.getDefaultCredential())); + defaultConnection = connectionFactory.create().block(); executorFactory = new PostgresExecutor.Factory( getJamesPostgresConnectionFactory(rlsEnabled, connectionFactory), postgresConfiguration, metricFactory); - postgresExecutor = executorFactory.create(); + defaultPostgresExecutor = executorFactory.create(); - PostgresqlConnectionFactory nonRLSConnectionFactory = new PostgresqlConnectionFactory(postgresqlConnectionConfigurationFunction.apply(postgresConfiguration.getNonRLSCredential())); + PostgresqlConnectionFactory byPassRLSConnectionFactory = new PostgresqlConnectionFactory(postgresqlConnectionConfigurationFunction.apply(postgresConfiguration.getByPassRLSCredential())); - nonRLSPostgresExecutor = new PostgresExecutor.Factory( - getJamesPostgresConnectionFactory(false, nonRLSConnectionFactory), + byPassRLSPostgresExecutor = new PostgresExecutor.Factory( + getJamesPostgresConnectionFactory(false, byPassRLSConnectionFactory), postgresConfiguration, metricFactory) .create(); - this.postgresTableManager = new PostgresTableManager(postgresExecutor, postgresModule, rlsEnabled); + this.postgresTableManager = new PostgresTableManager(defaultPostgresExecutor, postgresModule, rlsEnabled); } @Override @@ -205,9 +204,9 @@ public void afterAll(ExtensionContext extensionContext) { } private void disposePostgresSession() { - postgresExecutor.dispose(); - nonRLSPostgresExecutor.dispose(); - superConnection.close(); + defaultPostgresExecutor.dispose(); + byPassRLSPostgresExecutor.dispose(); + Mono.from(defaultConnection.close()).subscribe(); } @Override @@ -241,15 +240,15 @@ public Integer getMappedPort() { } public Mono getConnection() { - return Mono.just(superConnection); + return Mono.just(defaultConnection); } - public PostgresExecutor getPostgresExecutor() { - return postgresExecutor; + public PostgresExecutor getDefaultPostgresExecutor() { + return defaultPostgresExecutor; } - public PostgresExecutor getNonRLSPostgresExecutor() { - return nonRLSPostgresExecutor; + public PostgresExecutor getByPassRLSPostgresExecutor() { + return byPassRLSPostgresExecutor; } public ConnectionFactory getConnectionFactory() { @@ -279,7 +278,7 @@ private void dropTables(List tables) { .map(tableName -> "\"" + tableName + "\"") .collect(Collectors.joining(", ")); - Flux.from(superConnection.createStatement(String.format("DROP table if exists %s cascade;", tablesToDelete)) + Flux.from(defaultConnection.createStatement(String.format("DROP table if exists %s cascade;", tablesToDelete)) .execute()) .then() .block(); diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java index e1414906dc4..dd4a31e8aad 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java @@ -41,7 +41,7 @@ class PostgresTableManagerTest { static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresModule.EMPTY_MODULE); Function tableManagerFactory = - module -> new PostgresTableManager(postgresExtension.getPostgresExecutor(), module, true); + module -> new PostgresTableManager(postgresExtension.getDefaultPostgresExecutor(), module, true); @Test void initializeTableShouldSuccessWhenModuleHasSingleTable() { @@ -343,7 +343,7 @@ void createTableShouldNotCreateRlsColumnWhenDisableRLS() { boolean disabledRLS = false; - PostgresTableManager testee = new PostgresTableManager(postgresExtension.getPostgresExecutor(), module, disabledRLS); + PostgresTableManager testee = new PostgresTableManager(postgresExtension.getDefaultPostgresExecutor(), module, disabledRLS); testee.initializeTables() .block(); @@ -383,7 +383,7 @@ void additionalAlterQueryToCreateConstraintShouldSucceed() { .addAdditionalAlterQueries("ALTER TABLE tbn1 ADD CONSTRAINT " + constraintName + " EXCLUDE (clm2 WITH =)") .build(); PostgresModule module = PostgresModule.table(table); - PostgresTableManager testee = new PostgresTableManager(postgresExtension.getPostgresExecutor(), module, false); + PostgresTableManager testee = new PostgresTableManager(postgresExtension.getDefaultPostgresExecutor(), module, false); testee.initializeTables().block(); @@ -409,7 +409,7 @@ void additionalAlterQueryToReCreateConstraintShouldNotThrow() { .addAdditionalAlterQueries("ALTER TABLE tbn1 ADD CONSTRAINT " + constraintName + " EXCLUDE (clm2 WITH =)") .build(); PostgresModule module = PostgresModule.table(table); - PostgresTableManager testee = new PostgresTableManager(postgresExtension.getPostgresExecutor(), module, false); + PostgresTableManager testee = new PostgresTableManager(postgresExtension.getDefaultPostgresExecutor(), module, false); testee.initializeTables().block(); diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAOTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAOTest.java index b8d782fe371..0fc87c8d579 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAOTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/quota/PostgresQuotaCurrentValueDAOTest.java @@ -41,7 +41,7 @@ class PostgresQuotaCurrentValueDAOTest { @BeforeEach void setup() { - postgresQuotaCurrentValueDAO = new PostgresQuotaCurrentValueDAO(postgresExtension.getPostgresExecutor()); + postgresQuotaCurrentValueDAO = new PostgresQuotaCurrentValueDAO(postgresExtension.getDefaultPostgresExecutor()); } @Test diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/quota/PostgresQuotaLimitDaoTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/quota/PostgresQuotaLimitDaoTest.java index 4e382ef3d39..6b3ea3641fe 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/quota/PostgresQuotaLimitDaoTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/quota/PostgresQuotaLimitDaoTest.java @@ -39,7 +39,7 @@ public class PostgresQuotaLimitDaoTest { @BeforeEach void setup() { - postgresQuotaLimitDao = new PostgresQuotaLimitDAO(postgresExtension.getPostgresExecutor()); + postgresQuotaLimitDao = new PostgresQuotaLimitDAO(postgresExtension.getDefaultPostgresExecutor()); } @Test diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/utils/PostgresHealthCheckTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/utils/PostgresHealthCheckTest.java index e380920b5fa..f48f8d5b8c2 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/utils/PostgresHealthCheckTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/utils/PostgresHealthCheckTest.java @@ -22,14 +22,9 @@ import static org.assertj.core.api.Assertions.assertThat; import org.apache.james.backends.postgres.PostgresExtension; -import org.apache.james.backends.postgres.quota.PostgresQuotaLimitDAO; import org.apache.james.backends.postgres.quota.PostgresQuotaModule; import org.apache.james.core.healthcheck.Result; import org.apache.james.core.healthcheck.ResultStatus; -import org.apache.james.core.quota.QuotaComponent; -import org.apache.james.core.quota.QuotaLimit; -import org.apache.james.core.quota.QuotaScope; -import org.apache.james.core.quota.QuotaType; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -44,7 +39,7 @@ public class PostgresHealthCheckTest { @BeforeEach void setup() { - testee = new PostgresHealthCheck(postgresExtension.getPostgresExecutor()); + testee = new PostgresHealthCheck(postgresExtension.getDefaultPostgresExecutor()); } @Test diff --git a/event-bus/postgres/src/test/java/org/apache/james/events/PostgresEventDeadLettersTest.java b/event-bus/postgres/src/test/java/org/apache/james/events/PostgresEventDeadLettersTest.java index 7677f4e3cdb..6dff2be8e11 100644 --- a/event-bus/postgres/src/test/java/org/apache/james/events/PostgresEventDeadLettersTest.java +++ b/event-bus/postgres/src/test/java/org/apache/james/events/PostgresEventDeadLettersTest.java @@ -30,6 +30,6 @@ public class PostgresEventDeadLettersTest implements EventDeadLettersContract.Al @Override public EventDeadLetters eventDeadLetters() { - return new PostgresEventDeadLetters(postgresExtension.getPostgresExecutor(), new EventBusTestFixture.TestEventSerializer()); + return new PostgresEventDeadLetters(postgresExtension.getDefaultPostgresExecutor(), new EventBusTestFixture.TestEventSerializer()); } } diff --git a/event-sourcing/event-store-postgres/src/test/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreExtension.java b/event-sourcing/event-store-postgres/src/test/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreExtension.java index 652d8af6a45..6f5ea91e1c7 100644 --- a/event-sourcing/event-store-postgres/src/test/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreExtension.java +++ b/event-sourcing/event-store-postgres/src/test/java/org/apache/james/eventsourcing/eventstore/postgres/PostgresEventStoreExtension.java @@ -67,6 +67,6 @@ public boolean supportsParameter(ParameterContext parameterContext, ExtensionCon @Override public PostgresEventStore resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { - return new PostgresEventStore(new PostgresEventStoreDAO(postgresExtension.getPostgresExecutor(), jsonEventSerializer)); + return new PostgresEventStore(new PostgresEventStoreDAO(postgresExtension.getDefaultPostgresExecutor(), jsonEventSerializer)); } } diff --git a/mailbox/plugin/deleted-messages-vault-postgres/src/test/java/org/apache/james/vault/metadata/PostgresDeletedMessageMetadataVaultTest.java b/mailbox/plugin/deleted-messages-vault-postgres/src/test/java/org/apache/james/vault/metadata/PostgresDeletedMessageMetadataVaultTest.java index 766df623c36..b765147f1ab 100644 --- a/mailbox/plugin/deleted-messages-vault-postgres/src/test/java/org/apache/james/vault/metadata/PostgresDeletedMessageMetadataVaultTest.java +++ b/mailbox/plugin/deleted-messages-vault-postgres/src/test/java/org/apache/james/vault/metadata/PostgresDeletedMessageMetadataVaultTest.java @@ -39,7 +39,7 @@ public DeletedMessageMetadataVault metadataVault() { DeletedMessageWithStorageInformationConverter dtoConverter = new DeletedMessageWithStorageInformationConverter(blobIdFactory, messageIdFactory, new InMemoryId.Factory()); - return new PostgresDeletedMessageMetadataVault(postgresExtension.getPostgresExecutor(), + return new PostgresDeletedMessageMetadataVault(postgresExtension.getDefaultPostgresExecutor(), new MetadataSerializer(dtoConverter), blobIdFactory); } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentBlobReferenceSource.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentBlobReferenceSource.java index b6eae71ae29..3e64be72e31 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentBlobReferenceSource.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentBlobReferenceSource.java @@ -36,7 +36,7 @@ public class PostgresAttachmentBlobReferenceSource implements BlobReferenceSourc @Inject @Singleton - public PostgresAttachmentBlobReferenceSource(@Named(PostgresExecutor.NON_RLS_INJECT) PostgresExecutor postgresExecutor, + public PostgresAttachmentBlobReferenceSource(@Named(PostgresExecutor.BY_PASS_RLS_INJECT) PostgresExecutor postgresExecutor, BlobId.Factory bloIdFactory) { this(new PostgresAttachmentDAO(postgresExecutor, bloIdFactory)); } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java index 8aaa3a66a11..ec3814c8d4b 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMessageDAO.java @@ -90,7 +90,7 @@ public PostgresMessageDAO create(Optional domain) { private final BlobId.Factory blobIdFactory; @Inject - public PostgresMessageDAO(@Named(PostgresExecutor.NON_RLS_INJECT) PostgresExecutor postgresExecutor, BlobId.Factory blobIdFactory) { + public PostgresMessageDAO(@Named(PostgresExecutor.BY_PASS_RLS_INJECT) PostgresExecutor postgresExecutor, BlobId.Factory blobIdFactory) { this.postgresExecutor = postgresExecutor; this.blobIdFactory = blobIdFactory; } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java index 8407302b7aa..b8f2ad14bba 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java @@ -96,22 +96,22 @@ PostgresMailboxManager provideMailboxManager() { @Override PostgresMessageDAO providePostgresMessageDAO() { - return new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), BLOB_ID_FACTORY); + return new PostgresMessageDAO(postgresExtension.getDefaultPostgresExecutor(), BLOB_ID_FACTORY); } @Override PostgresMailboxMessageDAO providePostgresMailboxMessageDAO() { - return new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); + return new PostgresMailboxMessageDAO(postgresExtension.getDefaultPostgresExecutor()); } @Override PostgresThreadDAO threadDAO() { - return new PostgresThreadDAO(postgresExtension.getPostgresExecutor()); + return new PostgresThreadDAO(postgresExtension.getDefaultPostgresExecutor()); } @Override PostgresAttachmentDAO attachmentDAO() { - return new PostgresAttachmentDAO(postgresExtension.getPostgresExecutor(), BLOB_ID_FACTORY); + return new PostgresAttachmentDAO(postgresExtension.getDefaultPostgresExecutor(), BLOB_ID_FACTORY); } @Override diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresTestSystemFixture.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresTestSystemFixture.java index 3d954ffb649..7b6dd4f7d64 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresTestSystemFixture.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresTestSystemFixture.java @@ -104,10 +104,10 @@ static StoreMessageIdManager createMessageIdManager(PostgresMailboxSessionMapper } static MaxQuotaManager createMaxQuotaManager(PostgresExtension postgresExtension) { - return new PostgresPerUserMaxQuotaManager(new PostgresQuotaLimitDAO(postgresExtension.getPostgresExecutor())); + return new PostgresPerUserMaxQuotaManager(new PostgresQuotaLimitDAO(postgresExtension.getDefaultPostgresExecutor())); } public static CurrentQuotaManager createCurrentQuotaManager(PostgresExtension postgresExtension) { - return new PostgresCurrentQuotaManager(new PostgresQuotaCurrentValueDAO(postgresExtension.getPostgresExecutor())); + return new PostgresCurrentQuotaManager(new PostgresQuotaCurrentValueDAO(postgresExtension.getDefaultPostgresExecutor())); } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperTest.java index 0b2d75ba29e..4bb1495356f 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperTest.java @@ -42,12 +42,12 @@ public class PostgresAnnotationMapperTest extends AnnotationMapperTest { @Override protected AnnotationMapper createAnnotationMapper() { - return new PostgresAnnotationMapper(new PostgresMailboxAnnotationDAO(postgresExtension.getPostgresExecutor())); + return new PostgresAnnotationMapper(new PostgresMailboxAnnotationDAO(postgresExtension.getDefaultPostgresExecutor())); } @Override protected MailboxId generateMailboxId() { - MailboxMapper mailboxMapper = new PostgresMailboxMapper(new PostgresMailboxDAO(postgresExtension.getPostgresExecutor())); + MailboxMapper mailboxMapper = new PostgresMailboxMapper(new PostgresMailboxDAO(postgresExtension.getDefaultPostgresExecutor())); return mailboxMapper.create(benwaInboxPath, UID_VALIDITY).block().getMailboxId(); } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentBlobReferenceSourceTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentBlobReferenceSourceTest.java index b48d76ffa48..58a17a4189c 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentBlobReferenceSourceTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentBlobReferenceSourceTest.java @@ -50,7 +50,7 @@ class PostgresAttachmentBlobReferenceSourceTest { @BeforeEach void beforeEach() { HashBlobId.Factory blobIdFactory = new HashBlobId.Factory(); - postgresAttachmentDAO = new PostgresAttachmentDAO(postgresExtension.getPostgresExecutor(), + postgresAttachmentDAO = new PostgresAttachmentDAO(postgresExtension.getDefaultPostgresExecutor(), blobIdFactory); testee = new PostgresAttachmentBlobReferenceSource(postgresAttachmentDAO); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentMapperTest.java index 68dda51d5ec..698d639b057 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentMapperTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAttachmentMapperTest.java @@ -42,7 +42,7 @@ class PostgresAttachmentMapperTest extends AttachmentMapperTest { @Override protected AttachmentMapper createAttachmentMapper() { - PostgresAttachmentDAO postgresAttachmentDAO = new PostgresAttachmentDAO(postgresExtension.getPostgresExecutor(), BLOB_ID_FACTORY); + PostgresAttachmentDAO postgresAttachmentDAO = new PostgresAttachmentDAO(postgresExtension.getDefaultPostgresExecutor(), BLOB_ID_FACTORY); BlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, BLOB_ID_FACTORY); return new PostgresAttachmentMapper(postgresAttachmentDAO, blobStore); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperACLTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperACLTest.java index 4b73a298ea5..9f700ac8041 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperACLTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperACLTest.java @@ -31,6 +31,6 @@ class PostgresMailboxMapperACLTest extends MailboxMapperACLTest { @Override protected MailboxMapper createMailboxMapper() { - return new PostgresMailboxMapper(new PostgresMailboxDAO(postgresExtension.getPostgresExecutor())); + return new PostgresMailboxMapper(new PostgresMailboxDAO(postgresExtension.getDefaultPostgresExecutor())); } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperTest.java index 31a2ae282a5..35e63e01e27 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperTest.java @@ -43,7 +43,7 @@ public class PostgresMailboxMapperTest extends MailboxMapperTest { @Override protected MailboxMapper createMailboxMapper() { - return new PostgresMailboxMapper(new PostgresMailboxDAO(postgresExtension.getPostgresExecutor())); + return new PostgresMailboxMapper(new PostgresMailboxDAO(postgresExtension.getDefaultPostgresExecutor())); } @Override diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMapperProvider.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMapperProvider.java index 06cc36cca80..8a91e1be799 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMapperProvider.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMapperProvider.java @@ -67,7 +67,7 @@ public PostgresMapperProvider(PostgresExtension postgresExtension) { this.messageIdFactory = new PostgresMessageId.Factory(); this.blobIdFactory = new HashBlobId.Factory(); this.blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory); - this.messageUidProvider = new PostgresUidProvider(new PostgresMailboxDAO(postgresExtension.getPostgresExecutor())); + this.messageUidProvider = new PostgresUidProvider(new PostgresMailboxDAO(postgresExtension.getDefaultPostgresExecutor())); } @Override @@ -78,18 +78,18 @@ public List getSupportedCapabilities() { @Override public MailboxMapper createMailboxMapper() { - return new PostgresMailboxMapper(new PostgresMailboxDAO(postgresExtension.getPostgresExecutor())); + return new PostgresMailboxMapper(new PostgresMailboxDAO(postgresExtension.getDefaultPostgresExecutor())); } @Override public MessageMapper createMessageMapper() { - PostgresMailboxDAO mailboxDAO = new PostgresMailboxDAO(postgresExtension.getPostgresExecutor()); + PostgresMailboxDAO mailboxDAO = new PostgresMailboxDAO(postgresExtension.getDefaultPostgresExecutor()); PostgresModSeqProvider modSeqProvider = new PostgresModSeqProvider(mailboxDAO); PostgresUidProvider uidProvider = new PostgresUidProvider(mailboxDAO); return new PostgresMessageMapper( - postgresExtension.getPostgresExecutor(), + postgresExtension.getDefaultPostgresExecutor(), modSeqProvider, uidProvider, blobStore, @@ -99,12 +99,12 @@ public MessageMapper createMessageMapper() { @Override public MessageIdMapper createMessageIdMapper() { - PostgresMailboxDAO mailboxDAO = new PostgresMailboxDAO(postgresExtension.getPostgresExecutor()); + PostgresMailboxDAO mailboxDAO = new PostgresMailboxDAO(postgresExtension.getDefaultPostgresExecutor()); return new PostgresMessageIdMapper(mailboxDAO, - new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), blobIdFactory), - new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()), + new PostgresMessageDAO(postgresExtension.getDefaultPostgresExecutor(), blobIdFactory), + new PostgresMailboxMessageDAO(postgresExtension.getDefaultPostgresExecutor()), new PostgresModSeqProvider(mailboxDAO), - new PostgresAttachmentMapper(new PostgresAttachmentDAO(postgresExtension.getPostgresExecutor(), blobIdFactory), blobStore), + new PostgresAttachmentMapper(new PostgresAttachmentDAO(postgresExtension.getDefaultPostgresExecutor(), blobIdFactory), blobStore), blobStore, blobIdFactory, updatableTickingClock); @@ -132,7 +132,7 @@ public MessageUid generateMessageUid(Mailbox mailbox) { @Override public ModSeq generateModSeq(Mailbox mailbox) { try { - return new PostgresModSeqProvider(new PostgresMailboxDAO(postgresExtension.getPostgresExecutor())) + return new PostgresModSeqProvider(new PostgresMailboxDAO(postgresExtension.getDefaultPostgresExecutor())) .nextModSeq(mailbox); } catch (MailboxException e) { throw new RuntimeException(e); @@ -141,7 +141,7 @@ public ModSeq generateModSeq(Mailbox mailbox) { @Override public ModSeq highestModSeq(Mailbox mailbox) { - return new PostgresModSeqProvider(new PostgresMailboxDAO(postgresExtension.getPostgresExecutor())) + return new PostgresModSeqProvider(new PostgresMailboxDAO(postgresExtension.getDefaultPostgresExecutor())) .highestModSeq(mailbox); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageBlobReferenceSourceTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageBlobReferenceSourceTest.java index 0fe245c667c..2cb0503df93 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageBlobReferenceSourceTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageBlobReferenceSourceTest.java @@ -59,7 +59,7 @@ public class PostgresMessageBlobReferenceSourceTest { @BeforeEach void beforeEach() { - postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), new HashBlobId.Factory()); + postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getDefaultPostgresExecutor(), new HashBlobId.Factory()); blobReferenceSource = new PostgresMessageBlobReferenceSource(postgresMessageDAO); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java index 1b248de56ca..c7677b724b4 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java @@ -70,7 +70,7 @@ public class PostgresMessageMapperRowLevelSecurityTest { private Mailbox mailbox; private Mailbox generateMailbox() { - MailboxMapper mailboxMapper = new PostgresMailboxMapper(new PostgresMailboxDAO(postgresExtension.getPostgresExecutor())); + MailboxMapper mailboxMapper = new PostgresMailboxMapper(new PostgresMailboxDAO(postgresExtension.getDefaultPostgresExecutor())); return mailboxMapper.create(benwaInboxPath, UID_VALIDITY).block(); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresModSeqProviderTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresModSeqProviderTest.java index eff361562c2..cd7e59cca08 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresModSeqProviderTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresModSeqProviderTest.java @@ -54,7 +54,7 @@ public class PostgresModSeqProviderTest { @BeforeEach void setup() { - PostgresMailboxDAO mailboxDAO = new PostgresMailboxDAO(postgresExtension.getPostgresExecutor()); + PostgresMailboxDAO mailboxDAO = new PostgresMailboxDAO(postgresExtension.getDefaultPostgresExecutor()); modSeqProvider = new PostgresModSeqProvider(mailboxDAO); MailboxPath mailboxPath = new MailboxPath("gsoc", Username.of("ieugen" + UUID.randomUUID()), "INBOX"); UidValidity uidValidity = UidValidity.of(1234); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresUidProviderTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresUidProviderTest.java index f2e20f09aca..df8277fb6c7 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresUidProviderTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresUidProviderTest.java @@ -56,7 +56,7 @@ public class PostgresUidProviderTest { @BeforeEach void setup() { - PostgresMailboxDAO mailboxDAO = new PostgresMailboxDAO(postgresExtension.getPostgresExecutor()); + PostgresMailboxDAO mailboxDAO = new PostgresMailboxDAO(postgresExtension.getDefaultPostgresExecutor()); uidProvider = new PostgresUidProvider(mailboxDAO); MailboxPath mailboxPath = new MailboxPath("gsoc", Username.of("ieugen" + UUID.randomUUID()), "INBOX"); UidValidity uidValidity = UidValidity.of(1234); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/PostgresRecomputeCurrentQuotasServiceTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/PostgresRecomputeCurrentQuotasServiceTest.java index 89eed4ddc1c..0f4b8243d4c 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/PostgresRecomputeCurrentQuotasServiceTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/task/PostgresRecomputeCurrentQuotasServiceTest.java @@ -71,7 +71,7 @@ class PostgresRecomputeCurrentQuotasServiceTest implements RecomputeCurrentQuota void setUp() throws Exception { MailboxSessionMapperFactory mapperFactory = PostgresMailboxManagerProvider.provideMailboxSessionMapperFactory(postgresExtension); - PostgresUsersDAO usersDAO = new PostgresUsersDAO(postgresExtension.getPostgresExecutor(), + PostgresUsersDAO usersDAO = new PostgresUsersDAO(postgresExtension.getDefaultPostgresExecutor(), PostgresUsersRepositoryConfiguration.DEFAULT); usersRepository = new PostgresUsersRepository(NO_DOMAIN_LIST, usersDAO); @@ -81,7 +81,7 @@ void setUp() throws Exception { mailboxManager = PostgresMailboxManagerProvider.provideMailboxManager(postgresExtension, PreDeletionHooks.NO_PRE_DELETION_HOOK); sessionProvider = mailboxManager.getSessionProvider(); - currentQuotaManager = new PostgresCurrentQuotaManager(new PostgresQuotaCurrentValueDAO(postgresExtension.getPostgresExecutor())); + currentQuotaManager = new PostgresCurrentQuotaManager(new PostgresQuotaCurrentValueDAO(postgresExtension.getDefaultPostgresExecutor())); userQuotaRootResolver = new DefaultUserQuotaRootResolver(sessionProvider, mapperFactory); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/PostgresCurrentQuotaManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/PostgresCurrentQuotaManagerTest.java index 4e725af7d58..3402e281894 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/PostgresCurrentQuotaManagerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/PostgresCurrentQuotaManagerTest.java @@ -36,7 +36,7 @@ class PostgresCurrentQuotaManagerTest implements CurrentQuotaManagerContract { @BeforeEach void setup() { - currentQuotaManager = new PostgresCurrentQuotaManager(new PostgresQuotaCurrentValueDAO(postgresExtension.getPostgresExecutor())); + currentQuotaManager = new PostgresCurrentQuotaManager(new PostgresQuotaCurrentValueDAO(postgresExtension.getDefaultPostgresExecutor())); } @Override diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/PostgresPerUserMaxQuotaManagerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/PostgresPerUserMaxQuotaManagerTest.java index 56da9f23784..1d4eb2d14c8 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/PostgresPerUserMaxQuotaManagerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/quota/PostgresPerUserMaxQuotaManagerTest.java @@ -32,6 +32,6 @@ public class PostgresPerUserMaxQuotaManagerTest extends GenericMaxQuotaManagerTe @Override protected MaxQuotaManager provideMaxQuotaManager() { - return new PostgresPerUserMaxQuotaManager(new PostgresQuotaLimitDAO(postgresExtension.getPostgresExecutor())); + return new PostgresPerUserMaxQuotaManager(new PostgresQuotaLimitDAO(postgresExtension.getDefaultPostgresExecutor())); } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/AllSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/AllSearchOverrideTest.java index ed9aafdce97..b8f42d867a3 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/AllSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/AllSearchOverrideTest.java @@ -50,8 +50,8 @@ public class AllSearchOverrideTest { @BeforeEach void setUp() { - postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), new HashBlobId.Factory()); - postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); + postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getDefaultPostgresExecutor(), new HashBlobId.Factory()); + postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getDefaultPostgresExecutor()); testee = new AllSearchOverride(postgresExtension.getExecutorFactory()); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverrideTest.java index 42471bf5c2a..325435bc921 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedSearchOverrideTest.java @@ -51,8 +51,8 @@ public class DeletedSearchOverrideTest { @BeforeEach void setUp() { - postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), new HashBlobId.Factory()); - postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); + postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getDefaultPostgresExecutor(), new HashBlobId.Factory()); + postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getDefaultPostgresExecutor()); testee = new DeletedSearchOverride(postgresExtension.getExecutorFactory()); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverrideTest.java index 7f7b05307a1..657c7c758c1 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/DeletedWithRangeSearchOverrideTest.java @@ -51,8 +51,8 @@ public class DeletedWithRangeSearchOverrideTest { @BeforeEach void setUp() { - postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), new HashBlobId.Factory()); - postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); + postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getDefaultPostgresExecutor(), new HashBlobId.Factory()); + postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getDefaultPostgresExecutor()); testee = new DeletedWithRangeSearchOverride(postgresExtension.getExecutorFactory()); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverrideTest.java index 351f79da52e..b47b6e72f7c 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/NotDeletedWithRangeSearchOverrideTest.java @@ -51,8 +51,8 @@ public class NotDeletedWithRangeSearchOverrideTest { @BeforeEach void setUp() { - postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), new HashBlobId.Factory()); - postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); + postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getDefaultPostgresExecutor(), new HashBlobId.Factory()); + postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getDefaultPostgresExecutor()); testee = new NotDeletedWithRangeSearchOverride(postgresExtension.getExecutorFactory()); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UidSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UidSearchOverrideTest.java index 10bd7190b90..28f0dcfef8f 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UidSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UidSearchOverrideTest.java @@ -50,8 +50,8 @@ public class UidSearchOverrideTest { @BeforeEach void setUp() { - postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), new HashBlobId.Factory()); - postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); + postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getDefaultPostgresExecutor(), new HashBlobId.Factory()); + postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getDefaultPostgresExecutor()); testee = new UidSearchOverride(postgresExtension.getExecutorFactory()); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverrideTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverrideTest.java index 7b78e7253c1..984314e6dc9 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverrideTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/search/UnseenSearchOverrideTest.java @@ -50,8 +50,8 @@ public class UnseenSearchOverrideTest { @BeforeEach void setUp() { - postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getPostgresExecutor(), new HashBlobId.Factory()); - postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getPostgresExecutor()); + postgresMessageDAO = new PostgresMessageDAO(postgresExtension.getDefaultPostgresExecutor(), new HashBlobId.Factory()); + postgresMailboxMessageDAO = new PostgresMailboxMessageDAO(postgresExtension.getDefaultPostgresExecutor()); testee = new UnseenSearchOverride(postgresExtension.getExecutorFactory()); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperTest.java index ebd4c626e27..f4fbebb4343 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/user/PostgresSubscriptionMapperTest.java @@ -31,7 +31,7 @@ public class PostgresSubscriptionMapperTest extends SubscriptionMapperTest { @Override protected SubscriptionMapper createSubscriptionMapper() { - PostgresSubscriptionDAO dao = new PostgresSubscriptionDAO(postgresExtension.getPostgresExecutor()); + PostgresSubscriptionDAO dao = new PostgresSubscriptionDAO(postgresExtension.getDefaultPostgresExecutor()); return new PostgresSubscriptionMapper(dao); } } diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java index 8882526eaaf..daa8378d461 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java @@ -119,8 +119,8 @@ public void beforeTest() throws Exception { StoreMailboxAnnotationManager annotationManager = new StoreMailboxAnnotationManager(mapperFactory, storeRightManager); SessionProviderImpl sessionProvider = new SessionProviderImpl(authenticator, authorizator); DefaultUserQuotaRootResolver quotaRootResolver = new DefaultUserQuotaRootResolver(sessionProvider, mapperFactory); - CurrentQuotaManager currentQuotaManager = new PostgresCurrentQuotaManager(new PostgresQuotaCurrentValueDAO(postgresExtension.getPostgresExecutor())); - maxQuotaManager = new PostgresPerUserMaxQuotaManager(new PostgresQuotaLimitDAO(postgresExtension.getPostgresExecutor())); + CurrentQuotaManager currentQuotaManager = new PostgresCurrentQuotaManager(new PostgresQuotaCurrentValueDAO(postgresExtension.getDefaultPostgresExecutor())); + maxQuotaManager = new PostgresPerUserMaxQuotaManager(new PostgresQuotaLimitDAO(postgresExtension.getDefaultPostgresExecutor())); StoreQuotaManager storeQuotaManager = new StoreQuotaManager(currentQuotaManager, maxQuotaManager); ListeningCurrentQuotaUpdater quotaUpdater = new ListeningCurrentQuotaUpdater(currentQuotaManager, quotaRootResolver, eventBus, storeQuotaManager); QuotaComponents quotaComponents = new QuotaComponents(maxQuotaManager, storeQuotaManager, quotaRootResolver); diff --git a/server/apps/postgres-app/sample-configuration/postgres.properties b/server/apps/postgres-app/sample-configuration/postgres.properties index 85cefb1ba3a..58c7cd476c9 100644 --- a/server/apps/postgres-app/sample-configuration/postgres.properties +++ b/server/apps/postgres-app/sample-configuration/postgres.properties @@ -20,10 +20,10 @@ database.password=secret1 row.level.security.enabled=false # String. It is required when row.level.security.enabled is true. Database username with the permission of bypassing RLS. -#database.non-rls.username=nonrlsjames +#database.by-pass-rls.username=bypassrlsjames -# String. It is required when row.level.security.enabled is true. Database password of non-rls user. -#database.non-rls.password=secret1 +# String. It is required when row.level.security.enabled is true. Database password of by-pass-rls user. +#database.by-pass-rls.password=secret1 # Integer. Optional, default to 10. Database connection pool initial size. pool.initial.size=10 @@ -32,10 +32,10 @@ pool.initial.size=10 pool.max.size=15 # Integer. Optional, default to 5. rls-bypass database connection pool initial size. -non-rls.pool.initial.size=5 +by-pass-rls.pool.initial.size=5 # Integer. Optional, default to 10. rls-bypass database connection pool max size. -non-rls.pool.max.size=10 +by-pass-rls.pool.max.size=10 # String. Optional, defaults to allow. SSLMode required to connect to the Postgresql db server. # Check https://www.postgresql.org/docs/current/libpq-ssl.html#LIBPQ-SSL-PROTECTION for a list of supported SSLModes. diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/BodyDeduplicationIntegrationTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/BodyDeduplicationIntegrationTest.java index c048b3de6b3..05263b0ef60 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/BodyDeduplicationIntegrationTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/BodyDeduplicationIntegrationTest.java @@ -124,7 +124,7 @@ void bodyBlobsShouldBeDeDeduplicated(GuiceJamesServer server) throws Exception { .awaitMessageCount(CALMLY_AWAIT, 1); // Then the body blobs are deduplicated - int distinctBlobCount = postgresExtension.getPostgresExecutor() + int distinctBlobCount = postgresExtension.getDefaultPostgresExecutor() .executeCount(dslContext -> Mono.from(dslContext.select(DSL.countDistinct(PostgresMessageModule.MessageTable.BODY_BLOB_ID)) .from(PostgresMessageModule.MessageTable.TABLE_NAME))) .block(); diff --git a/server/blob/blob-postgres/src/test/java/org/apache/james/blob/postgres/PostgresBlobStoreDAOTest.java b/server/blob/blob-postgres/src/test/java/org/apache/james/blob/postgres/PostgresBlobStoreDAOTest.java index 8562be38d79..85b047d17c8 100644 --- a/server/blob/blob-postgres/src/test/java/org/apache/james/blob/postgres/PostgresBlobStoreDAOTest.java +++ b/server/blob/blob-postgres/src/test/java/org/apache/james/blob/postgres/PostgresBlobStoreDAOTest.java @@ -51,7 +51,7 @@ class PostgresBlobStoreDAOTest implements BlobStoreDAOContract { @BeforeEach void setUp() { - blobStore = new PostgresBlobStoreDAO(postgresExtension.getPostgresExecutor(), new HashBlobId.Factory()); + blobStore = new PostgresBlobStoreDAO(postgresExtension.getDefaultPostgresExecutor(), new HashBlobId.Factory()); } @Override diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index 592879b75e1..9573d65d538 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -85,17 +85,17 @@ JamesPostgresConnectionFactory provideJamesPostgresConnectionFactory(PostgresCon } @Provides - @Named(JamesPostgresConnectionFactory.NON_RLS_INJECT) + @Named(JamesPostgresConnectionFactory.BY_PASS_RLS_INJECT) @Singleton JamesPostgresConnectionFactory provideJamesPostgresConnectionFactoryWithRLSBypass(PostgresConfiguration postgresConfiguration, JamesPostgresConnectionFactory jamesPostgresConnectionFactory, - @Named(JamesPostgresConnectionFactory.NON_RLS_INJECT) ConnectionFactory connectionFactory) { + @Named(JamesPostgresConnectionFactory.BY_PASS_RLS_INJECT) ConnectionFactory connectionFactory) { if (!postgresConfiguration.rowLevelSecurityEnabled()) { return jamesPostgresConnectionFactory; } return new PoolBackedPostgresConnectionFactory(DISABLED_ROW_LEVEL_SECURITY, - postgresConfiguration.nonRLSPoolInitialSize(), - postgresConfiguration.nonRLSPoolMaxSize(), + postgresConfiguration.byPassRLSPoolInitialSize(), + postgresConfiguration.byPassRLSPoolMaxSize(), connectionFactory); } @@ -105,8 +105,8 @@ ConnectionFactory postgresqlConnectionFactory(PostgresConfiguration postgresConf return new PostgresqlConnectionFactory(PostgresqlConnectionConfiguration.builder() .host(postgresConfiguration.getHost()) .port(postgresConfiguration.getPort()) - .username(postgresConfiguration.getCredential().getUsername()) - .password(postgresConfiguration.getCredential().getPassword()) + .username(postgresConfiguration.getDefaultCredential().getUsername()) + .password(postgresConfiguration.getDefaultCredential().getPassword()) .database(postgresConfiguration.getDatabaseName()) .schema(postgresConfiguration.getDatabaseSchema()) .sslMode(postgresConfiguration.getSslMode()) @@ -114,14 +114,14 @@ ConnectionFactory postgresqlConnectionFactory(PostgresConfiguration postgresConf } @Provides - @Named(JamesPostgresConnectionFactory.NON_RLS_INJECT) + @Named(JamesPostgresConnectionFactory.BY_PASS_RLS_INJECT) @Singleton ConnectionFactory postgresqlConnectionFactoryRLSBypass(PostgresConfiguration postgresConfiguration) { return new PostgresqlConnectionFactory(PostgresqlConnectionConfiguration.builder() .host(postgresConfiguration.getHost()) .port(postgresConfiguration.getPort()) - .username(postgresConfiguration.getNonRLSCredential().getUsername()) - .password(postgresConfiguration.getNonRLSCredential().getPassword()) + .username(postgresConfiguration.getByPassRLSCredential().getUsername()) + .password(postgresConfiguration.getByPassRLSCredential().getPassword()) .database(postgresConfiguration.getDatabaseName()) .schema(postgresConfiguration.getDatabaseSchema()) .sslMode(postgresConfiguration.getSslMode()) @@ -143,9 +143,9 @@ PostgresTableManager postgresTableManager(PostgresExecutor postgresExecutor, } @Provides - @Named(PostgresExecutor.NON_RLS_INJECT) + @Named(PostgresExecutor.BY_PASS_RLS_INJECT) @Singleton - PostgresExecutor.Factory postgresExecutorFactoryWithRLSBypass(@Named(PostgresExecutor.NON_RLS_INJECT) JamesPostgresConnectionFactory singlePostgresConnectionFactory, + PostgresExecutor.Factory postgresExecutorFactoryWithRLSBypass(@Named(PostgresExecutor.BY_PASS_RLS_INJECT) JamesPostgresConnectionFactory singlePostgresConnectionFactory, PostgresConfiguration postgresConfiguration, MetricFactory metricFactory) { return new PostgresExecutor.Factory(singlePostgresConnectionFactory, postgresConfiguration, metricFactory); @@ -159,9 +159,9 @@ PostgresExecutor defaultPostgresExecutor(PostgresExecutor.Factory factory) { } @Provides - @Named(PostgresExecutor.NON_RLS_INJECT) + @Named(PostgresExecutor.BY_PASS_RLS_INJECT) @Singleton - PostgresExecutor postgresExecutorWithRLSBypass(@Named(PostgresExecutor.NON_RLS_INJECT) PostgresExecutor.Factory factory) { + PostgresExecutor postgresExecutorWithRLSBypass(@Named(PostgresExecutor.BY_PASS_RLS_INJECT) PostgresExecutor.Factory factory) { return factory.create(); } diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewDAO.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewDAO.java index de01286d41d..a61146c67ac 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewDAO.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewDAO.java @@ -46,7 +46,7 @@ public class PostgresEmailQueryViewDAO { private PostgresExecutor postgresExecutor; @Inject - public PostgresEmailQueryViewDAO(@Named(PostgresExecutor.NON_RLS_INJECT) PostgresExecutor postgresExecutor) { + public PostgresEmailQueryViewDAO(@Named(PostgresExecutor.BY_PASS_RLS_INJECT) PostgresExecutor postgresExecutor) { this.postgresExecutor = postgresExecutor; } diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadDAO.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadDAO.java index 70b480764c2..489e53a95ed 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadDAO.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadDAO.java @@ -66,7 +66,7 @@ public PostgresUploadDAO create(Optional domain) { @Singleton @Inject - public PostgresUploadDAO(@Named(PostgresExecutor.NON_RLS_INJECT) PostgresExecutor postgresExecutor, BlobId.Factory blobIdFactory) { + public PostgresUploadDAO(@Named(PostgresExecutor.BY_PASS_RLS_INJECT) PostgresExecutor postgresExecutor, BlobId.Factory blobIdFactory) { this.postgresExecutor = postgresExecutor; this.blobIdFactory = blobIdFactory; } diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java index ac240ee155c..35d2c7b86c0 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepository.java @@ -52,17 +52,17 @@ public class PostgresUploadRepository implements UploadRepository { private final BlobStore blobStore; private final Clock clock; private final PostgresUploadDAO.Factory uploadDAOFactory; - private final PostgresUploadDAO nonRLSUploadDAO; + private final PostgresUploadDAO byPassRLSUploadDAO; @Inject @Singleton public PostgresUploadRepository(BlobStore blobStore, Clock clock, PostgresUploadDAO.Factory uploadDAOFactory, - PostgresUploadDAO nonRLSUploadDAO) { + PostgresUploadDAO byPassRLSUploadDAO) { this.blobStore = blobStore; this.clock = clock; this.uploadDAOFactory = uploadDAOFactory; - this.nonRLSUploadDAO = nonRLSUploadDAO; + this.byPassRLSUploadDAO = byPassRLSUploadDAO; } @Override @@ -97,12 +97,12 @@ public Flux listUploads(Username user) { public Mono deleteByUploadDateBefore(Duration expireDuration) { LocalDateTime expirationTime = INSTANT_TO_LOCAL_DATE_TIME.apply(clock.instant().minus(expireDuration)); - return Flux.from(nonRLSUploadDAO.listByUploadDateBefore(expirationTime)) + return Flux.from(byPassRLSUploadDAO.listByUploadDateBefore(expirationTime)) .flatMap(uploadPair -> { Username username = uploadPair.getRight(); UploadMetaData upload = uploadPair.getLeft(); return Mono.from(blobStore.delete(UPLOAD_BUCKET, upload.blobId())) - .then(nonRLSUploadDAO.delete(upload.uploadId(), username)); + .then(byPassRLSUploadDAO.delete(upload.uploadId(), username)); }, DEFAULT_CONCURRENCY) .then(); } diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/filtering/PostgresEventSourcingFilteringManagementNoProjectionTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/filtering/PostgresEventSourcingFilteringManagementNoProjectionTest.java index 86041e4f84f..fc66484ae6a 100644 --- a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/filtering/PostgresEventSourcingFilteringManagementNoProjectionTest.java +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/filtering/PostgresEventSourcingFilteringManagementNoProjectionTest.java @@ -37,7 +37,7 @@ public class PostgresEventSourcingFilteringManagementNoProjectionTest implements @Override public FilteringManagement instantiateFilteringManagement() { - EventStore eventStore = new PostgresEventStore(new PostgresEventStoreDAO(postgresExtension.getPostgresExecutor(), + EventStore eventStore = new PostgresEventStore(new PostgresEventStoreDAO(postgresExtension.getDefaultPostgresExecutor(), JsonEventSerializer.forModules(FilteringRuleSetDefineDTOModules.FILTERING_RULE_SET_DEFINED, FilteringRuleSetDefineDTOModules.FILTERING_INCREMENT).withoutNestedType())); return new EventSourcingFilteringManagement(eventStore, diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/filtering/PostgresEventSourcingFilteringManagementTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/filtering/PostgresEventSourcingFilteringManagementTest.java index 49d84230944..4cb286c21da 100644 --- a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/filtering/PostgresEventSourcingFilteringManagementTest.java +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/filtering/PostgresEventSourcingFilteringManagementTest.java @@ -38,9 +38,9 @@ public class PostgresEventSourcingFilteringManagementTest implements FilteringMa @Override public FilteringManagement instantiateFilteringManagement() { - return new EventSourcingFilteringManagement(new PostgresEventStore(new PostgresEventStoreDAO(postgresExtension.getPostgresExecutor(), + return new EventSourcingFilteringManagement(new PostgresEventStore(new PostgresEventStoreDAO(postgresExtension.getDefaultPostgresExecutor(), JsonEventSerializer.forModules(FilteringRuleSetDefineDTOModules.FILTERING_RULE_SET_DEFINED, FilteringRuleSetDefineDTOModules.FILTERING_INCREMENT).withoutNestedType())), - new PostgresFilteringProjection(new PostgresFilteringProjectionDAO(postgresExtension.getPostgresExecutor()))); + new PostgresFilteringProjection(new PostgresFilteringProjectionDAO(postgresExtension.getDefaultPostgresExecutor()))); } } diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewTest.java index 2bc02c86903..0b4218a2ad1 100644 --- a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewTest.java +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewTest.java @@ -41,7 +41,7 @@ public class PostgresEmailQueryViewTest implements EmailQueryViewContract { @Override public EmailQueryView testee() { - return new PostgresEmailQueryView(new PostgresEmailQueryViewDAO(postgresExtension.getPostgresExecutor())); + return new PostgresEmailQueryView(new PostgresEmailQueryViewDAO(postgresExtension.getDefaultPostgresExecutor())); } @Override diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/projections/PostgresMessageFastViewProjectionTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/projections/PostgresMessageFastViewProjectionTest.java index 80fd09de74b..d436cb6677d 100644 --- a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/projections/PostgresMessageFastViewProjectionTest.java +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/projections/PostgresMessageFastViewProjectionTest.java @@ -42,7 +42,7 @@ class PostgresMessageFastViewProjectionTest implements MessageFastViewProjection void setUp() { metricFactory = new RecordingMetricFactory(); postgresMessageIdFactory = new PostgresMessageId.Factory(); - testee = new PostgresMessageFastViewProjection(postgresExtension.getPostgresExecutor(), metricFactory); + testee = new PostgresMessageFastViewProjection(postgresExtension.getDefaultPostgresExecutor(), metricFactory); } @Override diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepositoryTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepositoryTest.java index c9876fe4234..5ba8851bef0 100644 --- a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepositoryTest.java +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadRepositoryTest.java @@ -47,7 +47,7 @@ void setUp() { clock = new UpdatableTickingClock(Clock.systemUTC().instant()); HashBlobId.Factory blobIdFactory = new HashBlobId.Factory(); BlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory); - PostgresUploadDAO uploadDAO = new PostgresUploadDAO(postgresExtension.getNonRLSPostgresExecutor(), blobIdFactory); + PostgresUploadDAO uploadDAO = new PostgresUploadDAO(postgresExtension.getDefaultPostgresExecutor(), blobIdFactory); PostgresUploadDAO.Factory uploadFactory = new PostgresUploadDAO.Factory(blobIdFactory, postgresExtension.getExecutorFactory()); testee = new PostgresUploadRepository(blobStore, clock, uploadFactory, uploadDAO); } diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadServiceTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadServiceTest.java index 86a16084855..3a861739345 100644 --- a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadServiceTest.java +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadServiceTest.java @@ -52,10 +52,10 @@ public class PostgresUploadServiceTest implements UploadServiceContract { void setUp() { HashBlobId.Factory blobIdFactory = new HashBlobId.Factory(); BlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory); - PostgresUploadDAO uploadDAO = new PostgresUploadDAO(postgresExtension.getNonRLSPostgresExecutor(), blobIdFactory); + PostgresUploadDAO uploadDAO = new PostgresUploadDAO(postgresExtension.getDefaultPostgresExecutor(), blobIdFactory); PostgresUploadDAO.Factory uploadFactory = new PostgresUploadDAO.Factory(blobIdFactory, postgresExtension.getExecutorFactory()); uploadRepository = new PostgresUploadRepository(blobStore, Clock.systemUTC(), uploadFactory, uploadDAO); - uploadUsageRepository = new PostgresUploadUsageRepository(new PostgresQuotaCurrentValueDAO(postgresExtension.getPostgresExecutor())); + uploadUsageRepository = new PostgresUploadUsageRepository(new PostgresQuotaCurrentValueDAO(postgresExtension.getDefaultPostgresExecutor())); testee = new UploadServiceDefaultImpl(uploadRepository, uploadUsageRepository, UploadServiceContract.TEST_CONFIGURATION()); } diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadUsageRepositoryTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadUsageRepositoryTest.java index 1064a42b182..23c10a5de9d 100644 --- a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadUsageRepositoryTest.java +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadUsageRepositoryTest.java @@ -37,7 +37,7 @@ public class PostgresUploadUsageRepositoryTest implements UploadUsageRepositoryC private PostgresUploadUsageRepository uploadUsageRepository; @BeforeEach public void setup() { - uploadUsageRepository = new PostgresUploadUsageRepository(new PostgresQuotaCurrentValueDAO(postgresExtension.getPostgresExecutor())); + uploadUsageRepository = new PostgresUploadUsageRepository(new PostgresQuotaCurrentValueDAO(postgresExtension.getDefaultPostgresExecutor())); resetCounterToZero(); } @Override diff --git a/server/data/data-postgres/src/test/java/org/apache/james/domainlist/postgres/PostgresDomainListTest.java b/server/data/data-postgres/src/test/java/org/apache/james/domainlist/postgres/PostgresDomainListTest.java index fc7ba810499..a7136faf16c 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/domainlist/postgres/PostgresDomainListTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/domainlist/postgres/PostgresDomainListTest.java @@ -34,7 +34,7 @@ public class PostgresDomainListTest implements DomainListContract { @BeforeEach public void setup() throws Exception { - domainList = new PostgresDomainList(getDNSServer("localhost"), postgresExtension.getPostgresExecutor()); + domainList = new PostgresDomainList(getDNSServer("localhost"), postgresExtension.getDefaultPostgresExecutor()); domainList.configure(DomainListConfiguration.builder() .autoDetect(false) .autoDetectIp(false) diff --git a/server/data/data-postgres/src/test/java/org/apache/james/droplists/postgres/PostgresDropListsTest.java b/server/data/data-postgres/src/test/java/org/apache/james/droplists/postgres/PostgresDropListsTest.java index 99c5dc7b5bf..c0697ed2656 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/droplists/postgres/PostgresDropListsTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/droplists/postgres/PostgresDropListsTest.java @@ -33,7 +33,7 @@ class PostgresDropListsTest implements DropListContract { @BeforeEach void setup() { - dropList = new PostgresDropList(postgresExtension.getPostgresExecutor()); + dropList = new PostgresDropList(postgresExtension.getDefaultPostgresExecutor()); } @Override diff --git a/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryBlobReferenceSourceTest.java b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryBlobReferenceSourceTest.java index 7d33edb9a54..b2f60cb93d9 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryBlobReferenceSourceTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryBlobReferenceSourceTest.java @@ -57,7 +57,7 @@ void beforeEach() { .blobIdFactory(factory) .defaultBucketName() .passthrough(); - postgresMailRepositoryContentDAO = new PostgresMailRepositoryContentDAO(postgresExtension.getPostgresExecutor(), MimeMessageStore.factory(blobStore), factory); + postgresMailRepositoryContentDAO = new PostgresMailRepositoryContentDAO(postgresExtension.getDefaultPostgresExecutor(), MimeMessageStore.factory(blobStore), factory); postgresMailRepositoryBlobReferenceSource = new PostgresMailRepositoryBlobReferenceSource(postgresMailRepositoryContentDAO); } diff --git a/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryTest.java b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryTest.java index 35a17357d9f..9f2bd8033a8 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryTest.java @@ -58,6 +58,6 @@ public PostgresMailRepository retrieveRepository(MailRepositoryPath path) { .blobIdFactory(BLOB_ID_FACTORY) .defaultBucketName() .passthrough(); - return new PostgresMailRepository(url, new PostgresMailRepositoryContentDAO(postgresExtension.getPostgresExecutor(), MimeMessageStore.factory(blobStore), BLOB_ID_FACTORY)); + return new PostgresMailRepository(url, new PostgresMailRepositoryContentDAO(postgresExtension.getDefaultPostgresExecutor(), MimeMessageStore.factory(blobStore), BLOB_ID_FACTORY)); } } diff --git a/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryUrlStoreExtension.java b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryUrlStoreExtension.java index 5f56caf099b..0454c1dc099 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryUrlStoreExtension.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/mailrepository/postgres/PostgresMailRepositoryUrlStoreExtension.java @@ -65,6 +65,6 @@ public boolean supportsParameter(ParameterContext parameterContext, ExtensionCon @Override public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws ParameterResolutionException { - return new PostgresMailRepositoryUrlStore(postgresExtension.getPostgresExecutor()); + return new PostgresMailRepositoryUrlStore(postgresExtension.getDefaultPostgresExecutor()); } } diff --git a/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableTest.java b/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableTest.java index 757778dd7b0..21e8ac45c36 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTableTest.java @@ -50,9 +50,9 @@ void teardown() throws Exception { @Override public void createRecipientRewriteTable() { - postgresRecipientRewriteTable = new PostgresRecipientRewriteTable(new PostgresRecipientRewriteTableDAO(postgresExtension.getPostgresExecutor())); + postgresRecipientRewriteTable = new PostgresRecipientRewriteTable(new PostgresRecipientRewriteTableDAO(postgresExtension.getDefaultPostgresExecutor())); postgresRecipientRewriteTable.setUsersRepository(new PostgresUsersRepository(new SimpleDomainList(), - new PostgresUsersDAO(postgresExtension.getPostgresExecutor(), PostgresUsersRepositoryConfiguration.DEFAULT))); + new PostgresUsersDAO(postgresExtension.getDefaultPostgresExecutor(), PostgresUsersRepositoryConfiguration.DEFAULT))); } @Override diff --git a/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/PostgresStepdefs.java b/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/PostgresStepdefs.java index f3da4c21bd6..fc14db19d65 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/PostgresStepdefs.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/rrt/postgres/PostgresStepdefs.java @@ -57,9 +57,9 @@ public void tearDown() { } private AbstractRecipientRewriteTable getRecipientRewriteTable() throws DomainListException { - PostgresRecipientRewriteTable postgresRecipientRewriteTable = new PostgresRecipientRewriteTable(new PostgresRecipientRewriteTableDAO(postgresExtension.getPostgresExecutor())); + PostgresRecipientRewriteTable postgresRecipientRewriteTable = new PostgresRecipientRewriteTable(new PostgresRecipientRewriteTableDAO(postgresExtension.getDefaultPostgresExecutor())); postgresRecipientRewriteTable.setUsersRepository(new PostgresUsersRepository(RecipientRewriteTableFixture.domainListForCucumberTests(), - new PostgresUsersDAO(postgresExtension.getPostgresExecutor(), PostgresUsersRepositoryConfiguration.DEFAULT))); + new PostgresUsersDAO(postgresExtension.getDefaultPostgresExecutor(), PostgresUsersRepositoryConfiguration.DEFAULT))); postgresRecipientRewriteTable.setDomainList(RecipientRewriteTableFixture.domainListForCucumberTests()); return postgresRecipientRewriteTable; } diff --git a/server/data/data-postgres/src/test/java/org/apache/james/sieve/postgres/PostgresSieveQuotaDAOTest.java b/server/data/data-postgres/src/test/java/org/apache/james/sieve/postgres/PostgresSieveQuotaDAOTest.java index aaeb02af06f..1181e810ba2 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/sieve/postgres/PostgresSieveQuotaDAOTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/sieve/postgres/PostgresSieveQuotaDAOTest.java @@ -43,8 +43,8 @@ class PostgresSieveQuotaDAOTest { @BeforeEach void setup() { - testee = new PostgresSieveQuotaDAO(new PostgresQuotaCurrentValueDAO(postgresExtension.getPostgresExecutor()), - new PostgresQuotaLimitDAO(postgresExtension.getPostgresExecutor())); + testee = new PostgresSieveQuotaDAO(new PostgresQuotaCurrentValueDAO(postgresExtension.getDefaultPostgresExecutor()), + new PostgresQuotaLimitDAO(postgresExtension.getDefaultPostgresExecutor())); } @Test diff --git a/server/data/data-postgres/src/test/java/org/apache/james/sieve/postgres/PostgresSieveRepositoryTest.java b/server/data/data-postgres/src/test/java/org/apache/james/sieve/postgres/PostgresSieveRepositoryTest.java index d67c71069ee..35b0b4a0d54 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/sieve/postgres/PostgresSieveRepositoryTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/sieve/postgres/PostgresSieveRepositoryTest.java @@ -38,8 +38,8 @@ class PostgresSieveRepositoryTest implements SieveRepositoryContract { @BeforeEach void setUp() { - sieveRepository = new PostgresSieveRepository(new PostgresSieveQuotaDAO(new PostgresQuotaCurrentValueDAO(postgresExtension.getPostgresExecutor()), new PostgresQuotaLimitDAO(postgresExtension.getPostgresExecutor())), - new PostgresSieveScriptDAO(postgresExtension.getPostgresExecutor())); + sieveRepository = new PostgresSieveRepository(new PostgresSieveQuotaDAO(new PostgresQuotaCurrentValueDAO(postgresExtension.getDefaultPostgresExecutor()), new PostgresQuotaLimitDAO(postgresExtension.getDefaultPostgresExecutor())), + new PostgresSieveScriptDAO(postgresExtension.getDefaultPostgresExecutor())); } @Override diff --git a/server/data/data-postgres/src/test/java/org/apache/james/user/postgres/PostgresDelegationStoreTest.java b/server/data/data-postgres/src/test/java/org/apache/james/user/postgres/PostgresDelegationStoreTest.java index cae65185a65..6d163a23229 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/user/postgres/PostgresDelegationStoreTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/user/postgres/PostgresDelegationStoreTest.java @@ -40,7 +40,7 @@ public class PostgresDelegationStoreTest implements DelegationStoreContract { @BeforeEach void beforeEach() { - postgresUsersDAO = new PostgresUsersDAO(postgresExtension.getPostgresExecutor(), PostgresUsersRepositoryConfiguration.DEFAULT); + postgresUsersDAO = new PostgresUsersDAO(postgresExtension.getDefaultPostgresExecutor(), PostgresUsersRepositoryConfiguration.DEFAULT); postgresDelegationStore = new PostgresDelegationStore(postgresUsersDAO, any -> Mono.just(true)); } diff --git a/server/data/data-postgres/src/test/java/org/apache/james/user/postgres/PostgresUsersRepositoryTest.java b/server/data/data-postgres/src/test/java/org/apache/james/user/postgres/PostgresUsersRepositoryTest.java index 3676ee8dcd6..1f2c79b4448 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/user/postgres/PostgresUsersRepositoryTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/user/postgres/PostgresUsersRepositoryTest.java @@ -118,7 +118,7 @@ public UsersRepository testee(Optional administrator) throws Exception } private static UsersRepositoryImpl getUsersRepository(DomainList domainList, boolean enableVirtualHosting, Optional administrator) throws Exception { - PostgresUsersDAO usersDAO = new PostgresUsersDAO(postgresExtension.getPostgresExecutor(), + PostgresUsersDAO usersDAO = new PostgresUsersDAO(postgresExtension.getDefaultPostgresExecutor(), PostgresUsersRepositoryConfiguration.DEFAULT); BaseHierarchicalConfiguration configuration = new BaseHierarchicalConfiguration(); configuration.addProperty("enableVirtualHosting", String.valueOf(enableVirtualHosting)); diff --git a/server/task/task-postgres/src/test/java/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionDAOTest.java b/server/task/task-postgres/src/test/java/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionDAOTest.java index 22f07fd340d..85b8508444e 100644 --- a/server/task/task-postgres/src/test/java/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionDAOTest.java +++ b/server/task/task-postgres/src/test/java/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionDAOTest.java @@ -56,7 +56,7 @@ class PostgresTaskExecutionDetailsProjectionDAOTest { @BeforeEach void setUp() { - testee = new PostgresTaskExecutionDetailsProjectionDAO(postgresExtension.getPostgresExecutor(), JSON_TASK_ADDITIONAL_INFORMATION_SERIALIZER); + testee = new PostgresTaskExecutionDetailsProjectionDAO(postgresExtension.getDefaultPostgresExecutor(), JSON_TASK_ADDITIONAL_INFORMATION_SERIALIZER); } @Test diff --git a/server/task/task-postgres/src/test/java/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionTest.java b/server/task/task-postgres/src/test/java/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionTest.java index d64c0688d21..287c6c3d262 100644 --- a/server/task/task-postgres/src/test/java/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionTest.java +++ b/server/task/task-postgres/src/test/java/org/apache/james/task/eventsourcing/postgres/PostgresTaskExecutionDetailsProjectionTest.java @@ -39,7 +39,7 @@ class PostgresTaskExecutionDetailsProjectionTest implements TaskExecutionDetails @BeforeEach void setUp() { - PostgresTaskExecutionDetailsProjectionDAO dao = new PostgresTaskExecutionDetailsProjectionDAO(postgresExtension.getPostgresExecutor(), + PostgresTaskExecutionDetailsProjectionDAO dao = new PostgresTaskExecutionDetailsProjectionDAO(postgresExtension.getDefaultPostgresExecutor(), JSON_TASK_ADDITIONAL_INFORMATION_SERIALIZER); testeeSupplier = () -> new PostgresTaskExecutionDetailsProjection(dao); } From 7e0d57d752e54e79b5ef65d0a03c98fb51074ae1 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 12 Jun 2024 10:41:38 +0700 Subject: [PATCH 316/341] JAMES-2586 [UPGRADE] jooq 3.19.6 -> 3.19.9 --- backends-common/postgres/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backends-common/postgres/pom.xml b/backends-common/postgres/pom.xml index e969e220259..8507ce5f1f5 100644 --- a/backends-common/postgres/pom.xml +++ b/backends-common/postgres/pom.xml @@ -29,7 +29,7 @@ Apache James :: Backends Common :: Postgres - 3.19.6 + 3.19.9 1.0.4.RELEASE From 03d822170be762948cda4cc7889d12d004772cd7 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 12 Jun 2024 10:42:35 +0700 Subject: [PATCH 317/341] JAMES-2586 [UPGRADE] r2dbc.postgresql.version 1.0.4.RELEASE => 1.0.5.RELEASE --- backends-common/postgres/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backends-common/postgres/pom.xml b/backends-common/postgres/pom.xml index 8507ce5f1f5..3687a454ee5 100644 --- a/backends-common/postgres/pom.xml +++ b/backends-common/postgres/pom.xml @@ -30,7 +30,7 @@ 3.19.9 - 1.0.4.RELEASE + 1.0.5.RELEASE From b2740e1c8f0f0d09096120ad123b81d7e58ec915 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 12 Jun 2024 10:44:36 +0700 Subject: [PATCH 318/341] JAMES-2586 [UPGRADE] org.testcontainers:postgresql 1.19.1 -> 1.19.8 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index e06397d02db..ef0641bb80a 100644 --- a/pom.xml +++ b/pom.xml @@ -3002,7 +3002,7 @@ org.testcontainers postgresql - 1.19.1 + 1.19.8 org.testcontainers From 66535e284dccc9710e7a8955c79e47c937592fbb Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 12 Jun 2024 10:50:39 +0700 Subject: [PATCH 319/341] JAMES-2586 [UPGRADE] Postgres docker image 16.1 -> 16.3 --- .../org/apache/james/backends/postgres/PostgresFixture.java | 2 +- server/apps/postgres-app/docker-compose-distributed.yml | 2 +- server/apps/postgres-app/docker-compose.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresFixture.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresFixture.java index 5bc662b294b..c0c28758e75 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresFixture.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresFixture.java @@ -89,7 +89,7 @@ public String schema() { } } - String IMAGE = "postgres:16.1"; + String IMAGE = "postgres:16.3"; Integer PORT = POSTGRESQL_PORT; Supplier> PG_CONTAINER = () -> new PostgreSQLContainer<>(IMAGE) .withDatabaseName(DEFAULT_DATABASE.dbName()) diff --git a/server/apps/postgres-app/docker-compose-distributed.yml b/server/apps/postgres-app/docker-compose-distributed.yml index ddf5d3cc948..67d5df8c3be 100644 --- a/server/apps/postgres-app/docker-compose-distributed.yml +++ b/server/apps/postgres-app/docker-compose-distributed.yml @@ -49,7 +49,7 @@ services: - james postgres: - image: postgres:16.1 + image: postgres:16.3 container_name: postgres ports: - "5432:5432" diff --git a/server/apps/postgres-app/docker-compose.yml b/server/apps/postgres-app/docker-compose.yml index 2eabbe331bd..9fcef9e03c2 100644 --- a/server/apps/postgres-app/docker-compose.yml +++ b/server/apps/postgres-app/docker-compose.yml @@ -24,7 +24,7 @@ services: - ./sample-configuration/blob.properties:/root/conf/blob.properties postgres: - image: postgres:16.1 + image: postgres:16.3 ports: - "5432:5432" environment: From 2b16627926ed92e9f6399c7e4a61b65e0b7600d0 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Thu, 6 Jun 2024 06:12:38 +0700 Subject: [PATCH 320/341] JAMES-2586 - Update primaryKey constraint for Postgres mailbox_change and email_change --- .../james/jmap/postgres/change/PostgresEmailChangeModule.java | 2 +- .../james/jmap/postgres/change/PostgresMailboxChangeModule.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeModule.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeModule.java index 9324be3e451..442078212ac 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeModule.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresEmailChangeModule.java @@ -55,7 +55,7 @@ interface PostgresEmailChangeTable { .column(CREATED) .column(UPDATED) .column(DESTROYED) - .constraint(DSL.primaryKey(ACCOUNT_ID, STATE)))) + .constraint(DSL.primaryKey(ACCOUNT_ID, STATE, IS_SHARED)))) .supportsRowLevelSecurity() .build(); diff --git a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeModule.java b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeModule.java index 3d8a646c3b7..bf6851e97b8 100644 --- a/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeModule.java +++ b/server/data/data-jmap-postgres/src/main/java/org/apache/james/jmap/postgres/change/PostgresMailboxChangeModule.java @@ -57,7 +57,7 @@ interface PostgresMailboxChangeTable { .column(CREATED) .column(UPDATED) .column(DESTROYED) - .constraint(DSL.primaryKey(ACCOUNT_ID, STATE)))) + .constraint(DSL.primaryKey(ACCOUNT_ID, STATE, IS_SHARED)))) .supportsRowLevelSecurity() .build(); From be56a1647ce17a5002a949d2d28d884ef368c014 Mon Sep 17 00:00:00 2001 From: hung phan Date: Tue, 18 Jun 2024 16:12:16 +0700 Subject: [PATCH 321/341] JAMES-2586 (RLS) Optimize findNonPersonalMailboxes method in PostgresMailboxMapper -create new table MailboxMember (username, mailbox_id) -create dao for new table -create RLSSupportPostgresMailboxMapper to use MailboxMember -update bindings to use RLSSupportPostgresMailboxMapper in case rls is enabled --- .../postgres/PostgresConfiguration.java | 2 + .../PostgresMailboxSessionMapperFactory.java | 15 +++- .../postgres/mail/PostgresMailboxMapper.java | 19 ++-- .../mail/PostgresMailboxMemberDAO.java | 64 +++++++++++++ .../mail/PostgresMailboxMemberModule.java | 57 ++++++++++++ .../mail/RLSSupportPostgresMailboxMapper.java | 89 +++++++++++++++++++ .../postgres/mail/dao/PostgresMailboxDAO.java | 15 ++-- .../postgres/DeleteMessageListenerTest.java | 4 +- .../DeleteMessageListenerWithRLSTest.java | 4 +- .../PostgresMailboxManagerAttachmentTest.java | 4 +- .../PostgresMailboxManagerProvider.java | 4 +- .../postgres/PostgresTestSystemFixture.java | 4 +- ...sAnnotationMapperRowLevelSecurityTest.java | 4 +- ...gresMessageMapperRowLevelSecurityTest.java | 4 +- ...LSSupportPostgresMailboxMapperACLTest.java | 39 ++++++++ .../postgres/host/PostgresHostSystem.java | 4 +- .../james/PostgresJamesConfiguration.java | 30 ++++++- .../apache/james/PostgresJamesServerMain.java | 9 ++ .../RLSSupportPostgresMailboxModule.java | 34 +++++++ .../modules/data/PostgresCommonModule.java | 2 +- 20 files changed, 378 insertions(+), 29 deletions(-) create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMemberDAO.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMemberModule.java create mode 100644 mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/RLSSupportPostgresMailboxMapper.java create mode 100644 mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/RLSSupportPostgresMailboxMapperACLTest.java create mode 100644 server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/RLSSupportPostgresMailboxModule.java diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java index ed765ec82d5..e66e452a00f 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java @@ -32,6 +32,8 @@ import io.r2dbc.postgresql.client.SSLMode; public class PostgresConfiguration { + public static final String POSTGRES_CONFIGURATION_NAME = "postgres"; + public static final String DATABASE_NAME = "database.name"; public static final String DATABASE_NAME_DEFAULT_VALUE = "postgres"; public static final String DATABASE_SCHEMA = "database.schema"; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java index ada3421abd9..5a5035f15d6 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java @@ -22,6 +22,7 @@ import jakarta.inject.Inject; +import org.apache.james.backends.postgres.PostgresConfiguration; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.blob.api.BlobId; import org.apache.james.blob.api.BlobStore; @@ -29,10 +30,12 @@ import org.apache.james.mailbox.postgres.mail.PostgresAnnotationMapper; import org.apache.james.mailbox.postgres.mail.PostgresAttachmentMapper; import org.apache.james.mailbox.postgres.mail.PostgresMailboxMapper; +import org.apache.james.mailbox.postgres.mail.PostgresMailboxMemberDAO; import org.apache.james.mailbox.postgres.mail.PostgresMessageIdMapper; import org.apache.james.mailbox.postgres.mail.PostgresMessageMapper; import org.apache.james.mailbox.postgres.mail.PostgresModSeqProvider; import org.apache.james.mailbox.postgres.mail.PostgresUidProvider; +import org.apache.james.mailbox.postgres.mail.RLSSupportPostgresMailboxMapper; import org.apache.james.mailbox.postgres.mail.dao.PostgresAttachmentDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxAnnotationDAO; import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; @@ -57,22 +60,30 @@ public class PostgresMailboxSessionMapperFactory extends MailboxSessionMapperFac private final BlobStore blobStore; private final BlobId.Factory blobIdFactory; private final Clock clock; + private final boolean isRLSEnabled; @Inject public PostgresMailboxSessionMapperFactory(PostgresExecutor.Factory executorFactory, Clock clock, BlobStore blobStore, - BlobId.Factory blobIdFactory) { + BlobId.Factory blobIdFactory, + PostgresConfiguration postgresConfiguration) { this.executorFactory = executorFactory; this.blobStore = blobStore; this.blobIdFactory = blobIdFactory; this.clock = clock; + this.isRLSEnabled = postgresConfiguration.rowLevelSecurityEnabled(); } @Override public MailboxMapper createMailboxMapper(MailboxSession session) { PostgresMailboxDAO mailboxDAO = new PostgresMailboxDAO(executorFactory.create(session.getUser().getDomainPart())); - return new PostgresMailboxMapper(mailboxDAO); + if (isRLSEnabled) { + return new RLSSupportPostgresMailboxMapper(mailboxDAO, + new PostgresMailboxMemberDAO(executorFactory.create(session.getUser().getDomainPart()))); + } else { + return new PostgresMailboxMapper(mailboxDAO); + } } @Override diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java index 8d0c0d52662..3ef1ce20368 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java @@ -21,8 +21,6 @@ import java.util.function.Function; -import jakarta.inject.Inject; - import org.apache.james.core.Username; import org.apache.james.mailbox.acl.ACLDiff; import org.apache.james.mailbox.model.Mailbox; @@ -42,7 +40,6 @@ public class PostgresMailboxMapper implements MailboxMapper { private final PostgresMailboxDAO postgresMailboxDAO; - @Inject public PostgresMailboxMapper(PostgresMailboxDAO postgresMailboxDAO) { this.postgresMailboxDAO = postgresMailboxDAO; } @@ -102,20 +99,20 @@ public Mono updateACL(Mailbox mailbox, MailboxACL.ACLCommand mailboxACL MailboxACL oldACL = mailbox.getACL(); MailboxACL newACL = Throwing.supplier(() -> oldACL.apply(mailboxACLCommand)).get(); return postgresMailboxDAO.upsertACL(mailbox.getMailboxId(), newACL) - .map(updatedACL -> { - mailbox.setACL(updatedACL); - return ACLDiff.computeDiff(oldACL, updatedACL); - }); + .then(Mono.fromCallable(() -> { + mailbox.setACL(newACL); + return ACLDiff.computeDiff(oldACL, newACL); + })); } @Override public Mono setACL(Mailbox mailbox, MailboxACL mailboxACL) { MailboxACL oldACL = mailbox.getACL(); return postgresMailboxDAO.upsertACL(mailbox.getMailboxId(), mailboxACL) - .map(updatedACL -> { - mailbox.setACL(updatedACL); - return ACLDiff.computeDiff(oldACL, updatedACL); - }); + .then(Mono.fromCallable(() -> { + mailbox.setACL(mailboxACL); + return ACLDiff.computeDiff(oldACL, mailboxACL); + })); } } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMemberDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMemberDAO.java new file mode 100644 index 00000000000..fe87e1f32ba --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMemberDAO.java @@ -0,0 +1,64 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import static org.apache.james.mailbox.postgres.mail.PostgresMailboxMemberModule.PostgresMailboxMemberTable.MAILBOX_ID; +import static org.apache.james.mailbox.postgres.mail.PostgresMailboxMemberModule.PostgresMailboxMemberTable.TABLE_NAME; +import static org.apache.james.mailbox.postgres.mail.PostgresMailboxMemberModule.PostgresMailboxMemberTable.USER_NAME; + +import java.util.List; + +import org.apache.james.backends.postgres.utils.PostgresExecutor; +import org.apache.james.core.Username; +import org.apache.james.mailbox.postgres.PostgresMailboxId; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class PostgresMailboxMemberDAO { + private final PostgresExecutor postgresExecutor; + + public PostgresMailboxMemberDAO(PostgresExecutor postgresExecutor) { + this.postgresExecutor = postgresExecutor; + } + + public Flux findMailboxIdByUsername(Username username) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(MAILBOX_ID) + .from(TABLE_NAME) + .where(USER_NAME.eq(username.asString())))) + .map(record -> PostgresMailboxId.of(record.get(MAILBOX_ID))); + } + + public Mono insert(PostgresMailboxId mailboxId, List usernames) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.batch(usernames.stream() //TODO check issue: batch does not throw exception + .map(username -> dslContext.insertInto(TABLE_NAME) + .set(USER_NAME, username.asString()) + .set(MAILBOX_ID, mailboxId.asUuid())) + .toList()))); + } + + public Mono delete(PostgresMailboxId mailboxId, List usernames) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.batch(usernames.stream() + .map(username -> dslContext.deleteFrom(TABLE_NAME) + .where(USER_NAME.eq(username.asString()) + .and(MAILBOX_ID.eq(mailboxId.asUuid())))) + .toList()))); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMemberModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMemberModule.java new file mode 100644 index 00000000000..abcd3bfde3e --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMemberModule.java @@ -0,0 +1,57 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import java.util.UUID; + +import org.apache.james.backends.postgres.PostgresIndex; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.backends.postgres.PostgresTable; +import org.jooq.Field; +import org.jooq.Record; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; + +public interface PostgresMailboxMemberModule { + interface PostgresMailboxMemberTable { + Table TABLE_NAME = DSL.table("mailbox_member"); + + Field USER_NAME = DSL.field("user_name", SQLDataType.VARCHAR(255)); + Field MAILBOX_ID = DSL.field("mailbox_id", SQLDataType.UUID.notNull()); + + PostgresTable TABLE = PostgresTable.name(TABLE_NAME.getName()) + .createTableStep(((dsl, tableName) -> dsl.createTableIfNotExists(tableName) + .column(USER_NAME) + .column(MAILBOX_ID) + .constraint(DSL.primaryKey(USER_NAME, MAILBOX_ID)))) + .supportsRowLevelSecurity() + .build(); + + PostgresIndex MAILBOX_MEMBER_USERNAME_INDEX = PostgresIndex.name("mailbox_member_username_index") + .createIndexStep((dsl, indexName) -> dsl.createIndexIfNotExists(indexName) + .on(TABLE_NAME, USER_NAME)); + } + + PostgresModule MODULE = PostgresModule.builder() + .addTable(PostgresMailboxMemberTable.TABLE) + .addIndex(PostgresMailboxMemberTable.MAILBOX_MEMBER_USERNAME_INDEX) + .build(); +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/RLSSupportPostgresMailboxMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/RLSSupportPostgresMailboxMapper.java new file mode 100644 index 00000000000..214ab99cebd --- /dev/null +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/RLSSupportPostgresMailboxMapper.java @@ -0,0 +1,89 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import java.util.function.Function; + +import org.apache.james.core.Username; +import org.apache.james.mailbox.acl.ACLDiff; +import org.apache.james.mailbox.acl.PositiveUserACLDiff; +import org.apache.james.mailbox.model.Mailbox; +import org.apache.james.mailbox.model.MailboxACL; +import org.apache.james.mailbox.postgres.PostgresMailboxId; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; + +import com.github.fge.lambdas.Throwing; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +public class RLSSupportPostgresMailboxMapper extends PostgresMailboxMapper { + private final PostgresMailboxDAO postgresMailboxDAO; + private final PostgresMailboxMemberDAO postgresMailboxMemberDAO; + + public RLSSupportPostgresMailboxMapper(PostgresMailboxDAO postgresMailboxDAO, PostgresMailboxMemberDAO postgresMailboxMemberDAO) { + super(postgresMailboxDAO); + this.postgresMailboxDAO = postgresMailboxDAO; + this.postgresMailboxMemberDAO = postgresMailboxMemberDAO; + } + + @Override + public Flux findNonPersonalMailboxes(Username userName, MailboxACL.Right right) { + return postgresMailboxMemberDAO.findMailboxIdByUsername(userName) + .collectList() + .filter(postgresMailboxIds -> !postgresMailboxIds.isEmpty()) + .flatMapMany(postgresMailboxDAO::findMailboxByIds) + .filter(postgresMailbox -> postgresMailbox.getACL().getEntries().get(MailboxACL.EntryKey.createUserEntryKey(userName)).contains(right)) + .map(Function.identity()); + } + + @Override + public Mono updateACL(Mailbox mailbox, MailboxACL.ACLCommand mailboxACLCommand) { + MailboxACL oldACL = mailbox.getACL(); + MailboxACL newACL = Throwing.supplier(() -> oldACL.apply(mailboxACLCommand)).get(); + ACLDiff aclDiff = ACLDiff.computeDiff(oldACL, newACL); + PositiveUserACLDiff userACLDiff = new PositiveUserACLDiff(aclDiff); + return postgresMailboxDAO.upsertACL(mailbox.getMailboxId(), newACL) + .then(postgresMailboxMemberDAO.delete(PostgresMailboxId.class.cast(mailbox.getMailboxId()), + userACLDiff.removedEntries().map(entry -> Username.of(entry.getKey().getName())).toList())) + .then(postgresMailboxMemberDAO.insert(PostgresMailboxId.class.cast(mailbox.getMailboxId()), + userACLDiff.addedEntries().map(entry -> Username.of(entry.getKey().getName())).toList())) + .then(Mono.fromCallable(() -> { + mailbox.setACL(newACL); + return aclDiff; + })); + } + + @Override + public Mono setACL(Mailbox mailbox, MailboxACL mailboxACL) { + MailboxACL oldACL = mailbox.getACL(); + ACLDiff aclDiff = ACLDiff.computeDiff(oldACL, mailboxACL); + PositiveUserACLDiff userACLDiff = new PositiveUserACLDiff(aclDiff); + return postgresMailboxDAO.upsertACL(mailbox.getMailboxId(), mailboxACL) + .then(postgresMailboxMemberDAO.delete(PostgresMailboxId.class.cast(mailbox.getMailboxId()), + userACLDiff.removedEntries().map(entry -> Username.of(entry.getKey().getName())).toList())) + .then(postgresMailboxMemberDAO.insert(PostgresMailboxId.class.cast(mailbox.getMailboxId()), + userACLDiff.addedEntries().map(entry -> Username.of(entry.getKey().getName())).toList())) + .then(Mono.fromCallable(() -> { + mailbox.setACL(mailboxACL); + return aclDiff; + })); + } +} diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java index 89fbc929c31..5d5948afb80 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java @@ -32,6 +32,7 @@ import static org.jooq.impl.DSL.coalesce; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -146,12 +147,10 @@ private Mono update(Mailbox mailbox) { .switchIfEmpty(Mono.error(new MailboxNotFoundException(mailbox.getMailboxId()))); } - public Mono upsertACL(MailboxId mailboxId, MailboxACL acl) { - return postgresExecutor.executeRow(dslContext -> Mono.from(dslContext.update(TABLE_NAME) + public Mono upsertACL(MailboxId mailboxId, MailboxACL acl) { + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.update(TABLE_NAME) .set(MAILBOX_ACL, MAILBOX_ACL_TO_HSTORE_FUNCTION.apply(acl)) - .where(MAILBOX_ID.eq(((PostgresMailboxId) mailboxId).asUuid())) - .returning(MAILBOX_ACL))) - .map(record -> HSTORE_TO_MAILBOX_ACL_FUNCTION.apply(record.get(MAILBOX_ACL))); + .where(MAILBOX_ID.eq(((PostgresMailboxId) mailboxId).asUuid())))); //TODO check if update is success } public Flux findNonPersonalMailboxes(Username userName, MailboxACL.Right right) { @@ -184,6 +183,12 @@ public Mono findMailboxById(MailboxId id) { .switchIfEmpty(Mono.error(new MailboxNotFoundException(id))); } + public Flux findMailboxByIds(List mailboxIds) { + return postgresExecutor.executeRows(dsl -> Flux.from(dsl.selectFrom(TABLE_NAME) + .where(MAILBOX_ID.in(mailboxIds.stream().map(PostgresMailboxId::asUuid).toList())))) + .map(RECORD_TO_POSTGRES_MAILBOX_FUNCTION); + } + public Flux findMailboxWithPathLike(MailboxQuery.UserBound query) { String pathLike = MailboxExpressionBackwardCompatibility.getPathLike(query); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java index b8f2ad14bba..678ae41cbb7 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerTest.java @@ -24,6 +24,7 @@ import java.time.Clock; import java.time.Instant; +import org.apache.james.backends.postgres.PostgresConfiguration; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.blob.api.BlobStore; import org.apache.james.blob.api.BucketName; @@ -68,7 +69,8 @@ static void beforeAll() { postgresExtension.getExecutorFactory(), Clock.systemUTC(), blobStore, - BLOB_ID_FACTORY); + BLOB_ID_FACTORY, + PostgresConfiguration.builder().username("a").password("a").build()); MailboxACLResolver aclResolver = new UnionMailboxACLResolver(); MessageParser messageParser = new MessageParser(); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java index 8f92ab1990c..683521246d8 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/DeleteMessageListenerWithRLSTest.java @@ -25,6 +25,7 @@ import java.time.Instant; import java.util.UUID; +import org.apache.james.backends.postgres.PostgresConfiguration; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.blob.api.BlobId; import org.apache.james.blob.api.BlobStore; @@ -74,7 +75,8 @@ static void beforeAll() { postgresExtension.getExecutorFactory(), Clock.systemUTC(), blobStore, - blobIdFactory); + blobIdFactory, + PostgresConfiguration.builder().username("a").password("a").build()); MailboxACLResolver aclResolver = new UnionMailboxACLResolver(); MessageParser messageParser = new MessageParser(); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerAttachmentTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerAttachmentTest.java index 1ce1613e010..75dac52c492 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerAttachmentTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerAttachmentTest.java @@ -27,6 +27,7 @@ import java.time.Clock; import java.time.Instant; +import org.apache.james.backends.postgres.PostgresConfiguration; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.blob.api.BlobId; import org.apache.james.blob.api.BucketName; @@ -81,7 +82,8 @@ public class PostgresMailboxManagerAttachmentTest extends AbstractMailboxManager void beforeAll() throws Exception { BlobId.Factory BLOB_ID_FACTORY = new HashBlobId.Factory(); DeDuplicationBlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, BLOB_ID_FACTORY); - mapperFactory = new PostgresMailboxSessionMapperFactory(postgresExtension.getExecutorFactory(), Clock.systemUTC(), blobStore, BLOB_ID_FACTORY); + mapperFactory = new PostgresMailboxSessionMapperFactory(postgresExtension.getExecutorFactory(), Clock.systemUTC(), blobStore, BLOB_ID_FACTORY, + PostgresConfiguration.builder().username("a").password("a").build()); MailboxACLResolver aclResolver = new UnionMailboxACLResolver(); MessageParser messageParser = new MessageParser(); diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java index 2736d19ce38..5eea9180563 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerProvider.java @@ -22,6 +22,7 @@ import java.time.Clock; import java.time.Instant; +import org.apache.james.backends.postgres.PostgresConfiguration; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.blob.api.BlobId; import org.apache.james.blob.api.BucketName; @@ -97,7 +98,8 @@ public static PostgresMailboxSessionMapperFactory provideMailboxSessionMapperFac postgresExtension.getExecutorFactory(), Clock.systemUTC(), blobStore, - blobIdFactory); + blobIdFactory, + PostgresConfiguration.builder().username("a").password("a").build()); } } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresTestSystemFixture.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresTestSystemFixture.java index 7b6dd4f7d64..0df8ef9dea8 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresTestSystemFixture.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresTestSystemFixture.java @@ -24,6 +24,7 @@ import java.time.Clock; import java.time.Instant; +import org.apache.james.backends.postgres.PostgresConfiguration; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.backends.postgres.quota.PostgresQuotaCurrentValueDAO; import org.apache.james.backends.postgres.quota.PostgresQuotaLimitDAO; @@ -67,7 +68,8 @@ public static PostgresMailboxSessionMapperFactory createMapperFactory(PostgresEx BlobId.Factory blobIdFactory = new HashBlobId.Factory(); DeDuplicationBlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory); - return new PostgresMailboxSessionMapperFactory(postgresExtension.getExecutorFactory(), Clock.systemUTC(), blobStore, blobIdFactory); + return new PostgresMailboxSessionMapperFactory(postgresExtension.getExecutorFactory(), Clock.systemUTC(), blobStore, blobIdFactory, + PostgresConfiguration.builder().username("a").password("a").build()); } public static PostgresMailboxManager createMailboxManager(PostgresMailboxSessionMapperFactory mapperFactory) { diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperRowLevelSecurityTest.java index f75f4ecaac6..b48c9235e29 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperRowLevelSecurityTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresAnnotationMapperRowLevelSecurityTest.java @@ -23,6 +23,7 @@ import java.time.Instant; +import org.apache.james.backends.postgres.PostgresConfiguration; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.blob.api.BlobId; @@ -75,7 +76,8 @@ public void setUp() { postgresMailboxSessionMapperFactory = new PostgresMailboxSessionMapperFactory(postgresExtension.getExecutorFactory(), new UpdatableTickingClock(Instant.now()), new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory), - blobIdFactory); + blobIdFactory, + PostgresConfiguration.builder().username("a").password("a").build()); mailboxId = generateMailboxId(); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java index c7677b724b4..3d3ba1e7020 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMessageMapperRowLevelSecurityTest.java @@ -26,6 +26,7 @@ import jakarta.mail.Flags; +import org.apache.james.backends.postgres.PostgresConfiguration; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.blob.api.BlobId; import org.apache.james.blob.api.BucketName; @@ -80,7 +81,8 @@ public void setUp() { postgresMailboxSessionMapperFactory = new PostgresMailboxSessionMapperFactory(postgresExtension.getExecutorFactory(), new UpdatableTickingClock(Instant.now()), new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory), - blobIdFactory); + blobIdFactory, + PostgresConfiguration.builder().username("a").password("a").build()); mailbox = generateMailbox(); } diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/RLSSupportPostgresMailboxMapperACLTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/RLSSupportPostgresMailboxMapperACLTest.java new file mode 100644 index 00000000000..b42bc21cad4 --- /dev/null +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/RLSSupportPostgresMailboxMapperACLTest.java @@ -0,0 +1,39 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.mailbox.postgres.mail; + +import org.apache.james.backends.postgres.PostgresExtension; +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.mailbox.postgres.mail.dao.PostgresMailboxDAO; +import org.apache.james.mailbox.store.mail.MailboxMapper; +import org.apache.james.mailbox.store.mail.model.MailboxMapperACLTest; +import org.junit.jupiter.api.extension.RegisterExtension; + +class RLSSupportPostgresMailboxMapperACLTest extends MailboxMapperACLTest { + @RegisterExtension + static PostgresExtension postgresExtension = PostgresExtension.withoutRowLevelSecurity(PostgresModule.aggregateModules(PostgresMailboxModule.MODULE, + PostgresMailboxMemberModule.MODULE)); + + @Override + protected MailboxMapper createMailboxMapper() { + return new RLSSupportPostgresMailboxMapper(new PostgresMailboxDAO(postgresExtension.getPostgresExecutor()), + new PostgresMailboxMemberDAO(postgresExtension.getPostgresExecutor())); + } +} diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java index daa8378d461..8424925d44d 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java @@ -22,6 +22,7 @@ import java.time.Clock; import java.time.Instant; +import org.apache.james.backends.postgres.PostgresConfiguration; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.backends.postgres.quota.PostgresQuotaCurrentValueDAO; import org.apache.james.backends.postgres.quota.PostgresQuotaLimitDAO; @@ -108,7 +109,8 @@ public void beforeTest() throws Exception { BlobId.Factory blobIdFactory = new HashBlobId.Factory(); DeDuplicationBlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory); - PostgresMailboxSessionMapperFactory mapperFactory = new PostgresMailboxSessionMapperFactory(postgresExtension.getExecutorFactory(), Clock.systemUTC(), blobStore, blobIdFactory); + PostgresMailboxSessionMapperFactory mapperFactory = new PostgresMailboxSessionMapperFactory(postgresExtension.getExecutorFactory(), Clock.systemUTC(), blobStore, blobIdFactory, + PostgresConfiguration.builder().username("a").password("a").build()); MailboxACLResolver aclResolver = new UnionMailboxACLResolver(); MessageParser messageParser = new MessageParser(); diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java index 4bf98c55ead..ba5e58516a5 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java @@ -24,6 +24,7 @@ import java.util.Optional; import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.james.backends.postgres.PostgresConfiguration; import org.apache.james.data.UsersRepositoryModuleChooser; import org.apache.james.filesystem.api.FileSystem; import org.apache.james.filesystem.api.JamesDirectoriesProvider; @@ -75,6 +76,7 @@ public static class Builder { private Optional deletedMessageVaultConfiguration; private Optional jmapEnabled; private Optional dropListsEnabled; + private Optional rlsEnabled; private Builder() { searchConfiguration = Optional.empty(); @@ -86,6 +88,7 @@ private Builder() { deletedMessageVaultConfiguration = Optional.empty(); jmapEnabled = Optional.empty(); dropListsEnabled = Optional.empty(); + rlsEnabled = Optional.empty(); } public Builder workingDirectory(String path) { @@ -151,6 +154,11 @@ public Builder enableDropLists() { return this; } + public Builder rlsEnabled(Optional rlsEnabled) { + this.rlsEnabled = rlsEnabled; + return this; + } + public PostgresJamesConfiguration build() { ConfigurationPath configurationPath = this.configurationPath.orElse(new ConfigurationPath(FileSystem.FILE_PROTOCOL_AND_CONF)); JamesServerResourceLoader directories = new JamesServerResourceLoader(rootDirectory @@ -186,6 +194,8 @@ public PostgresJamesConfiguration build() { } }); + boolean rlsEnabled = this.rlsEnabled.orElse(readRLSEnabledFromFile(propertiesProvider)); + boolean jmapEnabled = this.jmapEnabled.orElseGet(() -> { try { return JMAPModule.parseConfiguration(propertiesProvider).isEnabled(); @@ -214,7 +224,16 @@ public PostgresJamesConfiguration build() { eventBusImpl, deletedMessageVaultConfiguration, jmapEnabled, - dropListsEnabled); + dropListsEnabled, + rlsEnabled); + } + + private boolean readRLSEnabledFromFile(PropertiesProvider propertiesProvider) { + try { + return PostgresConfiguration.from(propertiesProvider.getConfiguration(PostgresConfiguration.POSTGRES_CONFIGURATION_NAME)).rowLevelSecurityEnabled(); + } catch (FileNotFoundException | ConfigurationException e) { + return false; + } } } @@ -231,6 +250,7 @@ public static Builder builder() { private final VaultConfiguration deletedMessageVaultConfiguration; private final boolean jmapEnabled; private final boolean dropListsEnabled; + private final boolean rlsEnabled; private PostgresJamesConfiguration(ConfigurationPath configurationPath, JamesDirectoriesProvider directories, @@ -240,7 +260,8 @@ private PostgresJamesConfiguration(ConfigurationPath configurationPath, EventBusImpl eventBusImpl, VaultConfiguration deletedMessageVaultConfiguration, boolean jmapEnabled, - boolean dropListsEnabled) { + boolean dropListsEnabled, + boolean rlsEnabled) { this.configurationPath = configurationPath; this.directories = directories; this.searchConfiguration = searchConfiguration; @@ -250,6 +271,7 @@ private PostgresJamesConfiguration(ConfigurationPath configurationPath, this.deletedMessageVaultConfiguration = deletedMessageVaultConfiguration; this.jmapEnabled = jmapEnabled; this.dropListsEnabled = dropListsEnabled; + this.rlsEnabled = rlsEnabled; } @Override @@ -289,4 +311,8 @@ public boolean isJmapEnabled() { public boolean isDropListsEnabled() { return dropListsEnabled; } + + public boolean isRlsEnabled() { + return rlsEnabled; + } } diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java index 774d68705a5..75bf39d9461 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesServerMain.java @@ -51,6 +51,7 @@ import org.apache.james.modules.mailbox.DefaultEventModule; import org.apache.james.modules.mailbox.PostgresDeletedMessageVaultModule; import org.apache.james.modules.mailbox.PostgresMailboxModule; +import org.apache.james.modules.mailbox.RLSSupportPostgresMailboxModule; import org.apache.james.modules.mailbox.TikaMailboxModule; import org.apache.james.modules.plugins.QuotaMailingModule; import org.apache.james.modules.protocols.IMAPServerModule; @@ -187,6 +188,7 @@ public static GuiceJamesServer createServer(PostgresJamesConfiguration configura .combineWith(chooseUsersRepositoryModule(configuration)) .combineWith(chooseBlobStoreModules(configuration)) .combineWith(chooseDeletedMessageVaultModules(configuration.getDeletedMessageVaultConfiguration())) + .combineWith(chooseRLSSupportPostgresMailboxModule(configuration)) .overrideWith(chooseJmapModules(configuration)) .overrideWith(chooseTaskManagerModules(configuration)) .overrideWith(chooseDropListsModule(configuration)); @@ -260,4 +262,11 @@ private static Module chooseDropListsModule(PostgresJamesConfiguration configura }; } + + private static Module chooseRLSSupportPostgresMailboxModule(PostgresJamesConfiguration configuration) { + if (configuration.isRlsEnabled()) { + return new RLSSupportPostgresMailboxModule(); + } + return Modules.EMPTY_MODULE; + } } diff --git a/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/RLSSupportPostgresMailboxModule.java b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/RLSSupportPostgresMailboxModule.java new file mode 100644 index 00000000000..0217fa0d107 --- /dev/null +++ b/server/container/guice/mailbox-postgres/src/main/java/org/apache/james/modules/mailbox/RLSSupportPostgresMailboxModule.java @@ -0,0 +1,34 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.modules.mailbox; + +import org.apache.james.backends.postgres.PostgresModule; +import org.apache.james.mailbox.postgres.mail.PostgresMailboxMemberModule; + +import com.google.inject.AbstractModule; +import com.google.inject.multibindings.Multibinder; + +public class RLSSupportPostgresMailboxModule extends AbstractModule { + @Override + protected void configure() { + Multibinder postgresDataDefinitions = Multibinder.newSetBinder(binder(), PostgresModule.class); + postgresDataDefinitions.addBinding().toInstance(PostgresMailboxMemberModule.MODULE); + } +} diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index 9573d65d538..c2b87b70189 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -71,7 +71,7 @@ public void configure() { @Provides @Singleton PostgresConfiguration provideConfiguration(PropertiesProvider propertiesProvider) throws FileNotFoundException, ConfigurationException { - return PostgresConfiguration.from(propertiesProvider.getConfiguration("postgres")); + return PostgresConfiguration.from(propertiesProvider.getConfiguration(PostgresConfiguration.POSTGRES_CONFIGURATION_NAME)); } @Provides From 77256925a15e74e994259d1e4ad68676372361f4 Mon Sep 17 00:00:00 2001 From: hung phan Date: Tue, 18 Jun 2024 17:37:12 +0700 Subject: [PATCH 322/341] JAMES-2586 (NON_RLS) Optimize findNonPersonalMailboxes method in PostgresMailboxMapper - create index for mailbox_acl column in case rls is disabled - update findNonPersonalMailboxes method in PostgresMailboxMapper and PostgresMailboxDAO --- .../backends/postgres/PostgresTable.java | 44 ++++++++++++++-- .../postgres/PostgresTableManager.java | 23 ++++++++ .../postgres/PostgresTableManagerTest.java | 52 +++++++++++++++++++ .../postgres/mail/PostgresMailboxMapper.java | 4 +- .../postgres/mail/PostgresMailboxModule.java | 2 + .../postgres/mail/dao/PostgresMailboxDAO.java | 22 +++++--- 6 files changed, 134 insertions(+), 13 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java index db37fcdf9d8..d969da6b78c 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java @@ -19,6 +19,7 @@ package org.apache.james.backends.postgres; +import java.util.Arrays; import java.util.List; import java.util.function.Function; @@ -52,11 +53,22 @@ default FinalStage supportsRowLevelSecurity() { } } + public enum SupportCase { + RLS, NON_RLS, ALL + } + + public record AdditionalAlterQuery(String query, + SupportCase supportCase) { + public AdditionalAlterQuery(String query) { + this(query, SupportCase.ALL); + } + } + public static class FinalStage { private final String tableName; private final boolean supportsRowLevelSecurity; private final Function createTableStepFunction; - private final ImmutableList.Builder additionalAlterQueries; + private final ImmutableList.Builder additionalAlterQueries; public FinalStage(String tableName, boolean supportsRowLevelSecurity, Function createTableStepFunction) { this.tableName = tableName; @@ -65,10 +77,34 @@ public FinalStage(String tableName, boolean supportsRowLevelSecurity, Function createTableStepFunction; - private final List additionalAlterQueries; + private final List additionalAlterQueries; - private PostgresTable(String name, boolean supportsRowLevelSecurity, Function createTableStepFunction, List additionalAlterQueries) { + private PostgresTable(String name, boolean supportsRowLevelSecurity, Function createTableStepFunction, List additionalAlterQueries) { this.name = name; this.supportsRowLevelSecurity = supportsRowLevelSecurity; this.createTableStepFunction = createTableStepFunction; @@ -110,7 +146,7 @@ public boolean supportsRowLevelSecurity() { return supportsRowLevelSecurity; } - public List getAdditionalAlterQueries() { + public List getAdditionalAlterQueries() { return additionalAlterQueries; } } diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java index fcc0175c0ac..112aa28c823 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java @@ -123,6 +123,8 @@ private Mono alterTableIfNeeded(PostgresTable table, Connection connection private Mono executeAdditionalAlterQueries(PostgresTable table, Connection connection) { return Flux.fromIterable(table.getAdditionalAlterQueries()) + .filter(this::isApplied) + .map(PostgresTable.AdditionalAlterQuery::query) .concatMap(alterSQLQuery -> Mono.just(connection) .flatMapMany(pgConnection -> pgConnection.createStatement(alterSQLQuery) .execute()) @@ -138,6 +140,27 @@ private Mono executeAdditionalAlterQueries(PostgresTable table, Connection .then(); } + private boolean isApplied(PostgresTable.AdditionalAlterQuery additionalAlterQuery) { + switch (additionalAlterQuery.supportCase()) { + case RLS: + if (rowLevelSecurityEnabled) { + return true; + } else { + return false; + } + case NON_RLS: + if (!rowLevelSecurityEnabled) { + return true; + } else { + return false; + } + case ALL: + return true; + default: + throw new UnsupportedOperationException(); + } + } + private Mono enableRLSIfNeeded(PostgresTable table, Connection connection) { if (rowLevelSecurityEnabled && table.supportsRowLevelSecurity()) { return alterTableEnableRLS(table, connection); diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java index dd4a31e8aad..b58cd375c65 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java @@ -398,6 +398,58 @@ void additionalAlterQueryToCreateConstraintShouldSucceed() { assertThat(constraintExists).isTrue(); } + @Test + void additionalAlterQueryToCreateConstraintShouldSucceedWhenSupportCaseIsNonRLSAndRLSIsDisabled() { + String constraintName = "exclude_constraint"; + PostgresTable table = PostgresTable.name("tbn1") + .createTableStep((dsl, tbn) -> dsl.createTable(tbn) + .column("clm1", SQLDataType.UUID.notNull()) + .column("clm2", SQLDataType.VARCHAR(255).notNull())) + .disableRowLevelSecurity() + .addAdditionalAlterQuery("ALTER TABLE tbn1 ADD CONSTRAINT " + constraintName + " EXCLUDE (clm2 WITH =)", PostgresTable.SupportCase.NON_RLS) + .build(); + PostgresModule module = PostgresModule.table(table); + PostgresTableManager testee = new PostgresTableManager(postgresExtension.getPostgresExecutor(), module, false); + + testee.initializeTables().block(); + + boolean constraintExists = postgresExtension.getConnection() + .flatMapMany(connection -> connection.createStatement("SELECT EXISTS(SELECT 1 FROM pg_catalog.pg_constraint WHERE conname = $1) AS constraint_exists;") + .bind("$1", constraintName) + .execute()) + .flatMap(result -> result.map((row, rowMetaData) -> row.get("constraint_exists", Boolean.class))) + .single() + .block(); + + assertThat(constraintExists).isTrue(); + } + + @Test + void additionalAlterQueryToCreateConstraintShouldNotBeExecutedWhenSupportCaseIsNonRLSAndRLSIsEnabled() { + String constraintName = "exclude_constraint"; + PostgresTable table = PostgresTable.name("tbn1") + .createTableStep((dsl, tbn) -> dsl.createTable(tbn) + .column("clm1", SQLDataType.UUID.notNull()) + .column("clm2", SQLDataType.VARCHAR(255).notNull())) + .disableRowLevelSecurity() + .addAdditionalAlterQuery("ALTER TABLE tbn1 ADD CONSTRAINT " + constraintName + " EXCLUDE (clm2 WITH =)", PostgresTable.SupportCase.NON_RLS) + .build(); + PostgresModule module = PostgresModule.table(table); + PostgresTableManager testee = new PostgresTableManager(postgresExtension.getPostgresExecutor(), module, true); + + testee.initializeTables().block(); + + boolean constraintExists = postgresExtension.getConnection() + .flatMapMany(connection -> connection.createStatement("SELECT EXISTS(SELECT 1 FROM pg_catalog.pg_constraint WHERE conname = $1) AS constraint_exists;") + .bind("$1", constraintName) + .execute()) + .flatMap(result -> result.map((row, rowMetaData) -> row.get("constraint_exists", Boolean.class))) + .single() + .block(); + + assertThat(constraintExists).isFalse(); + } + @Test void additionalAlterQueryToReCreateConstraintShouldNotThrow() { String constraintName = "exclude_constraint"; diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java index 3ef1ce20368..8e991a0010f 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java @@ -88,9 +88,9 @@ public Flux list() { .map(Function.identity()); } - @Override public Flux findNonPersonalMailboxes(Username userName, MailboxACL.Right right) { - return postgresMailboxDAO.findNonPersonalMailboxes(userName, right) + return postgresMailboxDAO.findMailboxesByUsername(userName) + .filter(postgresMailbox -> postgresMailbox.getACL().getEntries().get(MailboxACL.EntryKey.createUserEntryKey(userName)).contains(right)) .map(Function.identity()); } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java index 68a02534134..4ecbd7e6042 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java @@ -63,6 +63,8 @@ interface PostgresMailboxTable { .constraint(DSL.primaryKey(MAILBOX_ID)) .constraint(DSL.constraint(MAILBOX_NAME_USER_NAME_NAMESPACE_UNIQUE_CONSTRAINT).unique(MAILBOX_NAME, USER_NAME, MAILBOX_NAMESPACE)))) .supportsRowLevelSecurity() + .addAdditionalAlterQuery("CREATE INDEX mailbox_mailbox_acl_index ON " + TABLE_NAME.getName() + " USING GIN (" + MAILBOX_ACL.getName() + ")", + PostgresTable.SupportCase.NON_RLS) .build(); PostgresIndex MAILBOX_USERNAME_NAMESPACE_INDEX = PostgresIndex.name("mailbox_username_namespace_index") .createIndexStep((dsl, indexName) -> dsl.createIndexIfNotExists(indexName) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java index 5d5948afb80..b0ca432e724 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java @@ -58,6 +58,7 @@ import org.apache.james.mailbox.store.MailboxExpressionBackwardCompatibility; import org.jooq.Record; import org.jooq.impl.DSL; +import org.jooq.impl.DefaultDataType; import org.jooq.postgres.extensions.types.Hstore; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -153,13 +154,20 @@ public Mono upsertACL(MailboxId mailboxId, MailboxACL acl) { .where(MAILBOX_ID.eq(((PostgresMailboxId) mailboxId).asUuid())))); //TODO check if update is success } - public Flux findNonPersonalMailboxes(Username userName, MailboxACL.Right right) { - String mailboxACLEntryByUser = String.format("mailbox_acl -> '%s'", userName.asString()); - - return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.selectFrom(TABLE_NAME) - .where(MAILBOX_ACL.isNotNull(), - DSL.field(mailboxACLEntryByUser).isNotNull(), - DSL.field(mailboxACLEntryByUser).contains(Character.toString(right.asCharacter()))))) + public Flux findMailboxesByUsername(Username userName) { + return postgresExecutor.executeRows(dslContext -> Flux.from(dslContext.select(MAILBOX_ID, + MAILBOX_NAME, + MAILBOX_UID_VALIDITY, + USER_NAME, + MAILBOX_NAMESPACE, + MAILBOX_LAST_UID, + MAILBOX_HIGHEST_MODSEQ, + DSL.function("slice", + DefaultDataType.getDefaultDataType("hstore"), + MAILBOX_ACL, + DSL.array(DSL.val(userName.asString()))).as(MAILBOX_ACL) + ).from(TABLE_NAME) + .where(DSL.sql(MAILBOX_ACL.getName() + " ? '" + userName.asString() + "'")))) //TODO fix security vulnerability .map(RECORD_TO_POSTGRES_MAILBOX_FUNCTION); } From 7b3f538e6503f13f8b552cc5395f788c4c371d82 Mon Sep 17 00:00:00 2001 From: hung phan Date: Thu, 20 Jun 2024 16:25:06 +0700 Subject: [PATCH 323/341] JAMES-2586 Refactor code after optimizing findNonPersonalMailboxes method - Update AdditionalAlterQuery in PostgresTable - check if upsertACL actually successful - replace batch method - remove duplicated code in PostgresMailboxMapper and RLSSupportPostgresMailboxMapper --- .../backends/postgres/PostgresTable.java | 66 ++++++++++++------- .../postgres/PostgresTableManager.java | 25 +------ .../postgres/PostgresTableManagerTest.java | 4 +- .../postgres/mail/PostgresMailboxMapper.java | 22 +++---- .../mail/PostgresMailboxMemberDAO.java | 11 ++-- .../postgres/mail/PostgresMailboxModule.java | 3 +- .../mail/RLSSupportPostgresMailboxMapper.java | 18 ++--- .../postgres/mail/dao/PostgresMailboxDAO.java | 7 +- 8 files changed, 76 insertions(+), 80 deletions(-) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java index d969da6b78c..0333f6b6508 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java @@ -53,14 +53,50 @@ default FinalStage supportsRowLevelSecurity() { } } - public enum SupportCase { - RLS, NON_RLS, ALL - } + public abstract static class AdditionalAlterQuery { + private String query; - public record AdditionalAlterQuery(String query, - SupportCase supportCase) { public AdditionalAlterQuery(String query) { - this(query, SupportCase.ALL); + this.query = query; + } + + abstract boolean shouldBeApplied(boolean rowLevelSecurityEnabled); + + public String getQuery() { + return query; + } + } + + public static class RLSOnlyAdditionalAlterQuery extends AdditionalAlterQuery { + public RLSOnlyAdditionalAlterQuery(String query) { + super(query); + } + + @Override + boolean shouldBeApplied(boolean rowLevelSecurityEnabled) { + return rowLevelSecurityEnabled; + } + } + + public static class NonRLSOnlyAdditionalAlterQuery extends AdditionalAlterQuery { + public NonRLSOnlyAdditionalAlterQuery(String query) { + super(query); + } + + @Override + boolean shouldBeApplied(boolean rowLevelSecurityEnabled) { + return !rowLevelSecurityEnabled; + } + } + + public static class AllCasesAdditionalAlterQuery extends AdditionalAlterQuery { + public AllCasesAdditionalAlterQuery(String query) { + super(query); + } + + @Override + boolean shouldBeApplied(boolean rowLevelSecurityEnabled) { + return true; } } @@ -77,27 +113,11 @@ public FinalStage(String tableName, boolean supportsRowLevelSecurity, Function alterTableIfNeeded(PostgresTable table, Connection connection private Mono executeAdditionalAlterQueries(PostgresTable table, Connection connection) { return Flux.fromIterable(table.getAdditionalAlterQueries()) - .filter(this::isApplied) - .map(PostgresTable.AdditionalAlterQuery::query) + .filter(additionalAlterQuery -> additionalAlterQuery.shouldBeApplied(rowLevelSecurityEnabled)) + .map(PostgresTable.AdditionalAlterQuery::getQuery) .concatMap(alterSQLQuery -> Mono.just(connection) .flatMapMany(pgConnection -> pgConnection.createStatement(alterSQLQuery) .execute()) @@ -140,27 +140,6 @@ private Mono executeAdditionalAlterQueries(PostgresTable table, Connection .then(); } - private boolean isApplied(PostgresTable.AdditionalAlterQuery additionalAlterQuery) { - switch (additionalAlterQuery.supportCase()) { - case RLS: - if (rowLevelSecurityEnabled) { - return true; - } else { - return false; - } - case NON_RLS: - if (!rowLevelSecurityEnabled) { - return true; - } else { - return false; - } - case ALL: - return true; - default: - throw new UnsupportedOperationException(); - } - } - private Mono enableRLSIfNeeded(PostgresTable table, Connection connection) { if (rowLevelSecurityEnabled && table.supportsRowLevelSecurity()) { return alterTableEnableRLS(table, connection); diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java index b58cd375c65..95154229306 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java @@ -406,7 +406,7 @@ void additionalAlterQueryToCreateConstraintShouldSucceedWhenSupportCaseIsNonRLSA .column("clm1", SQLDataType.UUID.notNull()) .column("clm2", SQLDataType.VARCHAR(255).notNull())) .disableRowLevelSecurity() - .addAdditionalAlterQuery("ALTER TABLE tbn1 ADD CONSTRAINT " + constraintName + " EXCLUDE (clm2 WITH =)", PostgresTable.SupportCase.NON_RLS) + .addAdditionalAlterQueries(new PostgresTable.NonRLSOnlyAdditionalAlterQuery("ALTER TABLE tbn1 ADD CONSTRAINT " + constraintName + " EXCLUDE (clm2 WITH =)")) .build(); PostgresModule module = PostgresModule.table(table); PostgresTableManager testee = new PostgresTableManager(postgresExtension.getPostgresExecutor(), module, false); @@ -432,7 +432,7 @@ void additionalAlterQueryToCreateConstraintShouldNotBeExecutedWhenSupportCaseIsN .column("clm1", SQLDataType.UUID.notNull()) .column("clm2", SQLDataType.VARCHAR(255).notNull())) .disableRowLevelSecurity() - .addAdditionalAlterQuery("ALTER TABLE tbn1 ADD CONSTRAINT " + constraintName + " EXCLUDE (clm2 WITH =)", PostgresTable.SupportCase.NON_RLS) + .addAdditionalAlterQueries(new PostgresTable.NonRLSOnlyAdditionalAlterQuery("ALTER TABLE tbn1 ADD CONSTRAINT " + constraintName + " EXCLUDE (clm2 WITH =)")) .build(); PostgresModule module = PostgresModule.table(table); PostgresTableManager testee = new PostgresTableManager(postgresExtension.getPostgresExecutor(), module, true); diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java index 8e991a0010f..0974c0529ec 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapper.java @@ -96,23 +96,21 @@ public Flux findNonPersonalMailboxes(Username userName, MailboxACL.Righ @Override public Mono updateACL(Mailbox mailbox, MailboxACL.ACLCommand mailboxACLCommand) { - MailboxACL oldACL = mailbox.getACL(); - MailboxACL newACL = Throwing.supplier(() -> oldACL.apply(mailboxACLCommand)).get(); - return postgresMailboxDAO.upsertACL(mailbox.getMailboxId(), newACL) - .then(Mono.fromCallable(() -> { - mailbox.setACL(newACL); - return ACLDiff.computeDiff(oldACL, newACL); - })); + return upsertACL(mailbox, + mailbox.getACL(), + Throwing.supplier(() -> mailbox.getACL().apply(mailboxACLCommand)).get()); } @Override public Mono setACL(Mailbox mailbox, MailboxACL mailboxACL) { - MailboxACL oldACL = mailbox.getACL(); - return postgresMailboxDAO.upsertACL(mailbox.getMailboxId(), mailboxACL) + return upsertACL(mailbox, mailbox.getACL(), mailboxACL); + } + + private Mono upsertACL(Mailbox mailbox, MailboxACL oldACL, MailboxACL newACL) { + return postgresMailboxDAO.upsertACL(mailbox.getMailboxId(), newACL) .then(Mono.fromCallable(() -> { - mailbox.setACL(mailboxACL); - return ACLDiff.computeDiff(oldACL, mailboxACL); + mailbox.setACL(newACL); + return ACLDiff.computeDiff(oldACL, newACL); })); } - } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMemberDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMemberDAO.java index fe87e1f32ba..5cf73eb29f6 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMemberDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMemberDAO.java @@ -47,11 +47,12 @@ public Flux findMailboxIdByUsername(Username username) { } public Mono insert(PostgresMailboxId mailboxId, List usernames) { - return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.batch(usernames.stream() //TODO check issue: batch does not throw exception - .map(username -> dslContext.insertInto(TABLE_NAME) - .set(USER_NAME, username.asString()) - .set(MAILBOX_ID, mailboxId.asUuid())) - .toList()))); + return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.insertInto(TABLE_NAME, USER_NAME, MAILBOX_ID) + .valuesOfRecords(usernames.stream() + .map(username -> dslContext.newRecord(USER_NAME, MAILBOX_ID) + .value1(username.asString()) + .value2(mailboxId.asUuid())) + .toList()))); } public Mono delete(PostgresMailboxId mailboxId, List usernames) { diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java index 4ecbd7e6042..5b17924d018 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxModule.java @@ -63,8 +63,7 @@ interface PostgresMailboxTable { .constraint(DSL.primaryKey(MAILBOX_ID)) .constraint(DSL.constraint(MAILBOX_NAME_USER_NAME_NAMESPACE_UNIQUE_CONSTRAINT).unique(MAILBOX_NAME, USER_NAME, MAILBOX_NAMESPACE)))) .supportsRowLevelSecurity() - .addAdditionalAlterQuery("CREATE INDEX mailbox_mailbox_acl_index ON " + TABLE_NAME.getName() + " USING GIN (" + MAILBOX_ACL.getName() + ")", - PostgresTable.SupportCase.NON_RLS) + .addAdditionalAlterQueries(new PostgresTable.NonRLSOnlyAdditionalAlterQuery("CREATE INDEX mailbox_mailbox_acl_index ON " + TABLE_NAME.getName() + " USING GIN (" + MAILBOX_ACL.getName() + ")")) .build(); PostgresIndex MAILBOX_USERNAME_NAMESPACE_INDEX = PostgresIndex.name("mailbox_username_namespace_index") .createIndexStep((dsl, indexName) -> dsl.createIndexIfNotExists(indexName) diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/RLSSupportPostgresMailboxMapper.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/RLSSupportPostgresMailboxMapper.java index 214ab99cebd..aa3db2b311a 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/RLSSupportPostgresMailboxMapper.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/RLSSupportPostgresMailboxMapper.java @@ -60,15 +60,7 @@ public Mono updateACL(Mailbox mailbox, MailboxACL.ACLCommand mailboxACL MailboxACL newACL = Throwing.supplier(() -> oldACL.apply(mailboxACLCommand)).get(); ACLDiff aclDiff = ACLDiff.computeDiff(oldACL, newACL); PositiveUserACLDiff userACLDiff = new PositiveUserACLDiff(aclDiff); - return postgresMailboxDAO.upsertACL(mailbox.getMailboxId(), newACL) - .then(postgresMailboxMemberDAO.delete(PostgresMailboxId.class.cast(mailbox.getMailboxId()), - userACLDiff.removedEntries().map(entry -> Username.of(entry.getKey().getName())).toList())) - .then(postgresMailboxMemberDAO.insert(PostgresMailboxId.class.cast(mailbox.getMailboxId()), - userACLDiff.addedEntries().map(entry -> Username.of(entry.getKey().getName())).toList())) - .then(Mono.fromCallable(() -> { - mailbox.setACL(newACL); - return aclDiff; - })); + return upsertACL(mailbox, newACL, aclDiff, userACLDiff); } @Override @@ -76,13 +68,17 @@ public Mono setACL(Mailbox mailbox, MailboxACL mailboxACL) { MailboxACL oldACL = mailbox.getACL(); ACLDiff aclDiff = ACLDiff.computeDiff(oldACL, mailboxACL); PositiveUserACLDiff userACLDiff = new PositiveUserACLDiff(aclDiff); - return postgresMailboxDAO.upsertACL(mailbox.getMailboxId(), mailboxACL) + return upsertACL(mailbox, mailboxACL, aclDiff, userACLDiff); + } + + private Mono upsertACL(Mailbox mailbox, MailboxACL newACL, ACLDiff aclDiff, PositiveUserACLDiff userACLDiff) { + return postgresMailboxDAO.upsertACL(mailbox.getMailboxId(), newACL) .then(postgresMailboxMemberDAO.delete(PostgresMailboxId.class.cast(mailbox.getMailboxId()), userACLDiff.removedEntries().map(entry -> Username.of(entry.getKey().getName())).toList())) .then(postgresMailboxMemberDAO.insert(PostgresMailboxId.class.cast(mailbox.getMailboxId()), userACLDiff.addedEntries().map(entry -> Username.of(entry.getKey().getName())).toList())) .then(Mono.fromCallable(() -> { - mailbox.setACL(mailboxACL); + mailbox.setACL(newACL); return aclDiff; })); } diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java index b0ca432e724..4c443deed93 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/mail/dao/PostgresMailboxDAO.java @@ -149,9 +149,12 @@ private Mono update(Mailbox mailbox) { } public Mono upsertACL(MailboxId mailboxId, MailboxACL acl) { - return postgresExecutor.executeVoid(dslContext -> Mono.from(dslContext.update(TABLE_NAME) + return postgresExecutor.executeReturnAffectedRowsCount(dslContext -> Mono.from(dslContext.update(TABLE_NAME) .set(MAILBOX_ACL, MAILBOX_ACL_TO_HSTORE_FUNCTION.apply(acl)) - .where(MAILBOX_ID.eq(((PostgresMailboxId) mailboxId).asUuid())))); //TODO check if update is success + .where(MAILBOX_ID.eq(((PostgresMailboxId) mailboxId).asUuid())))) + .filter(count -> count > 0) + .switchIfEmpty(Mono.error(new RuntimeException("Upsert mailbox acl failed with mailboxId " + mailboxId.serialize()))) + .then(); } public Flux findMailboxesByUsername(Username userName) { From 550559371efa213e951769d3f77fa848f5f91bde Mon Sep 17 00:00:00 2001 From: hung phan Date: Fri, 21 Jun 2024 16:12:04 +0700 Subject: [PATCH 324/341] JAMES-2586 Change boolean rlsEnabled to enum RowLevelSecurity --- .../postgres/PostgresConfiguration.java | 16 ++++----- .../backends/postgres/PostgresTable.java | 12 +++---- .../postgres/PostgresTableManager.java | 12 +++---- .../backends/postgres/RowLevelSecurity.java | 35 +++++++++++++++++++ .../PoolBackedPostgresConnectionFactory.java | 14 ++++---- ...olBackedPostgresConnectionFactoryTest.java | 2 +- .../postgres/PostgresConfigurationTest.java | 4 +-- .../backends/postgres/PostgresExtension.java | 32 ++++++++--------- .../postgres/PostgresTableManagerTest.java | 15 ++++---- .../PostgresMailboxSessionMapperFactory.java | 7 ++-- ...LSSupportPostgresMailboxMapperACLTest.java | 4 +-- .../james/PostgresJamesConfiguration.java | 4 ++- .../modules/data/PostgresCommonModule.java | 8 ++--- 13 files changed, 100 insertions(+), 65 deletions(-) create mode 100644 backends-common/postgres/src/main/java/org/apache/james/backends/postgres/RowLevelSecurity.java diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java index e66e452a00f..29e5d904762 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresConfiguration.java @@ -256,7 +256,7 @@ public PostgresConfiguration build() { databaseSchema.orElse(DATABASE_SCHEMA_DEFAULT_VALUE), new Credential(username.get(), password.get()), new Credential(byPassRLSUser.orElse(username.get()), byPassRLSPassword.orElse(password.get())), - rowLevelSecurityEnabled.orElse(false), + rowLevelSecurityEnabled.filter(rlsEnabled -> rlsEnabled).map(rlsEnabled -> RowLevelSecurity.ENABLED).orElse(RowLevelSecurity.DISABLED), poolInitialSize.orElse(POOL_INITIAL_SIZE_DEFAULT_VALUE), poolMaxSize.orElse(POOL_MAX_SIZE_DEFAULT_VALUE), byPassRLSPoolInitialSize.orElse(BY_PASS_RLS_POOL_INITIAL_SIZE_DEFAULT_VALUE), @@ -297,7 +297,7 @@ public static PostgresConfiguration from(Configuration propertiesConfiguration) private final String databaseSchema; private final Credential defaultCredential; private final Credential byPassRLSCredential; - private final boolean rowLevelSecurityEnabled; + private final RowLevelSecurity rowLevelSecurity; private final Integer poolInitialSize; private final Integer poolMaxSize; private final Integer byPassRLSPoolInitialSize; @@ -306,7 +306,7 @@ public static PostgresConfiguration from(Configuration propertiesConfiguration) private final Duration jooqReactiveTimeout; private PostgresConfiguration(String host, int port, String databaseName, String databaseSchema, - Credential defaultCredential, Credential byPassRLSCredential, boolean rowLevelSecurityEnabled, + Credential defaultCredential, Credential byPassRLSCredential, RowLevelSecurity rowLevelSecurity, Integer poolInitialSize, Integer poolMaxSize, Integer byPassRLSPoolInitialSize, Integer byPassRLSPoolMaxSize, SSLMode sslMode, Duration jooqReactiveTimeout) { @@ -316,7 +316,7 @@ private PostgresConfiguration(String host, int port, String databaseName, String this.databaseSchema = databaseSchema; this.defaultCredential = defaultCredential; this.byPassRLSCredential = byPassRLSCredential; - this.rowLevelSecurityEnabled = rowLevelSecurityEnabled; + this.rowLevelSecurity = rowLevelSecurity; this.poolInitialSize = poolInitialSize; this.poolMaxSize = poolMaxSize; this.byPassRLSPoolInitialSize = byPassRLSPoolInitialSize; @@ -349,8 +349,8 @@ public Credential getByPassRLSCredential() { return byPassRLSCredential; } - public boolean rowLevelSecurityEnabled() { - return rowLevelSecurityEnabled; + public RowLevelSecurity getRowLevelSecurity() { + return rowLevelSecurity; } public Integer poolInitialSize() { @@ -379,7 +379,7 @@ public Duration getJooqReactiveTimeout() { @Override public final int hashCode() { - return Objects.hash(host, port, databaseName, databaseSchema, defaultCredential, byPassRLSCredential, rowLevelSecurityEnabled, poolInitialSize, poolMaxSize, sslMode, jooqReactiveTimeout); + return Objects.hash(host, port, databaseName, databaseSchema, defaultCredential, byPassRLSCredential, rowLevelSecurity, poolInitialSize, poolMaxSize, sslMode, jooqReactiveTimeout); } @Override @@ -387,7 +387,7 @@ public final boolean equals(Object o) { if (o instanceof PostgresConfiguration) { PostgresConfiguration that = (PostgresConfiguration) o; - return Objects.equals(this.rowLevelSecurityEnabled, that.rowLevelSecurityEnabled) + return Objects.equals(this.rowLevelSecurity, that.rowLevelSecurity) && Objects.equals(this.host, that.host) && Objects.equals(this.port, that.port) && Objects.equals(this.defaultCredential, that.defaultCredential) diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java index 0333f6b6508..f9bd1308c90 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTable.java @@ -60,7 +60,7 @@ public AdditionalAlterQuery(String query) { this.query = query; } - abstract boolean shouldBeApplied(boolean rowLevelSecurityEnabled); + abstract boolean shouldBeApplied(RowLevelSecurity rowLevelSecurity); public String getQuery() { return query; @@ -73,8 +73,8 @@ public RLSOnlyAdditionalAlterQuery(String query) { } @Override - boolean shouldBeApplied(boolean rowLevelSecurityEnabled) { - return rowLevelSecurityEnabled; + boolean shouldBeApplied(RowLevelSecurity rowLevelSecurity) { + return rowLevelSecurity.isRowLevelSecurityEnabled(); } } @@ -84,8 +84,8 @@ public NonRLSOnlyAdditionalAlterQuery(String query) { } @Override - boolean shouldBeApplied(boolean rowLevelSecurityEnabled) { - return !rowLevelSecurityEnabled; + boolean shouldBeApplied(RowLevelSecurity rowLevelSecurity) { + return !rowLevelSecurity.isRowLevelSecurityEnabled(); } } @@ -95,7 +95,7 @@ public AllCasesAdditionalAlterQuery(String query) { } @Override - boolean shouldBeApplied(boolean rowLevelSecurityEnabled) { + boolean shouldBeApplied(RowLevelSecurity rowLevelSecurity) { return true; } } diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java index 7df62d890f3..ffb88497682 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/PostgresTableManager.java @@ -43,7 +43,7 @@ public class PostgresTableManager implements Startable { private static final Logger LOGGER = LoggerFactory.getLogger(PostgresTableManager.class); private final PostgresExecutor postgresExecutor; private final PostgresModule module; - private final boolean rowLevelSecurityEnabled; + private final RowLevelSecurity rowLevelSecurity; @Inject public PostgresTableManager(PostgresExecutor postgresExecutor, @@ -51,14 +51,14 @@ public PostgresTableManager(PostgresExecutor postgresExecutor, PostgresConfiguration postgresConfiguration) { this.postgresExecutor = postgresExecutor; this.module = module; - this.rowLevelSecurityEnabled = postgresConfiguration.rowLevelSecurityEnabled(); + this.rowLevelSecurity = postgresConfiguration.getRowLevelSecurity(); } @VisibleForTesting - public PostgresTableManager(PostgresExecutor postgresExecutor, PostgresModule module, boolean rowLevelSecurityEnabled) { + public PostgresTableManager(PostgresExecutor postgresExecutor, PostgresModule module, RowLevelSecurity rowLevelSecurity) { this.postgresExecutor = postgresExecutor; this.module = module; - this.rowLevelSecurityEnabled = rowLevelSecurityEnabled; + this.rowLevelSecurity = rowLevelSecurity; } public void initPostgres() { @@ -123,7 +123,7 @@ private Mono alterTableIfNeeded(PostgresTable table, Connection connection private Mono executeAdditionalAlterQueries(PostgresTable table, Connection connection) { return Flux.fromIterable(table.getAdditionalAlterQueries()) - .filter(additionalAlterQuery -> additionalAlterQuery.shouldBeApplied(rowLevelSecurityEnabled)) + .filter(additionalAlterQuery -> additionalAlterQuery.shouldBeApplied(rowLevelSecurity)) .map(PostgresTable.AdditionalAlterQuery::getQuery) .concatMap(alterSQLQuery -> Mono.just(connection) .flatMapMany(pgConnection -> pgConnection.createStatement(alterSQLQuery) @@ -141,7 +141,7 @@ private Mono executeAdditionalAlterQueries(PostgresTable table, Connection } private Mono enableRLSIfNeeded(PostgresTable table, Connection connection) { - if (rowLevelSecurityEnabled && table.supportsRowLevelSecurity()) { + if (rowLevelSecurity.isRowLevelSecurityEnabled() && table.supportsRowLevelSecurity()) { return alterTableEnableRLS(table, connection); } return Mono.empty(); diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/RowLevelSecurity.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/RowLevelSecurity.java new file mode 100644 index 00000000000..2f806b6c74e --- /dev/null +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/RowLevelSecurity.java @@ -0,0 +1,35 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.backends.postgres; + +public enum RowLevelSecurity { + ENABLED(true), + DISABLED(false); + + private boolean rowLevelSecurityEnabled; + + RowLevelSecurity(boolean rowLevelSecurityEnabled) { + this.rowLevelSecurityEnabled = rowLevelSecurityEnabled; + } + + public boolean isRowLevelSecurityEnabled() { + return rowLevelSecurityEnabled; + } +} diff --git a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java index 02af49b468c..465f93a1c38 100644 --- a/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java +++ b/backends-common/postgres/src/main/java/org/apache/james/backends/postgres/utils/PoolBackedPostgresConnectionFactory.java @@ -19,6 +19,7 @@ package org.apache.james.backends.postgres.utils; +import org.apache.james.backends.postgres.RowLevelSecurity; import org.apache.james.core.Domain; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,11 +34,12 @@ public class PoolBackedPostgresConnectionFactory implements JamesPostgresConnect private static final Logger LOGGER = LoggerFactory.getLogger(PoolBackedPostgresConnectionFactory.class); private static final int DEFAULT_INITIAL_SIZE = 10; private static final int DEFAULT_MAX_SIZE = 20; - private final boolean rowLevelSecurityEnabled; + + private final RowLevelSecurity rowLevelSecurity; private final ConnectionPool pool; - public PoolBackedPostgresConnectionFactory(boolean rowLevelSecurityEnabled, int initialSize, int maxSize, ConnectionFactory connectionFactory) { - this.rowLevelSecurityEnabled = rowLevelSecurityEnabled; + public PoolBackedPostgresConnectionFactory(RowLevelSecurity rowLevelSecurity, int initialSize, int maxSize, ConnectionFactory connectionFactory) { + this.rowLevelSecurity = rowLevelSecurity; ConnectionPoolConfiguration configuration = ConnectionPoolConfiguration.builder(connectionFactory) .initialSize(initialSize) .maxSize(maxSize) @@ -46,13 +48,13 @@ public PoolBackedPostgresConnectionFactory(boolean rowLevelSecurityEnabled, int pool = new ConnectionPool(configuration); } - public PoolBackedPostgresConnectionFactory(boolean rowLevelSecurityEnabled, ConnectionFactory connectionFactory) { - this(rowLevelSecurityEnabled, DEFAULT_INITIAL_SIZE, DEFAULT_MAX_SIZE, connectionFactory); + public PoolBackedPostgresConnectionFactory(RowLevelSecurity rowLevelSecurity, ConnectionFactory connectionFactory) { + this(rowLevelSecurity, DEFAULT_INITIAL_SIZE, DEFAULT_MAX_SIZE, connectionFactory); } @Override public Mono getConnection(Domain domain) { - if (rowLevelSecurityEnabled) { + if (rowLevelSecurity.isRowLevelSecurityEnabled()) { return pool.create().flatMap(connection -> setDomainAttributeForConnection(domain.asString(), connection)); } else { return pool.create(); diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PoolBackedPostgresConnectionFactoryTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PoolBackedPostgresConnectionFactoryTest.java index 31bd7afc469..4e4cb45b7f0 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PoolBackedPostgresConnectionFactoryTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PoolBackedPostgresConnectionFactoryTest.java @@ -29,6 +29,6 @@ public class PoolBackedPostgresConnectionFactoryTest extends JamesPostgresConnec @Override JamesPostgresConnectionFactory jamesPostgresConnectionFactory() { - return new PoolBackedPostgresConnectionFactory(true, postgresExtension.getConnectionFactory()); + return new PoolBackedPostgresConnectionFactory(RowLevelSecurity.ENABLED, postgresExtension.getConnectionFactory()); } } diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java index 75cc50513de..08d76a23569 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresConfigurationTest.java @@ -51,7 +51,7 @@ void shouldReturnCorrespondingProperties() { assertThat(configuration.getDefaultCredential().getPassword()).isEqualTo("1"); assertThat(configuration.getByPassRLSCredential().getUsername()).isEqualTo("bypassrlsjames"); assertThat(configuration.getByPassRLSCredential().getPassword()).isEqualTo("2"); - assertThat(configuration.rowLevelSecurityEnabled()).isEqualTo(true); + assertThat(configuration.getRowLevelSecurity()).isEqualTo(RowLevelSecurity.ENABLED); assertThat(configuration.getSslMode()).isEqualTo(SSLMode.REQUIRE); } @@ -68,7 +68,7 @@ void shouldUseDefaultValues() { assertThat(configuration.getDatabaseSchema()).isEqualTo(PostgresConfiguration.DATABASE_SCHEMA_DEFAULT_VALUE); assertThat(configuration.getByPassRLSCredential().getUsername()).isEqualTo("james"); assertThat(configuration.getByPassRLSCredential().getPassword()).isEqualTo("1"); - assertThat(configuration.rowLevelSecurityEnabled()).isEqualTo(false); + assertThat(configuration.getRowLevelSecurity()).isEqualTo(RowLevelSecurity.DISABLED); assertThat(configuration.getSslMode()).isEqualTo(SSLMode.ALLOW); } diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java index e527ee1925e..dc304746f61 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresExtension.java @@ -70,10 +70,8 @@ public int getMax() { } } - private static final boolean ROW_LEVEL_SECURITY_ENABLED = true; - public static PostgresExtension withRowLevelSecurity(PostgresModule module) { - return new PostgresExtension(module, ROW_LEVEL_SECURITY_ENABLED); + return new PostgresExtension(module, RowLevelSecurity.ENABLED); } public static PostgresExtension withoutRowLevelSecurity(PostgresModule module) { @@ -81,7 +79,7 @@ public static PostgresExtension withoutRowLevelSecurity(PostgresModule module) { } public static PostgresExtension withoutRowLevelSecurity(PostgresModule module, PoolSize poolSize) { - return new PostgresExtension(module, !ROW_LEVEL_SECURITY_ENABLED, Optional.of(poolSize)); + return new PostgresExtension(module, RowLevelSecurity.DISABLED, Optional.of(poolSize)); } public static PostgresExtension empty() { @@ -91,7 +89,7 @@ public static PostgresExtension empty() { public static final PoolSize DEFAULT_POOL_SIZE = PoolSize.SMALL; public static PostgreSQLContainer PG_CONTAINER = DockerPostgresSingleton.SINGLETON; private final PostgresModule postgresModule; - private final boolean rlsEnabled; + private final RowLevelSecurity rowLevelSecurity; private final PostgresFixture.Database selectedDatabase; private PoolSize poolSize; private PostgresConfiguration postgresConfiguration; @@ -112,14 +110,14 @@ public void unpause() { .exec(); } - private PostgresExtension(PostgresModule postgresModule, boolean rlsEnabled) { - this(postgresModule, rlsEnabled, Optional.empty()); + private PostgresExtension(PostgresModule postgresModule, RowLevelSecurity rowLevelSecurity) { + this(postgresModule, rowLevelSecurity, Optional.empty()); } - private PostgresExtension(PostgresModule postgresModule, boolean rlsEnabled, Optional maybePoolSize) { + private PostgresExtension(PostgresModule postgresModule, RowLevelSecurity rowLevelSecurity, Optional maybePoolSize) { this.postgresModule = postgresModule; - this.rlsEnabled = rlsEnabled; - if (rlsEnabled) { + this.rowLevelSecurity = rowLevelSecurity; + if (rowLevelSecurity.isRowLevelSecurityEnabled()) { this.selectedDatabase = PostgresFixture.Database.ROW_LEVEL_SECURITY_DATABASE; } else { this.selectedDatabase = DEFAULT_DATABASE; @@ -138,7 +136,7 @@ public void beforeAll(ExtensionContext extensionContext) throws Exception { } private void querySettingRowLevelSecurityIfNeed() { - if (rlsEnabled) { + if (rowLevelSecurity.isRowLevelSecurityEnabled()) { Throwing.runnable(() -> { PG_CONTAINER.execInContainer("psql", "-U", DEFAULT_DATABASE.dbUser(), "-c", "create user " + ROW_LEVEL_SECURITY_DATABASE.dbUser() + " WITH PASSWORD '" + ROW_LEVEL_SECURITY_DATABASE.dbPassword() + "';"); PG_CONTAINER.execInContainer("psql", "-U", DEFAULT_DATABASE.dbUser(), "-c", "create database " + ROW_LEVEL_SECURITY_DATABASE.dbName() + ";"); @@ -162,7 +160,7 @@ private void initPostgresSession() { .password(selectedDatabase.dbPassword()) .byPassRLSUser(DEFAULT_DATABASE.dbUser()) .byPassRLSPassword(DEFAULT_DATABASE.dbPassword()) - .rowLevelSecurityEnabled(rlsEnabled) + .rowLevelSecurityEnabled(rowLevelSecurity.isRowLevelSecurityEnabled()) .jooqReactiveTimeout(Optional.of(Duration.ofSeconds(20L))) .build(); @@ -181,7 +179,7 @@ private void initPostgresSession() { connectionFactory = new PostgresqlConnectionFactory(postgresqlConnectionConfigurationFunction.apply(postgresConfiguration.getDefaultCredential())); defaultConnection = connectionFactory.create().block(); executorFactory = new PostgresExecutor.Factory( - getJamesPostgresConnectionFactory(rlsEnabled, connectionFactory), + getJamesPostgresConnectionFactory(rowLevelSecurity, connectionFactory), postgresConfiguration, metricFactory); @@ -190,12 +188,12 @@ private void initPostgresSession() { PostgresqlConnectionFactory byPassRLSConnectionFactory = new PostgresqlConnectionFactory(postgresqlConnectionConfigurationFunction.apply(postgresConfiguration.getByPassRLSCredential())); byPassRLSPostgresExecutor = new PostgresExecutor.Factory( - getJamesPostgresConnectionFactory(false, byPassRLSConnectionFactory), + getJamesPostgresConnectionFactory(RowLevelSecurity.DISABLED, byPassRLSConnectionFactory), postgresConfiguration, metricFactory) .create(); - this.postgresTableManager = new PostgresTableManager(defaultPostgresExecutor, postgresModule, rlsEnabled); + this.postgresTableManager = new PostgresTableManager(defaultPostgresExecutor, postgresModule, rowLevelSecurity); } @Override @@ -284,9 +282,9 @@ private void dropTables(List tables) { .block(); } - private JamesPostgresConnectionFactory getJamesPostgresConnectionFactory(boolean rlsEnabled, PostgresqlConnectionFactory connectionFactory) { + private JamesPostgresConnectionFactory getJamesPostgresConnectionFactory(RowLevelSecurity rowLevelSecurity, PostgresqlConnectionFactory connectionFactory) { return new PoolBackedPostgresConnectionFactory( - rlsEnabled, + rowLevelSecurity, poolSize.getMin(), poolSize.getMax(), connectionFactory); diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java index 95154229306..2980885fd8b 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/PostgresTableManagerTest.java @@ -41,7 +41,7 @@ class PostgresTableManagerTest { static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresModule.EMPTY_MODULE); Function tableManagerFactory = - module -> new PostgresTableManager(postgresExtension.getDefaultPostgresExecutor(), module, true); + module -> new PostgresTableManager(postgresExtension.getDefaultPostgresExecutor(), module, RowLevelSecurity.ENABLED); @Test void initializeTableShouldSuccessWhenModuleHasSingleTable() { @@ -340,10 +340,7 @@ void createTableShouldNotCreateRlsColumnWhenDisableRLS() { .build(); PostgresModule module = PostgresModule.table(table); - boolean disabledRLS = false; - - - PostgresTableManager testee = new PostgresTableManager(postgresExtension.getDefaultPostgresExecutor(), module, disabledRLS); + PostgresTableManager testee = new PostgresTableManager(postgresExtension.getDefaultPostgresExecutor(), module, RowLevelSecurity.DISABLED); testee.initializeTables() .block(); @@ -383,7 +380,7 @@ void additionalAlterQueryToCreateConstraintShouldSucceed() { .addAdditionalAlterQueries("ALTER TABLE tbn1 ADD CONSTRAINT " + constraintName + " EXCLUDE (clm2 WITH =)") .build(); PostgresModule module = PostgresModule.table(table); - PostgresTableManager testee = new PostgresTableManager(postgresExtension.getDefaultPostgresExecutor(), module, false); + PostgresTableManager testee = new PostgresTableManager(postgresExtension.getDefaultPostgresExecutor(), module, RowLevelSecurity.DISABLED); testee.initializeTables().block(); @@ -409,7 +406,7 @@ void additionalAlterQueryToCreateConstraintShouldSucceedWhenSupportCaseIsNonRLSA .addAdditionalAlterQueries(new PostgresTable.NonRLSOnlyAdditionalAlterQuery("ALTER TABLE tbn1 ADD CONSTRAINT " + constraintName + " EXCLUDE (clm2 WITH =)")) .build(); PostgresModule module = PostgresModule.table(table); - PostgresTableManager testee = new PostgresTableManager(postgresExtension.getPostgresExecutor(), module, false); + PostgresTableManager testee = new PostgresTableManager(postgresExtension.getDefaultPostgresExecutor(), module, RowLevelSecurity.DISABLED); testee.initializeTables().block(); @@ -435,7 +432,7 @@ void additionalAlterQueryToCreateConstraintShouldNotBeExecutedWhenSupportCaseIsN .addAdditionalAlterQueries(new PostgresTable.NonRLSOnlyAdditionalAlterQuery("ALTER TABLE tbn1 ADD CONSTRAINT " + constraintName + " EXCLUDE (clm2 WITH =)")) .build(); PostgresModule module = PostgresModule.table(table); - PostgresTableManager testee = new PostgresTableManager(postgresExtension.getPostgresExecutor(), module, true); + PostgresTableManager testee = new PostgresTableManager(postgresExtension.getDefaultPostgresExecutor(), module, RowLevelSecurity.ENABLED); testee.initializeTables().block(); @@ -461,7 +458,7 @@ void additionalAlterQueryToReCreateConstraintShouldNotThrow() { .addAdditionalAlterQueries("ALTER TABLE tbn1 ADD CONSTRAINT " + constraintName + " EXCLUDE (clm2 WITH =)") .build(); PostgresModule module = PostgresModule.table(table); - PostgresTableManager testee = new PostgresTableManager(postgresExtension.getDefaultPostgresExecutor(), module, false); + PostgresTableManager testee = new PostgresTableManager(postgresExtension.getDefaultPostgresExecutor(), module, RowLevelSecurity.DISABLED); testee.initializeTables().block(); diff --git a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java index 5a5035f15d6..8e157c514b0 100644 --- a/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java +++ b/mailbox/postgres/src/main/java/org/apache/james/mailbox/postgres/PostgresMailboxSessionMapperFactory.java @@ -23,6 +23,7 @@ import jakarta.inject.Inject; import org.apache.james.backends.postgres.PostgresConfiguration; +import org.apache.james.backends.postgres.RowLevelSecurity; import org.apache.james.backends.postgres.utils.PostgresExecutor; import org.apache.james.blob.api.BlobId; import org.apache.james.blob.api.BlobStore; @@ -60,7 +61,7 @@ public class PostgresMailboxSessionMapperFactory extends MailboxSessionMapperFac private final BlobStore blobStore; private final BlobId.Factory blobIdFactory; private final Clock clock; - private final boolean isRLSEnabled; + private final RowLevelSecurity rowLevelSecurity; @Inject public PostgresMailboxSessionMapperFactory(PostgresExecutor.Factory executorFactory, @@ -72,13 +73,13 @@ public PostgresMailboxSessionMapperFactory(PostgresExecutor.Factory executorFact this.blobStore = blobStore; this.blobIdFactory = blobIdFactory; this.clock = clock; - this.isRLSEnabled = postgresConfiguration.rowLevelSecurityEnabled(); + this.rowLevelSecurity = postgresConfiguration.getRowLevelSecurity(); } @Override public MailboxMapper createMailboxMapper(MailboxSession session) { PostgresMailboxDAO mailboxDAO = new PostgresMailboxDAO(executorFactory.create(session.getUser().getDomainPart())); - if (isRLSEnabled) { + if (rowLevelSecurity.isRowLevelSecurityEnabled()) { return new RLSSupportPostgresMailboxMapper(mailboxDAO, new PostgresMailboxMemberDAO(executorFactory.create(session.getUser().getDomainPart()))); } else { diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/RLSSupportPostgresMailboxMapperACLTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/RLSSupportPostgresMailboxMapperACLTest.java index b42bc21cad4..1352f0b74e1 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/RLSSupportPostgresMailboxMapperACLTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/RLSSupportPostgresMailboxMapperACLTest.java @@ -33,7 +33,7 @@ class RLSSupportPostgresMailboxMapperACLTest extends MailboxMapperACLTest { @Override protected MailboxMapper createMailboxMapper() { - return new RLSSupportPostgresMailboxMapper(new PostgresMailboxDAO(postgresExtension.getPostgresExecutor()), - new PostgresMailboxMemberDAO(postgresExtension.getPostgresExecutor())); + return new RLSSupportPostgresMailboxMapper(new PostgresMailboxDAO(postgresExtension.getDefaultPostgresExecutor()), + new PostgresMailboxMemberDAO(postgresExtension.getDefaultPostgresExecutor())); } } diff --git a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java index ba5e58516a5..21b9c633c79 100644 --- a/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java +++ b/server/apps/postgres-app/src/main/java/org/apache/james/PostgresJamesConfiguration.java @@ -230,7 +230,9 @@ public PostgresJamesConfiguration build() { private boolean readRLSEnabledFromFile(PropertiesProvider propertiesProvider) { try { - return PostgresConfiguration.from(propertiesProvider.getConfiguration(PostgresConfiguration.POSTGRES_CONFIGURATION_NAME)).rowLevelSecurityEnabled(); + return PostgresConfiguration.from(propertiesProvider.getConfiguration(PostgresConfiguration.POSTGRES_CONFIGURATION_NAME)) + .getRowLevelSecurity() + .isRowLevelSecurityEnabled(); } catch (FileNotFoundException | ConfigurationException e) { return false; } diff --git a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java index c2b87b70189..c9c51a7ae62 100644 --- a/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java +++ b/server/container/guice/postgres-common/src/main/java/org/apache/james/modules/data/PostgresCommonModule.java @@ -28,6 +28,7 @@ import org.apache.james.backends.postgres.PostgresConfiguration; import org.apache.james.backends.postgres.PostgresModule; import org.apache.james.backends.postgres.PostgresTableManager; +import org.apache.james.backends.postgres.RowLevelSecurity; import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.PoolBackedPostgresConnectionFactory; import org.apache.james.backends.postgres.utils.PostgresConnectionClosure; @@ -55,7 +56,6 @@ public class PostgresCommonModule extends AbstractModule { private static final Logger LOGGER = LoggerFactory.getLogger("POSTGRES"); - private static final boolean DISABLED_ROW_LEVEL_SECURITY = false; @Override public void configure() { @@ -78,7 +78,7 @@ PostgresConfiguration provideConfiguration(PropertiesProvider propertiesProvider @Singleton JamesPostgresConnectionFactory provideJamesPostgresConnectionFactory(PostgresConfiguration postgresConfiguration, ConnectionFactory connectionFactory) { - return new PoolBackedPostgresConnectionFactory(postgresConfiguration.rowLevelSecurityEnabled(), + return new PoolBackedPostgresConnectionFactory(postgresConfiguration.getRowLevelSecurity(), postgresConfiguration.poolInitialSize(), postgresConfiguration.poolMaxSize(), connectionFactory); @@ -90,10 +90,10 @@ JamesPostgresConnectionFactory provideJamesPostgresConnectionFactory(PostgresCon JamesPostgresConnectionFactory provideJamesPostgresConnectionFactoryWithRLSBypass(PostgresConfiguration postgresConfiguration, JamesPostgresConnectionFactory jamesPostgresConnectionFactory, @Named(JamesPostgresConnectionFactory.BY_PASS_RLS_INJECT) ConnectionFactory connectionFactory) { - if (!postgresConfiguration.rowLevelSecurityEnabled()) { + if (!postgresConfiguration.getRowLevelSecurity().isRowLevelSecurityEnabled()) { return jamesPostgresConnectionFactory; } - return new PoolBackedPostgresConnectionFactory(DISABLED_ROW_LEVEL_SECURITY, + return new PoolBackedPostgresConnectionFactory(RowLevelSecurity.DISABLED, postgresConfiguration.byPassRLSPoolInitialSize(), postgresConfiguration.byPassRLSPoolMaxSize(), connectionFactory); From 7fb94124c140bd747053013ed229a0af720ac8ed Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Tue, 25 Jun 2024 11:33:01 +0700 Subject: [PATCH 325/341] JAMES-2586 [PGSQL] Fix checkstyle & adapt code after rebase master --- .../JamesPostgresConnectionFactoryTest.java | 2 -- .../postgres/quota/PostgresQuotaLimitDaoTest.java | 14 +++++++------- .../CassandraThreadIdGuessingAlgorithmTest.java | 2 -- .../cassandra/mail/CassandraMapperProvider.java | 1 - .../PostgresMailboxManagerAttachmentTest.java | 10 +++++----- .../PostgresThreadIdGuessingAlgorithmTest.java | 2 +- .../postgres/mail/PostgresMailboxMapperTest.java | 8 ++++---- .../SearchThreadIdGuessingAlgorithmTest.java | 2 -- .../imapmailbox/postgres/PostgresFetchTest.java | 1 - .../postgres/PostgresMailboxAnnotationTest.java | 1 - .../postgres/host/PostgresHostSystem.java | 2 +- .../james/PostgresWithLDAPJamesServerTest.java | 3 ++- .../james/PostgresWithOpenSearchDisabledTest.java | 3 +-- .../upload/CassandraUploadRepositoryTest.java | 1 + .../PostgresEmailQueryViewManagerRLSTest.java | 4 ++-- .../upload/PostgresUploadUsageRepositoryTest.java | 2 ++ .../postgres/PostgresNotificationRegistryTest.java | 1 + .../PushSubscriptionSetMethodContract.scala | 7 +++---- ...PostgresDeletedMessageVaultIntegrationTest.java | 3 +-- 19 files changed, 31 insertions(+), 38 deletions(-) diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/JamesPostgresConnectionFactoryTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/JamesPostgresConnectionFactoryTest.java index 6d503c6d790..6d27f26ca9d 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/JamesPostgresConnectionFactoryTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/JamesPostgresConnectionFactoryTest.java @@ -21,8 +21,6 @@ import static org.assertj.core.api.Assertions.assertThat; -import java.util.Optional; - import org.apache.james.backends.postgres.utils.JamesPostgresConnectionFactory; import org.apache.james.core.Domain; import org.junit.jupiter.api.Test; diff --git a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/quota/PostgresQuotaLimitDaoTest.java b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/quota/PostgresQuotaLimitDaoTest.java index 6b3ea3641fe..b489c194e9e 100644 --- a/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/quota/PostgresQuotaLimitDaoTest.java +++ b/backends-common/postgres/src/test/java/org/apache/james/backends/postgres/quota/PostgresQuotaLimitDaoTest.java @@ -19,6 +19,8 @@ package org.apache.james.backends.postgres.quota; +import static org.assertj.core.api.Assertions.assertThat; + import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.core.quota.QuotaComponent; import org.apache.james.core.quota.QuotaLimit; @@ -28,8 +30,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import static org.assertj.core.api.Assertions.assertThat; - public class PostgresQuotaLimitDaoTest { private PostgresQuotaLimitDAO postgresQuotaLimitDao; @@ -44,8 +44,8 @@ void setup() { @Test void getQuotaLimitsShouldGetSomeQuotaLimitsSuccessfully() { - QuotaLimit expectedOne = QuotaLimit.builder().quotaComponent(QuotaComponent.MAILBOX).quotaScope(QuotaScope.DOMAIN).identifier("A").quotaType(QuotaType.COUNT).quotaLimit(200l).build(); - QuotaLimit expectedTwo = QuotaLimit.builder().quotaComponent(QuotaComponent.MAILBOX).quotaScope(QuotaScope.DOMAIN).identifier("A").quotaType(QuotaType.SIZE).quotaLimit(100l).build(); + QuotaLimit expectedOne = QuotaLimit.builder().quotaComponent(QuotaComponent.MAILBOX).quotaScope(QuotaScope.DOMAIN).identifier("A").quotaType(QuotaType.COUNT).quotaLimit(200L).build(); + QuotaLimit expectedTwo = QuotaLimit.builder().quotaComponent(QuotaComponent.MAILBOX).quotaScope(QuotaScope.DOMAIN).identifier("A").quotaType(QuotaType.SIZE).quotaLimit(100L).build(); postgresQuotaLimitDao.setQuotaLimit(expectedOne).block(); postgresQuotaLimitDao.setQuotaLimit(expectedTwo).block(); @@ -55,7 +55,7 @@ void getQuotaLimitsShouldGetSomeQuotaLimitsSuccessfully() { @Test void setQuotaLimitShouldSaveObjectSuccessfully() { - QuotaLimit expected = QuotaLimit.builder().quotaComponent(QuotaComponent.MAILBOX).quotaScope(QuotaScope.DOMAIN).identifier("A").quotaType(QuotaType.COUNT).quotaLimit(100l).build(); + QuotaLimit expected = QuotaLimit.builder().quotaComponent(QuotaComponent.MAILBOX).quotaScope(QuotaScope.DOMAIN).identifier("A").quotaType(QuotaType.COUNT).quotaLimit(100L).build(); postgresQuotaLimitDao.setQuotaLimit(expected).block(); assertThat(postgresQuotaLimitDao.getQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.DOMAIN, "A", QuotaType.COUNT)).block()) @@ -64,7 +64,7 @@ void setQuotaLimitShouldSaveObjectSuccessfully() { @Test void setQuotaLimitShouldSaveObjectSuccessfullyWhenLimitIsMinusOne() { - QuotaLimit expected = QuotaLimit.builder().quotaComponent(QuotaComponent.MAILBOX).quotaScope(QuotaScope.DOMAIN).identifier("A").quotaType(QuotaType.COUNT).quotaLimit(-1l).build(); + QuotaLimit expected = QuotaLimit.builder().quotaComponent(QuotaComponent.MAILBOX).quotaScope(QuotaScope.DOMAIN).identifier("A").quotaType(QuotaType.COUNT).quotaLimit(-1L).build(); postgresQuotaLimitDao.setQuotaLimit(expected).block(); assertThat(postgresQuotaLimitDao.getQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.DOMAIN, "A", QuotaType.COUNT)).block()) @@ -73,7 +73,7 @@ void setQuotaLimitShouldSaveObjectSuccessfullyWhenLimitIsMinusOne() { @Test void deleteQuotaLimitShouldDeleteObjectSuccessfully() { - QuotaLimit quotaLimit = QuotaLimit.builder().quotaComponent(QuotaComponent.MAILBOX).quotaScope(QuotaScope.DOMAIN).identifier("A").quotaType(QuotaType.COUNT).quotaLimit(100l).build(); + QuotaLimit quotaLimit = QuotaLimit.builder().quotaComponent(QuotaComponent.MAILBOX).quotaScope(QuotaScope.DOMAIN).identifier("A").quotaType(QuotaType.COUNT).quotaLimit(100L).build(); postgresQuotaLimitDao.setQuotaLimit(quotaLimit).block(); postgresQuotaLimitDao.deleteQuotaLimit(QuotaLimit.QuotaLimitKey.of(QuotaComponent.MAILBOX, QuotaScope.DOMAIN, "A", QuotaType.COUNT)).block(); diff --git a/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/CassandraThreadIdGuessingAlgorithmTest.java b/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/CassandraThreadIdGuessingAlgorithmTest.java index 0edc7dbe794..1d7e8dd5a4e 100644 --- a/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/CassandraThreadIdGuessingAlgorithmTest.java +++ b/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/CassandraThreadIdGuessingAlgorithmTest.java @@ -52,8 +52,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import reactor.core.publisher.Flux; - public class CassandraThreadIdGuessingAlgorithmTest extends ThreadIdGuessingAlgorithmContract { private CassandraMailboxManager mailboxManager; private CassandraThreadDAO threadDAO; diff --git a/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/mail/CassandraMapperProvider.java b/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/mail/CassandraMapperProvider.java index 730a2a836f1..4f5c310b181 100644 --- a/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/mail/CassandraMapperProvider.java +++ b/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/mail/CassandraMapperProvider.java @@ -42,7 +42,6 @@ import org.apache.james.mailbox.store.mail.MessageMapper; import org.apache.james.mailbox.store.mail.UidProvider; import org.apache.james.mailbox.store.mail.model.MapperProvider; -import org.apache.james.mailbox.store.mail.model.MessageUidProvider; import org.apache.james.utils.UpdatableTickingClock; import com.google.common.collect.ImmutableList; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerAttachmentTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerAttachmentTest.java index 75dac52c492..e821fd7b00f 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerAttachmentTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresMailboxManagerAttachmentTest.java @@ -80,9 +80,9 @@ public class PostgresMailboxManagerAttachmentTest extends AbstractMailboxManager @BeforeEach void beforeAll() throws Exception { - BlobId.Factory BLOB_ID_FACTORY = new HashBlobId.Factory(); - DeDuplicationBlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, BLOB_ID_FACTORY); - mapperFactory = new PostgresMailboxSessionMapperFactory(postgresExtension.getExecutorFactory(), Clock.systemUTC(), blobStore, BLOB_ID_FACTORY, + BlobId.Factory blobIdFactory = new HashBlobId.Factory(); + DeDuplicationBlobStore blobStore = new DeDuplicationBlobStore(new MemoryBlobStoreDAO(), BucketName.DEFAULT, blobIdFactory); + mapperFactory = new PostgresMailboxSessionMapperFactory(postgresExtension.getExecutorFactory(), Clock.systemUTC(), blobStore, blobIdFactory, PostgresConfiguration.builder().username("a").password("a").build()); MailboxACLResolver aclResolver = new UnionMailboxACLResolver(); @@ -101,9 +101,9 @@ void beforeAll() throws Exception { MessageSearchIndex index = new SimpleMessageSearchIndex(mapperFactory, mapperFactory, new DefaultTextExtractor(), storeAttachmentManager); - PostgresMessageDAO.Factory postgresMessageDAOFactory = new PostgresMessageDAO.Factory(BLOB_ID_FACTORY, postgresExtension.getExecutorFactory()); + PostgresMessageDAO.Factory postgresMessageDAOFactory = new PostgresMessageDAO.Factory(blobIdFactory, postgresExtension.getExecutorFactory()); PostgresMailboxMessageDAO.Factory postgresMailboxMessageDAOFactory = new PostgresMailboxMessageDAO.Factory(postgresExtension.getExecutorFactory()); - PostgresAttachmentDAO.Factory attachmentDAOFactory = new PostgresAttachmentDAO.Factory(postgresExtension.getExecutorFactory(), BLOB_ID_FACTORY); + PostgresAttachmentDAO.Factory attachmentDAOFactory = new PostgresAttachmentDAO.Factory(postgresExtension.getExecutorFactory(), blobIdFactory); PostgresThreadDAO.Factory threadDAOFactory = new PostgresThreadDAO.Factory(postgresExtension.getExecutorFactory()); eventBus.register(new DeleteMessageListener(blobStore, postgresMailboxMessageDAOFactory, postgresMessageDAOFactory, diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresThreadIdGuessingAlgorithmTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresThreadIdGuessingAlgorithmTest.java index 8567d0f3489..3d064e4e620 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresThreadIdGuessingAlgorithmTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/PostgresThreadIdGuessingAlgorithmTest.java @@ -47,9 +47,9 @@ import org.apache.james.metrics.tests.RecordingMetricFactory; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import org.testcontainers.shaded.com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; import com.google.common.hash.Hashing; import reactor.core.publisher.Flux; diff --git a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperTest.java b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperTest.java index 35e63e01e27..e49a33f7c24 100644 --- a/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperTest.java +++ b/mailbox/postgres/src/test/java/org/apache/james/mailbox/postgres/mail/PostgresMailboxMapperTest.java @@ -63,17 +63,17 @@ void retrieveMailboxShouldReturnCorrectHighestModSeqAndLastUidWhenDefault() { @Test void retrieveMailboxShouldReturnCorrectHighestModSeqAndLastUid() { - Username BENWA = Username.of("benwa"); - MailboxPath benwaInboxPath = MailboxPath.forUser(BENWA, "INBOX"); + Username benwa = Username.of("benwa"); + MailboxPath benwaInboxPath = MailboxPath.forUser(benwa, "INBOX"); Mailbox mailbox = mailboxMapper.create(benwaInboxPath, UidValidity.of(43)).block(); // increase modSeq - ModSeq nextModSeq = new PostgresModSeqProvider.Factory(postgresExtension.getExecutorFactory()).create(MailboxSessionUtil.create(BENWA)) + ModSeq nextModSeq = new PostgresModSeqProvider.Factory(postgresExtension.getExecutorFactory()).create(MailboxSessionUtil.create(benwa)) .nextModSeqReactive(mailbox.getMailboxId()).block(); // increase lastUid - MessageUid nextUid = new PostgresUidProvider.Factory(postgresExtension.getExecutorFactory()).create(MailboxSessionUtil.create(BENWA)) + MessageUid nextUid = new PostgresUidProvider.Factory(postgresExtension.getExecutorFactory()).create(MailboxSessionUtil.create(benwa)) .nextUidReactive(mailbox.getMailboxId()).block(); PostgresMailbox metaData = (PostgresMailbox) mailboxMapper.findMailboxById(mailbox.getMailboxId()).block(); diff --git a/mailbox/scanning-search/src/test/java/org/apache/james/mailbox/store/search/SearchThreadIdGuessingAlgorithmTest.java b/mailbox/scanning-search/src/test/java/org/apache/james/mailbox/store/search/SearchThreadIdGuessingAlgorithmTest.java index 345f3da4109..75ea84cfc23 100644 --- a/mailbox/scanning-search/src/test/java/org/apache/james/mailbox/store/search/SearchThreadIdGuessingAlgorithmTest.java +++ b/mailbox/scanning-search/src/test/java/org/apache/james/mailbox/store/search/SearchThreadIdGuessingAlgorithmTest.java @@ -38,8 +38,6 @@ import org.apache.james.mailbox.store.mail.model.MimeMessageId; import org.apache.james.mailbox.store.mail.model.Subject; -import reactor.core.publisher.Flux; - public class SearchThreadIdGuessingAlgorithmTest extends ThreadIdGuessingAlgorithmContract { private InMemoryMailboxManager mailboxManager; diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchTest.java index 715bfa4a4b7..358cc3180c1 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresFetchTest.java @@ -22,7 +22,6 @@ import org.apache.james.mpt.api.ImapHostSystem; import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; import org.apache.james.mpt.imapmailbox.suite.Fetch; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; public class PostgresFetchTest extends Fetch { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxAnnotationTest.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxAnnotationTest.java index 40b8a88903e..e4c7535eb98 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxAnnotationTest.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/PostgresMailboxAnnotationTest.java @@ -22,7 +22,6 @@ import org.apache.james.mpt.api.ImapHostSystem; import org.apache.james.mpt.imapmailbox.postgres.host.PostgresHostSystemExtension; import org.apache.james.mpt.imapmailbox.suite.MailboxAnnotation; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.extension.RegisterExtension; public class PostgresMailboxAnnotationTest extends MailboxAnnotation { diff --git a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java index 8424925d44d..d1a329509ef 100644 --- a/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java +++ b/mpt/impl/imap-mailbox/postgres/src/test/java/org/apache/james/mpt/imapmailbox/postgres/host/PostgresHostSystem.java @@ -45,9 +45,9 @@ import org.apache.james.mailbox.SubscriptionManager; import org.apache.james.mailbox.acl.MailboxACLResolver; import org.apache.james.mailbox.acl.UnionMailboxACLResolver; +import org.apache.james.mailbox.postgres.PostgresMailboxManager; import org.apache.james.mailbox.postgres.PostgresMailboxSessionMapperFactory; import org.apache.james.mailbox.postgres.PostgresMessageId; -import org.apache.james.mailbox.postgres.PostgresMailboxManager; import org.apache.james.mailbox.postgres.quota.PostgresCurrentQuotaManager; import org.apache.james.mailbox.postgres.quota.PostgresPerUserMaxQuotaManager; import org.apache.james.mailbox.quota.CurrentQuotaManager; diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java index efe1b872f64..a0be3e81710 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithLDAPJamesServerTest.java @@ -26,13 +26,14 @@ import java.io.IOException; import org.apache.commons.net.imap.IMAPClient; +import org.apache.james.PostgresJamesConfiguration.EventBusImpl; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.data.LdapTestExtension; import org.apache.james.modules.protocols.ImapGuiceProbe; import org.apache.james.user.ldap.DockerLdapSingleton; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; -import org.apache.james.PostgresJamesConfiguration.EventBusImpl; + class PostgresWithLDAPJamesServerTest { static PostgresExtension postgresExtension = PostgresExtension.empty(); diff --git a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithOpenSearchDisabledTest.java b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithOpenSearchDisabledTest.java index 5f706b8ba38..49555b377b0 100644 --- a/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithOpenSearchDisabledTest.java +++ b/server/apps/postgres-app/src/test/java/org/apache/james/PostgresWithOpenSearchDisabledTest.java @@ -19,13 +19,12 @@ package org.apache.james; -import org.apache.james.PostgresJamesConfiguration.EventBusImpl; - import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; import static org.assertj.core.api.Assertions.assertThat; import java.nio.charset.StandardCharsets; +import org.apache.james.PostgresJamesConfiguration.EventBusImpl; import org.apache.james.backends.opensearch.OpenSearchConfiguration; import org.apache.james.backends.postgres.PostgresExtension; import org.apache.james.core.Domain; diff --git a/server/data/data-jmap-cassandra/src/test/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepositoryTest.java b/server/data/data-jmap-cassandra/src/test/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepositoryTest.java index 285ad9a0908..d598677c3e6 100644 --- a/server/data/data-jmap-cassandra/src/test/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepositoryTest.java +++ b/server/data/data-jmap-cassandra/src/test/java/org/apache/james/jmap/cassandra/upload/CassandraUploadRepositoryTest.java @@ -41,6 +41,7 @@ class CassandraUploadRepositoryTest implements UploadRepositoryContract { static CassandraClusterExtension cassandra = new CassandraClusterExtension(UploadModule.MODULE); private CassandraUploadRepository testee; private UpdatableTickingClock clock; + @BeforeEach void setUp() { clock = new UpdatableTickingClock(Clock.systemUTC().instant()); diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewManagerRLSTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewManagerRLSTest.java index f296470d4ff..a0a48d734f2 100644 --- a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewManagerRLSTest.java +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/projections/PostgresEmailQueryViewManagerRLSTest.java @@ -37,8 +37,8 @@ public class PostgresEmailQueryViewManagerRLSTest { public static final PostgresMailboxId MAILBOX_ID_1 = PostgresMailboxId.generate(); public static final PostgresMessageId.Factory MESSAGE_ID_FACTORY = new PostgresMessageId.Factory(); public static final PostgresMessageId MESSAGE_ID_1 = MESSAGE_ID_FACTORY.generate(); - ZonedDateTime DATE_1 = ZonedDateTime.parse("2010-10-30T15:12:00Z"); - ZonedDateTime DATE_2 = ZonedDateTime.parse("2010-10-30T16:12:00Z"); + private static final ZonedDateTime DATE_1 = ZonedDateTime.parse("2010-10-30T15:12:00Z"); + private static final ZonedDateTime DATE_2 = ZonedDateTime.parse("2010-10-30T16:12:00Z"); @RegisterExtension static PostgresExtension postgresExtension = PostgresExtension.withRowLevelSecurity(PostgresEmailQueryViewModule.MODULE); diff --git a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadUsageRepositoryTest.java b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadUsageRepositoryTest.java index 23c10a5de9d..29aefe323d3 100644 --- a/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadUsageRepositoryTest.java +++ b/server/data/data-jmap-postgres/src/test/java/org/apache/james/jmap/postgres/upload/PostgresUploadUsageRepositoryTest.java @@ -35,11 +35,13 @@ public class PostgresUploadUsageRepositoryTest implements UploadUsageRepositoryC PostgresModule.aggregateModules(PostgresUploadModule.MODULE, PostgresQuotaModule.MODULE)); private PostgresUploadUsageRepository uploadUsageRepository; + @BeforeEach public void setup() { uploadUsageRepository = new PostgresUploadUsageRepository(new PostgresQuotaCurrentValueDAO(postgresExtension.getDefaultPostgresExecutor())); resetCounterToZero(); } + @Override public UploadUsageRepository uploadUsageRepository() { return uploadUsageRepository; diff --git a/server/data/data-postgres/src/test/java/org/apache/james/vacation/postgres/PostgresNotificationRegistryTest.java b/server/data/data-postgres/src/test/java/org/apache/james/vacation/postgres/PostgresNotificationRegistryTest.java index 19c55dbf2ad..84599f1a671 100644 --- a/server/data/data-postgres/src/test/java/org/apache/james/vacation/postgres/PostgresNotificationRegistryTest.java +++ b/server/data/data-postgres/src/test/java/org/apache/james/vacation/postgres/PostgresNotificationRegistryTest.java @@ -40,6 +40,7 @@ public void setUp() throws Exception { notificationRegistry = new PostgresNotificationRegistry(zonedDateTimeProvider, postgresExtension.getExecutorFactory()); recipientId = RecipientId.fromMailAddress(new MailAddress("benwa@apache.org")); } + @Override public NotificationRegistry notificationRegistry() { return notificationRegistry; diff --git a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/PushSubscriptionSetMethodContract.scala b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/PushSubscriptionSetMethodContract.scala index b63b94557a4..d0f706ebd73 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/PushSubscriptionSetMethodContract.scala +++ b/server/protocols/jmap-rfc-8621-integration-tests/jmap-rfc-8621-integration-tests-common/src/main/scala/org/apache/james/jmap/rfc8621/contract/PushSubscriptionSetMethodContract.scala @@ -39,7 +39,6 @@ import io.restassured.http.ContentType.JSON import jakarta.inject.Inject import net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson import net.javacrumbs.jsonunit.core.Option.IGNORING_ARRAY_ORDER -import net.javacrumbs.jsonunit.core.internal.Options import org.apache.http.HttpStatus.SC_OK import org.apache.james.GuiceJamesServer import org.apache.james.core.Username @@ -612,7 +611,7 @@ trait PushSubscriptionSetMethodContract { .asString assertThatJson(response) - .withOptions(new Options(IGNORING_ARRAY_ORDER)) + .withOptions(IGNORING_ARRAY_ORDER) .isEqualTo( s"""{ | "sessionState": "${SESSION_STATE.value}", @@ -774,7 +773,7 @@ trait PushSubscriptionSetMethodContract { .asString assertThatJson(response) - .withOptions(new Options(IGNORING_ARRAY_ORDER)) + .withOptions(IGNORING_ARRAY_ORDER) .isEqualTo( s"""{ | "sessionState": "${SESSION_STATE.value}", @@ -917,7 +916,7 @@ trait PushSubscriptionSetMethodContract { .asString assertThatJson(response) - .withOptions(new Options(IGNORING_ARRAY_ORDER)) + .withOptions(IGNORING_ARRAY_ORDER) .isEqualTo( s"""{ | "sessionState": "${SESSION_STATE.value}", diff --git a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/vault/PostgresDeletedMessageVaultIntegrationTest.java b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/vault/PostgresDeletedMessageVaultIntegrationTest.java index fc12a043605..e7bcb0daaa2 100644 --- a/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/vault/PostgresDeletedMessageVaultIntegrationTest.java +++ b/server/protocols/webadmin-integration-test/postgres-webadmin-integration-test/src/test/java/org/apache/james/webadmin/integration/vault/PostgresDeletedMessageVaultIntegrationTest.java @@ -19,7 +19,6 @@ package org.apache.james.webadmin.integration.vault; -import static io.restassured.config.ParamConfig.UpdateStrategy.REPLACE; import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; import static org.awaitility.Durations.FIVE_HUNDRED_MILLISECONDS; import static org.awaitility.Durations.ONE_MINUTE; @@ -84,7 +83,7 @@ void setUp(GuiceJamesServer jamesServer) throws Exception { this.smtpMessageSender = new SMTPMessageSender(DOMAIN); this.webAdminApi = WebAdminUtils.spec(jamesServer.getProbe(WebAdminGuiceProbe.class).getWebAdminPort()) .config(WebAdminUtils.defaultConfig() - .paramConfig(new ParamConfig(REPLACE, REPLACE, REPLACE))); + .paramConfig(new ParamConfig().replaceAllParameters())); jamesServer.getProbe(DataProbeImpl.class) .fluent() From f753b5ca0ff4611768920e2cc91f2f504882147d Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Tue, 25 Jun 2024 15:52:31 +0700 Subject: [PATCH 326/341] JAMES-2586 Fix sequential issue with updating flags in the reactive pipeline - Update: disabled for cassandra weakWrite --- ...sandraMessageMapperRelaxedConsistencyTest.java | 15 +++++++++++++++ .../store/mail/model/MessageMapperTest.java | 6 +++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/mail/CassandraMessageMapperRelaxedConsistencyTest.java b/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/mail/CassandraMessageMapperRelaxedConsistencyTest.java index a71d7318973..202ce3797ac 100644 --- a/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/mail/CassandraMessageMapperRelaxedConsistencyTest.java +++ b/mailbox/cassandra/src/test/java/org/apache/james/mailbox/cassandra/mail/CassandraMessageMapperRelaxedConsistencyTest.java @@ -98,5 +98,20 @@ public void setFlagsShouldWorkWithConcurrencyWithRemove() throws Exception { public void userFlagsUpdateShouldWorkInConcurrentEnvironment() throws Exception { super.userFlagsUpdateShouldWorkInConcurrentEnvironment(); } + + @Disabled("JAMES-3435 Without strong consistency flags update is not thread safe as long as it follows a read-before-write pattern") + @Override + public void updateFlagsWithRangeAllRangeShouldReturnUpdatedFlagsWithUidOrderAsc() { + } + + @Disabled("JAMES-3435 Without strong consistency flags update is not thread safe as long as it follows a read-before-write pattern") + @Override + public void updateFlagsOnRangeShouldReturnUpdatedFlagsWithUidOrderAsc() { + } + + @Disabled("JAMES-3435 Without strong consistency flags update is not thread safe as long as it follows a read-before-write pattern") + @Override + public void updateFlagsWithRangeFromShouldReturnUpdatedFlagsWithUidOrderAsc() { + } } } diff --git a/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MessageMapperTest.java b/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MessageMapperTest.java index ae012ea0e62..bef9c55b432 100644 --- a/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MessageMapperTest.java +++ b/mailbox/store/src/test/java/org/apache/james/mailbox/store/mail/model/MessageMapperTest.java @@ -852,7 +852,7 @@ void updateFlagsWithRangeAllRangeShouldAffectAllMessages() throws MailboxExcepti } @Test - void updateFlagsOnRangeShouldReturnUpdatedFlagsWithUidOrderAsc() throws MailboxException { + public void updateFlagsOnRangeShouldReturnUpdatedFlagsWithUidOrderAsc() throws MailboxException { saveMessages(); Iterator it = messageMapper.updateFlags(benwaInboxMailbox, @@ -867,7 +867,7 @@ void updateFlagsOnRangeShouldReturnUpdatedFlagsWithUidOrderAsc() throws MailboxE } @Test - void updateFlagsWithRangeFromShouldReturnUpdatedFlagsWithUidOrderAsc() throws MailboxException { + public void updateFlagsWithRangeFromShouldReturnUpdatedFlagsWithUidOrderAsc() throws MailboxException { saveMessages(); Iterator it = messageMapper.updateFlags(benwaInboxMailbox, @@ -882,7 +882,7 @@ void updateFlagsWithRangeFromShouldReturnUpdatedFlagsWithUidOrderAsc() throws Ma } @Test - void updateFlagsWithRangeAllRangeShouldReturnUpdatedFlagsWithUidOrderAsc() throws MailboxException { + public void updateFlagsWithRangeAllRangeShouldReturnUpdatedFlagsWithUidOrderAsc() throws MailboxException { saveMessages(); Iterator it = messageMapper.updateFlags(benwaInboxMailbox, From ca7fbe7c94c20a691f5bec26fba78609ca41d5dc Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 26 Jun 2024 08:53:08 +0700 Subject: [PATCH 327/341] JAMES-2586 Fix BlobStoreConfigurationTest --- .../blobstore/BlobStoreConfigurationTest.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/server/container/guice/distributed/src/test/java/org/apache/james/modules/blobstore/BlobStoreConfigurationTest.java b/server/container/guice/distributed/src/test/java/org/apache/james/modules/blobstore/BlobStoreConfigurationTest.java index 3fc6cffb0bd..65aff8ff8ba 100644 --- a/server/container/guice/distributed/src/test/java/org/apache/james/modules/blobstore/BlobStoreConfigurationTest.java +++ b/server/container/guice/distributed/src/test/java/org/apache/james/modules/blobstore/BlobStoreConfigurationTest.java @@ -257,7 +257,7 @@ void fromShouldThrowWhenBlobStoreImplIsMissing() { assertThatThrownBy(() -> BlobStoreConfiguration.from(configuration)) .isInstanceOf(IllegalStateException.class) - .hasMessage("implementation property is missing please use one of supported values in: cassandra, file, s3"); + .hasMessage("implementation property is missing please use one of supported values in: " + supportedBlobStores()); } @Test @@ -267,7 +267,7 @@ void fromShouldThrowWhenBlobStoreImplIsNull() { assertThatThrownBy(() -> BlobStoreConfiguration.from(configuration)) .isInstanceOf(IllegalStateException.class) - .hasMessage("implementation property is missing please use one of supported values in: cassandra, file, s3"); + .hasMessage("implementation property is missing please use one of supported values in: " + supportedBlobStores()); } @Test @@ -277,7 +277,7 @@ void fromShouldThrowWhenBlobStoreImplIsEmpty() { assertThatThrownBy(() -> BlobStoreConfiguration.from(configuration)) .isInstanceOf(IllegalStateException.class) - .hasMessage("implementation property is missing please use one of supported values in: cassandra, file, s3"); + .hasMessage("implementation property is missing please use one of supported values in: " + supportedBlobStores()); } @Test @@ -287,7 +287,11 @@ void fromShouldThrowWhenBlobStoreImplIsNotInSupportedList() { assertThatThrownBy(() -> BlobStoreConfiguration.from(configuration)) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("un_supported is not a valid name of BlobStores, please use one of supported values in: cassandra, file, s3"); + .hasMessage("un_supported is not a valid name of BlobStores, please use one of supported values in: " + supportedBlobStores()); + } + + private String supportedBlobStores() { + return "cassandra, file, s3, postgres"; } @Test From 711790d817e4d78220f4030c7fc5c924f7320cfe Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 26 Jun 2024 18:07:39 +0700 Subject: [PATCH 328/341] [ENHANCEMENT] Better reactify Identity methods - update for Postgres --- .../james/rrt/postgres/PostgresRecipientRewriteTable.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTable.java b/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTable.java index f0ff1bfc0e8..be6cd20ba47 100644 --- a/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTable.java +++ b/server/data/data-postgres/src/main/java/org/apache/james/rrt/postgres/PostgresRecipientRewriteTable.java @@ -36,6 +36,8 @@ import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableMap; +import reactor.core.publisher.Flux; + public class PostgresRecipientRewriteTable extends AbstractRecipientRewriteTable { private PostgresRecipientRewriteTableDAO postgresRecipientRewriteTableDAO; @@ -85,4 +87,10 @@ public Stream listSources(Mapping mapping) { return postgresRecipientRewriteTableDAO.getSources(mapping).toStream(); } + @Override + public Flux listSourcesReactive(Mapping mapping) { + Preconditions.checkArgument(listSourcesSupportedType.contains(mapping.getType()), + "Not supported mapping of type %s", mapping.getType()); + return postgresRecipientRewriteTableDAO.getSources(mapping); + } } From 3c6695cde18596a141514f10563956d7c9d6f0fe Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 26 Jun 2024 18:18:01 +0700 Subject: [PATCH 329/341] JAMES-2586 Fixup PostgresPushSubscriptionSetMethodTest - add ClockMQExtension --- .../rfc8621/postgres/PostgresPushSubscriptionSetMethodTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresPushSubscriptionSetMethodTest.java b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresPushSubscriptionSetMethodTest.java index 93696a33db0..06ba0f85e90 100644 --- a/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresPushSubscriptionSetMethodTest.java +++ b/server/protocols/jmap-rfc-8621-integration-tests/postgres-jmap-rfc-8621-integration-tests/src/test/java/org/apache/james/jmap/rfc8621/postgres/PostgresPushSubscriptionSetMethodTest.java @@ -21,6 +21,7 @@ import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; +import org.apache.james.ClockExtension; import org.apache.james.JamesServerBuilder; import org.apache.james.JamesServerExtension; import org.apache.james.PostgresJamesConfiguration; @@ -53,6 +54,7 @@ public class PostgresPushSubscriptionSetMethodTest implements PushSubscriptionSe .build()) .extension(PostgresExtension.empty()) .extension(new RabbitMQExtension()) + .extension(new ClockExtension()) .server(configuration -> PostgresJamesServerMain.createServer(configuration) .overrideWith(new TestJMAPServerModule()) .overrideWith(new PushSubscriptionProbeModule()) From 16dc81ecdcbeb5e2348f8edabd2cac7db27fc069 Mon Sep 17 00:00:00 2001 From: vttran Date: Thu, 27 Jun 2024 10:08:15 +0700 Subject: [PATCH 330/341] Disable test: JamesWithNonCompatibleElasticSearchServerTest test failed, and CassandraJamesServerMain was mark as deprecated, and will be removed in the future. --- .../james/JamesWithNonCompatibleElasticSearchServerTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/apps/cassandra-app/src/test/java/org/apache/james/JamesWithNonCompatibleElasticSearchServerTest.java b/server/apps/cassandra-app/src/test/java/org/apache/james/JamesWithNonCompatibleElasticSearchServerTest.java index 31699b491f6..543a0f941e8 100644 --- a/server/apps/cassandra-app/src/test/java/org/apache/james/JamesWithNonCompatibleElasticSearchServerTest.java +++ b/server/apps/cassandra-app/src/test/java/org/apache/james/JamesWithNonCompatibleElasticSearchServerTest.java @@ -29,6 +29,7 @@ import org.apache.james.modules.mailbox.OpenSearchStartUpCheck; import org.apache.james.util.docker.Images; import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -51,6 +52,7 @@ static void afterAll() { } @Test + @Disabled("test failed, and CassandraJamesServerMain was mark as deprecated, and will be removed in the future.") void jamesShouldStopWhenStartingWithANonCompatibleElasticSearchServer(GuiceJamesServer server) throws Exception { assertThatThrownBy(server::start) .isInstanceOfSatisfying( From c75c3710df4ed954b3448aa12adfbf4993081d91 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Thu, 27 Jun 2024 22:02:01 +0700 Subject: [PATCH 331/341] Fixup - add missing dependencies in apache-james-mpt-smtp-cassandra-rabbitmq-object-storage --- mpt/impl/smtp/cassandra-rabbitmq-object-storage/pom.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mpt/impl/smtp/cassandra-rabbitmq-object-storage/pom.xml b/mpt/impl/smtp/cassandra-rabbitmq-object-storage/pom.xml index bca08801b2d..bc2faf81182 100644 --- a/mpt/impl/smtp/cassandra-rabbitmq-object-storage/pom.xml +++ b/mpt/impl/smtp/cassandra-rabbitmq-object-storage/pom.xml @@ -110,6 +110,13 @@ test-jar test + + ${james.groupId} + queue-rabbitmq-guice + ${project.version} + test-jar + test + ${james.groupId} testing-base From 4791371ec78028634630767097eaeb886c476a8c Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 26 Jun 2024 18:19:22 +0700 Subject: [PATCH 332/341] [Antora] [PGSQL] Setup postgresql James server documentation section --- docs/modules/servers/nav.adoc | 7 +++++++ docs/modules/servers/pages/index.adoc | 9 +++++++++ .../servers/pages/postgres/architecture/index.adoc | 2 ++ .../servers/pages/postgres/benchmark/index.adoc | 2 ++ .../servers/pages/postgres/extending/index.adoc | 2 ++ docs/modules/servers/pages/postgres/index.adoc | 14 ++++++++++++++ .../servers/pages/postgres/operate/index.adoc | 2 ++ docs/modules/servers/pages/postgres/run/index.adoc | 2 ++ 8 files changed, 40 insertions(+) create mode 100644 docs/modules/servers/pages/postgres/architecture/index.adoc create mode 100644 docs/modules/servers/pages/postgres/benchmark/index.adoc create mode 100644 docs/modules/servers/pages/postgres/extending/index.adoc create mode 100644 docs/modules/servers/pages/postgres/index.adoc create mode 100644 docs/modules/servers/pages/postgres/operate/index.adoc create mode 100644 docs/modules/servers/pages/postgres/run/index.adoc diff --git a/docs/modules/servers/nav.adoc b/docs/modules/servers/nav.adoc index 7fdb1f8bc13..0b08d54e8ed 100644 --- a/docs/modules/servers/nav.adoc +++ b/docs/modules/servers/nav.adoc @@ -77,4 +77,11 @@ *** xref:distributed/benchmark/index.adoc[Performance benchmark] **** xref:distributed/benchmark/db-benchmark.adoc[] **** xref:distributed/benchmark/james-benchmark.adoc[] +** xref:postgres/index.adoc[] +*** xref:postgres/objectives.adoc[] +*** xref:postgres/architecture/index.adoc[] +*** xref:postgres/run/index.adoc[] +*** xref:postgres/operate/index.adoc[] +*** xref:postgres/extending/index.adoc[] +*** xref:postgres/benchmark/index.adoc[] ** xref:test.adoc[] diff --git a/docs/modules/servers/pages/index.adoc b/docs/modules/servers/pages/index.adoc index 3fd055e4367..4c6faf58354 100644 --- a/docs/modules/servers/pages/index.adoc +++ b/docs/modules/servers/pages/index.adoc @@ -16,6 +16,7 @@ The available James Servers are: * <> * <> * <> + * <> * <> If you are just checking out James for the first time, then we highly recommend @@ -79,6 +80,14 @@ and is intended for experts only. +[#postgres] +== James Postgres Mail Server + +The xref:postgres/index.adoc[*Distributed with Postgres Server*] is a one +variant of the distributed server with Postgres as the database. + + + [#test] == James Test Server diff --git a/docs/modules/servers/pages/postgres/architecture/index.adoc b/docs/modules/servers/pages/postgres/architecture/index.adoc new file mode 100644 index 00000000000..9d44d70ca1c --- /dev/null +++ b/docs/modules/servers/pages/postgres/architecture/index.adoc @@ -0,0 +1,2 @@ += Distributed James Postgres Server — Architecture +:navtitle: Architecture \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/benchmark/index.adoc b/docs/modules/servers/pages/postgres/benchmark/index.adoc new file mode 100644 index 00000000000..0b65da76717 --- /dev/null +++ b/docs/modules/servers/pages/postgres/benchmark/index.adoc @@ -0,0 +1,2 @@ += Distributed James Postgres Server — Performance testing +:navtitle: Performance testing \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/extending/index.adoc b/docs/modules/servers/pages/postgres/extending/index.adoc new file mode 100644 index 00000000000..c95b2919ad5 --- /dev/null +++ b/docs/modules/servers/pages/postgres/extending/index.adoc @@ -0,0 +1,2 @@ += Distributed James Postgres Server — Extending server behavior +:navtitle: Extending server behavior \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/index.adoc b/docs/modules/servers/pages/postgres/index.adoc new file mode 100644 index 00000000000..cf59a011f24 --- /dev/null +++ b/docs/modules/servers/pages/postgres/index.adoc @@ -0,0 +1,14 @@ += Postgres James Mail Server +:navtitle: Distributed Postgres James Application + +The Postgres James server offers an easy way to scale email server. Based on +SQL database solutions, here is https://www.postgresql.org/[Postgres]. + +Postgres is a powerful and versatile database server. Known for its advanced features, scalability, +and robust performance, Postgres is the ideal choice for handling high-throughput and large data sets efficiently. +Its row-level security ensures top-notch data protection, while the flexible architecture allows seamless integration +with various storage and search solutions + +In this section of the documentation, we will introduce you to: + +* xref:postgres/objectives.adoc[Objectives and motivation of the Distributed Postgres Server] diff --git a/docs/modules/servers/pages/postgres/operate/index.adoc b/docs/modules/servers/pages/postgres/operate/index.adoc new file mode 100644 index 00000000000..041520ffe82 --- /dev/null +++ b/docs/modules/servers/pages/postgres/operate/index.adoc @@ -0,0 +1,2 @@ += Distributed James Postgres Server — Operate +:navtitle: Operate \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/run/index.adoc b/docs/modules/servers/pages/postgres/run/index.adoc new file mode 100644 index 00000000000..3f9a1012665 --- /dev/null +++ b/docs/modules/servers/pages/postgres/run/index.adoc @@ -0,0 +1,2 @@ += Distributed James Postgres Server — Run +:navtitle: Run \ No newline at end of file From 645b821b8601b4891c338f264278712cb515dd24 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 26 Jun 2024 18:19:42 +0700 Subject: [PATCH 333/341] [Antora] [PGSQL] Objectives and motivation page for postgres doc --- .../servers/pages/postgres/objectives.adoc | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 docs/modules/servers/pages/postgres/objectives.adoc diff --git a/docs/modules/servers/pages/postgres/objectives.adoc b/docs/modules/servers/pages/postgres/objectives.adoc new file mode 100644 index 00000000000..d1dcb91090b --- /dev/null +++ b/docs/modules/servers/pages/postgres/objectives.adoc @@ -0,0 +1,22 @@ += Distributed James Server — Objectives and motivation +:navtitle: Objectives and motivation + +From the outstanding advantages of a distributed mail system, such as scalability and enhancement, +this project aims to implement a backend database version using Postgres. + +Primary Objectives: + +* Provide more options: The current James Distributed server uses Cassandra as the backend database. + This project aims to provide an alternative to Cassandra, using Postgres as the backend database. + This choice aims to offer a highly scalable and reactive James mail server, suitable for small to medium deployments, + while the distributed setup remains more fitting for larger ones. +* Propose an alternative to the jpa-app variant: The jpa-app variant is a simple version of James that uses JPA + to store data and is compatible with various SQL databases. + With the postgres-app, we use the `r2dbc` library to connect to the Postgres database, implementing non-blocking, + reactive APIs for higher performance. +* Leverage advanced Postgres features: Postgres is a powerful database that supports many advanced features. + This project aims to leverage these features to improve the efficiency of the James server. + For example, the implement https://www.postgresql.org/docs/current/ddl-rowsecurity.html[row-level security] + to improve the security of the James server. +* Flexible deployment: The new architecture allows flexible module choices. You can use Postgres directly for + blob storage or use Object Storage (e.g Minio, S3...). \ No newline at end of file From 4beebdfbf6500e3ef72a1d466ea8f668fcd09606 Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Mon, 1 Jul 2024 08:25:45 +0700 Subject: [PATCH 334/341] [Antora] [PGSQL] Add Configuration section to postgresql doc --- docs/modules/servers/nav.adoc | 30 +++++++++++ .../pages/postgres/configure/batchsizes.adoc | 5 ++ .../pages/postgres/configure/blobstore.adoc | 51 +++++++++++++++++++ .../configure/collecting-contacts.adoc | 4 ++ .../postgres/configure/collecting-events.adoc | 4 ++ .../servers/pages/postgres/configure/dns.adoc | 5 ++ .../pages/postgres/configure/domainlist.adoc | 5 ++ .../pages/postgres/configure/droplists.adoc | 6 +++ .../servers/pages/postgres/configure/dsn.adoc | 7 +++ .../pages/postgres/configure/extensions.adoc | 6 +++ .../pages/postgres/configure/healthcheck.adoc | 5 ++ .../pages/postgres/configure/imap.adoc | 6 +++ .../pages/postgres/configure/index.adoc | 24 +++++++++ .../pages/postgres/configure/jmap.adoc | 7 +++ .../servers/pages/postgres/configure/jmx.adoc | 5 ++ .../servers/pages/postgres/configure/jvm.adoc | 5 ++ .../pages/postgres/configure/listeners.adoc | 6 +++ .../postgres/configure/mailetcontainer.adoc | 6 +++ .../pages/postgres/configure/mailets.adoc | 6 +++ .../configure/mailrepositorystore.adoc | 9 ++++ .../pages/postgres/configure/matchers.adoc | 7 +++ .../pages/postgres/configure/opensearch.adoc | 8 +++ .../pages/postgres/configure/pop3.adoc | 7 +++ .../pages/postgres/configure/queue.adoc | 5 ++ .../pages/postgres/configure/rabbitmq.adoc | 5 ++ .../configure/recipientrewritetable.adoc | 7 +++ .../pages/postgres/configure/redis.adoc | 5 ++ .../remote-delivery-error-handling.adoc | 8 +++ .../pages/postgres/configure/search.adoc | 5 ++ .../pages/postgres/configure/sieve.adoc | 7 +++ .../pages/postgres/configure/smtp-hooks.adoc | 7 +++ .../pages/postgres/configure/smtp.adoc | 7 +++ .../pages/postgres/configure/spam.adoc | 8 +++ .../servers/pages/postgres/configure/ssl.adoc | 7 +++ .../pages/postgres/configure/tika.adoc | 5 ++ .../postgres/configure/usersrepository.adoc | 5 ++ .../pages/postgres/configure/vault.adoc | 8 +++ .../pages/postgres/configure/webadmin.adoc | 7 +++ 38 files changed, 320 insertions(+) create mode 100644 docs/modules/servers/pages/postgres/configure/batchsizes.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/blobstore.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/collecting-contacts.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/collecting-events.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/dns.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/domainlist.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/droplists.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/dsn.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/extensions.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/healthcheck.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/imap.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/index.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/jmap.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/jmx.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/jvm.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/listeners.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/mailetcontainer.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/mailets.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/mailrepositorystore.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/matchers.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/opensearch.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/pop3.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/queue.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/rabbitmq.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/recipientrewritetable.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/redis.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/remote-delivery-error-handling.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/search.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/sieve.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/smtp-hooks.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/smtp.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/spam.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/ssl.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/tika.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/usersrepository.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/vault.adoc create mode 100644 docs/modules/servers/pages/postgres/configure/webadmin.adoc diff --git a/docs/modules/servers/nav.adoc b/docs/modules/servers/nav.adoc index 0b08d54e8ed..ce8c08a65e7 100644 --- a/docs/modules/servers/nav.adoc +++ b/docs/modules/servers/nav.adoc @@ -81,6 +81,36 @@ *** xref:postgres/objectives.adoc[] *** xref:postgres/architecture/index.adoc[] *** xref:postgres/run/index.adoc[] +*** xref:postgres/configure/index.adoc[] +**** Protocols +***** xref:postgres/configure/imap.adoc[imapserver.xml] +***** xref:postgres/configure/jmap.adoc[jmap.properties] +***** xref:postgres/configure/jmx.adoc[jmx.properties] +***** xref:postgres/configure/smtp.adoc[smtpserver.xml & lmtpserver.xml] +***** xref:postgres/configure/smtp-hooks.adoc[Packaged SMTP hooks] +***** xref:postgres/configure/pop3.adoc[pop3server.xml] +***** xref:postgres/configure/webadmin.adoc[webadmin.properties] +***** xref:postgres/configure/ssl.adoc[SSL & TLS] +***** xref:postgres/configure/sieve.adoc[Sieve & ManageSieve] +**** Storage dependencies +***** xref:postgres/configure/blobstore.adoc[blobstore.properties] +***** xref:postgres/configure/opensearch.adoc[opensearch.properties] +***** xref:postgres/configure/rabbitmq.adoc[rabbitmq.properties] +***** xref:postgres/configure/redis.adoc[redis.properties] +***** xref:postgres/configure/tika.adoc[tika.properties] +**** Core components +***** xref:postgres/configure/batchsizes.adoc[batchsizes.properties] +***** xref:postgres/configure/dns.adoc[dnsservice.xml] +***** xref:postgres/configure/domainlist.adoc[domainlist.xml] +***** xref:postgres/configure/droplists.adoc[DropLists] +***** xref:postgres/configure/healthcheck.adoc[healthcheck.properties] +***** xref:postgres/configure/mailetcontainer.adoc[mailetcontainer.xml] +***** xref:postgres/configure/mailets.adoc[Packaged Mailets] +***** xref:postgres/configure/matchers.adoc[Packaged Matchers] +***** xref:postgres/configure/mailrepositorystore.adoc[mailrepositorystore.xml] +***** xref:postgres/configure/recipientrewritetable.adoc[recipientrewritetable.xml] +***** xref:postgres/configure/search.adoc[search.properties] +***** xref:postgres/configure/usersrepository.adoc[usersrepository.xml] *** xref:postgres/operate/index.adoc[] *** xref:postgres/extending/index.adoc[] *** xref:postgres/benchmark/index.adoc[] diff --git a/docs/modules/servers/pages/postgres/configure/batchsizes.adoc b/docs/modules/servers/pages/postgres/configure/batchsizes.adoc new file mode 100644 index 00000000000..8c7264ce05a --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/batchsizes.adoc @@ -0,0 +1,5 @@ += Postgresql James Server — batchsizes.properties +:navtitle: batchsizes.properties + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +include::partial$configure/batchsizes.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/blobstore.adoc b/docs/modules/servers/pages/postgres/configure/blobstore.adoc new file mode 100644 index 00000000000..e7c1d341aa1 --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/blobstore.adoc @@ -0,0 +1,51 @@ += Postgresql James Server — blobstore.properties +:navtitle: blobstore.properties + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +:pages-path: postgres + +== BlobStore + +This file is optional. If omitted, the *postgres* blob store will be used. + +BlobStore is the dedicated component to store blobs, non-indexable content. +James uses the BlobStore for storing blobs which are usually mail contents, attachments, deleted mails... + +You can choose the underlying implementation of BlobStore to fit with your James setup. + +It could be the implementation on top of Postgres or file storage service S3 compatible like Openstack Swift and AWS S3. + +Consult link:{sample-configuration-prefix-url}/blob.properties[blob.properties] +in GIT to get some examples and hints. + +=== Implementation choice + +*implementation* : + +* postgres: use cassandra based Postgres +* objectstorage: use Swift/AWS S3 based BlobStore +* file: (experimental) use directly the file system. Useful for legacy architecture based on shared ISCI SANs and/or +distributed file system with no object store available. + +*deduplication.enable*: Mandatory. Supported value: true and false. + +If you choose to enable deduplication, the mails with the same content will be stored only once. + +WARNING: Once this feature is enabled, there is no turning back as turning it off will lead to the deletion of all +the mails sharing the same content once one is deleted. + +Deduplication requires a garbage collector mechanism to effectively drop blobs. A first implementation +based on bloom filters can be used and triggered using the WebAdmin REST API. See +xref:{pages-path}/operate/webadmin.adoc#_running_blob_garbage_collection[Running blob garbage collection]. + +In order to avoid concurrency issues upon garbage collection, we slice the blobs in generation, the two more recent +generations are not garbage collected. + +*deduplication.gc.generation.duration*: Allow controlling the duration of one generation. Longer implies better deduplication +but deleted blobs will live longer. Duration, defaults on 30 days, the default unit is in days. + +*deduplication.gc.generation.family*: Every time the duration is changed, this integer counter must be incremented to avoid +conflicts. Defaults to 1. + + +include::partial$configure/blobstore.adoc[] diff --git a/docs/modules/servers/pages/postgres/configure/collecting-contacts.adoc b/docs/modules/servers/pages/postgres/configure/collecting-contacts.adoc new file mode 100644 index 00000000000..b077a2c45ce --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/collecting-contacts.adoc @@ -0,0 +1,4 @@ += Contact collection + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +include::partial$configure/collecting-contacts.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/collecting-events.adoc b/docs/modules/servers/pages/postgres/configure/collecting-events.adoc new file mode 100644 index 00000000000..431f06aa8be --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/collecting-events.adoc @@ -0,0 +1,4 @@ += Event collection + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +include::partial$configure/collecting-events.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/dns.adoc b/docs/modules/servers/pages/postgres/configure/dns.adoc new file mode 100644 index 00000000000..ffff105f3e8 --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/dns.adoc @@ -0,0 +1,5 @@ += Postgresql James Server — dnsservice.xml +:navtitle: dnsservice.xml + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +include::partial$configure/dns.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/domainlist.adoc b/docs/modules/servers/pages/postgres/configure/domainlist.adoc new file mode 100644 index 00000000000..9654c2c6b74 --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/domainlist.adoc @@ -0,0 +1,5 @@ += Postgresql James Server — domainlist.xml +:navtitle: domainlist.xml + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +include::partial$configure/domainlist.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/droplists.adoc b/docs/modules/servers/pages/postgres/configure/droplists.adoc new file mode 100644 index 00000000000..fb1c242047d --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/droplists.adoc @@ -0,0 +1,6 @@ += Postgresql James Server — DropLists +:navtitle: DropLists + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +:pages-path: postgres +include::partial$configure/droplists.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/dsn.adoc b/docs/modules/servers/pages/postgres/configure/dsn.adoc new file mode 100644 index 00000000000..46cdc91803e --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/dsn.adoc @@ -0,0 +1,7 @@ += Postgresql James Server — Delivery Submission Notifications +:navtitle: ESMTP DSN setup + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +:pages-path: distributed +:mailet-repository-path-prefix: postgres +include::partial$configure/dsn.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/extensions.adoc b/docs/modules/servers/pages/postgres/configure/extensions.adoc new file mode 100644 index 00000000000..c99cb4a6289 --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/extensions.adoc @@ -0,0 +1,6 @@ += Postgresql James Server — extensions.properties +:navtitle: extensions.properties + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +:pages-path: postgres +include::partial$configure/extensions.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/healthcheck.adoc b/docs/modules/servers/pages/postgres/configure/healthcheck.adoc new file mode 100644 index 00000000000..dd0a5e4bcb2 --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/healthcheck.adoc @@ -0,0 +1,5 @@ += Postgresql James Server — healthcheck.properties +:navtitle: healthcheck.properties + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +include::partial$configure/healthcheck.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/imap.adoc b/docs/modules/servers/pages/postgres/configure/imap.adoc new file mode 100644 index 00000000000..47b538272fb --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/imap.adoc @@ -0,0 +1,6 @@ += Postgresql James Server — imapserver.xml +:navtitle: imapserver.xml + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +:pages-path: postgres +include::partial$configure/imap.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/index.adoc b/docs/modules/servers/pages/postgres/configure/index.adoc new file mode 100644 index 00000000000..5ef404256d1 --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/index.adoc @@ -0,0 +1,24 @@ += Postgresql James Server — Configuration +:navtitle: Configuration + +This section presents how to configure the Postgresql James server. + +The Postgresql James Server relies on separated files for configuring various components. Some files follow a *xml* format +and some others follow a *property* format. Some files can be omitted, in which case the functionality can be disabled, +or rely on reasonable defaults. + +The following configuration files are exposed: + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +:xref-base: postgres/configure +:server-name: Postgresql James server + +include::partial$configure/forProtocolsPartial.adoc[] + +include::partial$configure/forStorageDependenciesPartial.adoc[] + +include::partial$configure/forCoreComponentsPartial.adoc[] + +include::partial$configure/forExtensionsPartial.adoc[] + +include::partial$configure/systemPropertiesPartial.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/jmap.adoc b/docs/modules/servers/pages/postgres/configure/jmap.adoc new file mode 100644 index 00000000000..912ba217436 --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/jmap.adoc @@ -0,0 +1,7 @@ += Postgresql James Server — jmap.properties +:navtitle: jmap.properties + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +:server-name: Postgresql James server +:backend-name: Postgresql +include::partial$configure/jmap.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/jmx.adoc b/docs/modules/servers/pages/postgres/configure/jmx.adoc new file mode 100644 index 00000000000..0b294bbfa6a --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/jmx.adoc @@ -0,0 +1,5 @@ += Postgresql James Server — jmx.properties +:navtitle: jmx.properties + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +include::partial$configure/jmx.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/jvm.adoc b/docs/modules/servers/pages/postgres/configure/jvm.adoc new file mode 100644 index 00000000000..28611f12800 --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/jvm.adoc @@ -0,0 +1,5 @@ += Postgresql James Server — jvm.properties +:navtitle: jvm.properties + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +include::partial$configure/jvm.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/listeners.adoc b/docs/modules/servers/pages/postgres/configure/listeners.adoc new file mode 100644 index 00000000000..011dd6c3963 --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/listeners.adoc @@ -0,0 +1,6 @@ += Postgresql James Server — listeners.xml +:navtitle: listeners.xml + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +:server-name: Postgresql James server +include::partial$configure/listeners.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/mailetcontainer.adoc b/docs/modules/servers/pages/postgres/configure/mailetcontainer.adoc new file mode 100644 index 00000000000..8b8184fbd95 --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/mailetcontainer.adoc @@ -0,0 +1,6 @@ += Postgresql James Server — mailetcontainer.xml +:navtitle: mailetcontainer.xml + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +:pages-path: postgres +include::partial$configure/mailetcontainer.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/mailets.adoc b/docs/modules/servers/pages/postgres/configure/mailets.adoc new file mode 100644 index 00000000000..07c8f532e56 --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/mailets.adoc @@ -0,0 +1,6 @@ += Postgresql James Server — Mailets +:navtitle: Mailets + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +:server-name: Postgresql James server +include::partial$configure/mailets.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/mailrepositorystore.adoc b/docs/modules/servers/pages/postgres/configure/mailrepositorystore.adoc new file mode 100644 index 00000000000..bba70563b2c --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/mailrepositorystore.adoc @@ -0,0 +1,9 @@ += Postgresql James Server — mailrepositorystore.xml + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +:pages-path: postgres +:server-name: Postgresql James server +:mailet-repository-path-prefix: postgres +:mail-repository-protocol: postgres +:mail-repository-class: org.apache.james.mailrepository.postgres.PostgresMailRepository +include::partial$configure/mailrepositorystore.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/matchers.adoc b/docs/modules/servers/pages/postgres/configure/matchers.adoc new file mode 100644 index 00000000000..d97cc58fd6a --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/matchers.adoc @@ -0,0 +1,7 @@ += Postgresql James Server — Matchers +:navtitle: Matchers + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +:pages-path: postgres +:server-name: Postgresql James server +include::partial$configure/matchers.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/opensearch.adoc b/docs/modules/servers/pages/postgres/configure/opensearch.adoc new file mode 100644 index 00000000000..16314afb10c --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/opensearch.adoc @@ -0,0 +1,8 @@ += Postgresql James Server — opensearch.properties +:navtitle: opensearch.properties + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +:pages-path: postgres +:server-name: Postgresql James server +:package-tag: postgres +include::partial$configure/opensearch.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/pop3.adoc b/docs/modules/servers/pages/postgres/configure/pop3.adoc new file mode 100644 index 00000000000..95da0cfbc9a --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/pop3.adoc @@ -0,0 +1,7 @@ += Postgresql James Server — pop3server.xml +:navtitle: pop3server.xml + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +:pages-path: postgres +:server-name: Postgresql James server +include::partial$configure/pop3.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/queue.adoc b/docs/modules/servers/pages/postgres/configure/queue.adoc new file mode 100644 index 00000000000..09f666e498a --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/queue.adoc @@ -0,0 +1,5 @@ += Postgresql James Server — queue.properties +:navtitle: queue.properties + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +include::partial$configure/queue.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/rabbitmq.adoc b/docs/modules/servers/pages/postgres/configure/rabbitmq.adoc new file mode 100644 index 00000000000..ddee170f82d --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/rabbitmq.adoc @@ -0,0 +1,5 @@ += Postgresql James Server — rabbitmq.properties +:navtitle: rabbitmq.properties + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +include::partial$configure/rabbitmq.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/recipientrewritetable.adoc b/docs/modules/servers/pages/postgres/configure/recipientrewritetable.adoc new file mode 100644 index 00000000000..6cc602f7866 --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/recipientrewritetable.adoc @@ -0,0 +1,7 @@ += Postgresql James Server — recipientrewritetable.xml +:navtitle: recipientrewritetable.xml + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +:pages-path: postgres +:server-name: Postgresql James server +include::partial$configure/recipientrewritetable.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/redis.adoc b/docs/modules/servers/pages/postgres/configure/redis.adoc new file mode 100644 index 00000000000..c3b2558d4b0 --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/redis.adoc @@ -0,0 +1,5 @@ += Postgresql James Server — redis.properties +:navtitle: redis.properties + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +include::partial$configure/redis.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/remote-delivery-error-handling.adoc b/docs/modules/servers/pages/postgres/configure/remote-delivery-error-handling.adoc new file mode 100644 index 00000000000..7500221ac3e --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/remote-delivery-error-handling.adoc @@ -0,0 +1,8 @@ += Postgresql James Server — About RemoteDelivery error handling +:navtitle: About RemoteDelivery error handling + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +:pages-path: postgres +:server-name: Postgresql James server +:mailet-repository-path-prefix: postgres +include::partial$configure/remote-delivery-error-handling.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/search.adoc b/docs/modules/servers/pages/postgres/configure/search.adoc new file mode 100644 index 00000000000..0c329853048 --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/search.adoc @@ -0,0 +1,5 @@ += Postgresql James Server — Search configuration +:navtitle: Search configuration + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +include::partial$configure/search.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/sieve.adoc b/docs/modules/servers/pages/postgres/configure/sieve.adoc new file mode 100644 index 00000000000..8326b2752e4 --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/sieve.adoc @@ -0,0 +1,7 @@ += Sieve +:navtitle: Sieve + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +:pages-path: postgres +:server-name: Postgresql James server +include::partial$configure/sieve.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/smtp-hooks.adoc b/docs/modules/servers/pages/postgres/configure/smtp-hooks.adoc new file mode 100644 index 00000000000..cac323ebc8d --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/smtp-hooks.adoc @@ -0,0 +1,7 @@ += Postgresql James Server — SMTP Hooks +:navtitle: SMTP Hooks + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +:pages-path: postgres +:server-name: Postgresql James server +include::partial$configure/smtp-hooks.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/smtp.adoc b/docs/modules/servers/pages/postgres/configure/smtp.adoc new file mode 100644 index 00000000000..e78cd94302f --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/smtp.adoc @@ -0,0 +1,7 @@ += Postgresql James Server — smtpserver.xml +:navtitle: smtpserver.xml + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +:pages-path: postgres +:server-name: Postgresql James server +include::partial$configure/smtp.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/spam.adoc b/docs/modules/servers/pages/postgres/configure/spam.adoc new file mode 100644 index 00000000000..bce4eb9ae1a --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/spam.adoc @@ -0,0 +1,8 @@ += Postgresql James Server — Anti-Spam configuration +:navtitle: Anti-Spam configuration + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +:pages-path: postgres +:server-name: Postgresql James server +:mailet-repository-path-prefix: postgres +include::partial$configure/spam.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/ssl.adoc b/docs/modules/servers/pages/postgres/configure/ssl.adoc new file mode 100644 index 00000000000..16924ae6b2c --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/ssl.adoc @@ -0,0 +1,7 @@ += Postgresql James Server — SSL & TLS configuration +:navtitle: SSL & TLS configuration + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +:pages-path: postgres +:server-name: Postgresql James server +include::partial$configure/ssl.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/tika.adoc b/docs/modules/servers/pages/postgres/configure/tika.adoc new file mode 100644 index 00000000000..90a68e6eb8f --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/tika.adoc @@ -0,0 +1,5 @@ += Postgresql James Server — tika.properties +:navtitle: tika.properties + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +include::partial$configure/tika.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/usersrepository.adoc b/docs/modules/servers/pages/postgres/configure/usersrepository.adoc new file mode 100644 index 00000000000..8f6d3cba524 --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/usersrepository.adoc @@ -0,0 +1,5 @@ += Postgresql James Server — usersrepository.xml +:navtitle: usersrepository.xml + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +include::partial$configure/usersrepository.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/vault.adoc b/docs/modules/servers/pages/postgres/configure/vault.adoc new file mode 100644 index 00000000000..dcdfc7dd207 --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/vault.adoc @@ -0,0 +1,8 @@ += Postgresql James Server — deletedMessageVault.properties +:navtitle: deletedMessageVault.properties + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +:pages-path: postgres +:server-name: Postgresql James server +:backend-name: Postgresql +include::partial$configure/vault.adoc[] \ No newline at end of file diff --git a/docs/modules/servers/pages/postgres/configure/webadmin.adoc b/docs/modules/servers/pages/postgres/configure/webadmin.adoc new file mode 100644 index 00000000000..161652dde4d --- /dev/null +++ b/docs/modules/servers/pages/postgres/configure/webadmin.adoc @@ -0,0 +1,7 @@ += Postgresql James Server — webadmin.properties +:navtitle: webadmin.properties + +:sample-configuration-prefix-url: https://github.com/apache/james-project/blob/postgresql/server/apps/postgres-app/sample-configuration +:pages-path: postgres +:server-name: Postgresql James server +include::partial$configure/webadmin.adoc[] \ No newline at end of file From e1d49ca8d6b4c23d26bc868b88b05740c06e0e4c Mon Sep 17 00:00:00 2001 From: Tung Tran Date: Wed, 3 Jul 2024 12:49:21 +0700 Subject: [PATCH 335/341] [Antora] [PGSQL] Add Performance benchmarks section to postgresql doc --- .../james-imap-base-performance-postgres.png | Bin 0 -> 376870 bytes .../images/postgres_pg_stat_statements.png | Bin 0 -> 208175 bytes docs/modules/servers/nav.adoc | 2 + .../postgres/benchmark/benchmark_prepare.adoc | 40 ++++++++++++++++++ .../postgres/benchmark/db-benchmark.adoc | 8 ++++ .../pages/postgres/benchmark/index.adoc | 9 +++- .../postgres/benchmark/james-benchmark.adoc | 10 +++++ 7 files changed, 67 insertions(+), 2 deletions(-) create mode 100644 docs/modules/servers/assets/images/james-imap-base-performance-postgres.png create mode 100644 docs/modules/servers/assets/images/postgres_pg_stat_statements.png create mode 100644 docs/modules/servers/pages/postgres/benchmark/benchmark_prepare.adoc create mode 100644 docs/modules/servers/pages/postgres/benchmark/db-benchmark.adoc create mode 100644 docs/modules/servers/pages/postgres/benchmark/james-benchmark.adoc diff --git a/docs/modules/servers/assets/images/james-imap-base-performance-postgres.png b/docs/modules/servers/assets/images/james-imap-base-performance-postgres.png new file mode 100644 index 0000000000000000000000000000000000000000..47bb0eb2c960f341d321396ab8c7c289a5ddccbf GIT binary patch literal 376870 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

    MA%)y!!vRd&{t@ziw@G0V)lO(kb2DX#vt* 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

    T3Xw$p^EkTM=C*fp^N$30)9uO23+P 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∋e9-rCP3<;gH;#Y?HFJC?Ti8)d~dVW-~ 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

    2llmi(H}l6+4uuIjzBCMHtEy; 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�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 wKXH5Cs(J9i&F2N$;H`D!ohZMCrZv7NtXgNS7{z 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

    AnKuW>ZStbn%(0b^<@U5uUx?YgatHq{rlP zi)qScjQ69TcH&`Xpm}|<^=EK^bB+#vJ`C)gz}KO%#>J+rZJY&xx?-7|5`ex$ObQ>G z42R-IDi;+a+_T+CIm!5;QP>Gn$%t=Ht#C<6<(f@#6CL-vX^6nl6%66qmrTLJ{PMdk zT|r;K)(?~Q1XTV<))Pi3*+N7YL9$A`OEF3<8*O>HeXiT%!xVp8y32A%+yry0R9JW+ zyEs4#*PSIx;FGQZIgvHIG$`$_{QPA(FFC%N+Ir*hwTq11BBf6KxJeyt?Yj6vf9u#c z#a6%O){P&RT|9>~x1(DH#T26P8S7l+kL%HuF1v0kCFoLS&jU?|XsK$;;DHWef}kKG zEMEz3#_nHyM;YdVFUe+L??y5P<>O0U-M8^-T*F7+`#aTzd@hC03X4t~yJnfs z@Crerbi~fM+o|Z{&F;Ygo4R+d>q}9y*C1|seId^KmL`v#vb~P-m+#h|(mHeI0XQxI zUEBX7TM-aAbtVp2lh-z2eKO(n}N9C|JSIIkxpC&$%xzYx@<=2t4>4 zg=1Zvri{;@DAJyV#}*a3R8$GfIoh0O=m?4OiMV%T#R}7w=2L`y%eYz=QXGj_y|zMV zb97_ud!n#gEeAYw~_%6;>{`q@5US7bpEn8v@V1^UhEGB#C~&-(qeE=>-z8}o+_KbOj|4Ccb7 z|AunH2_)0Z37J&>EC3{M3??bnQeK`1l|Tqn{Bh7-YCgV;g1KKnVK2W|i1<7NUi{9I zrkp>!$l1EbKT@%9CTpG`b%p2>=gj_zbB>8m`5_zZy?fau$stGL3Bs<|#fN-(v2skb zkB7gKRuN9!Fh3c4-Qbz*is4hrq2$@RS(PLrajH&YXj2Q95? zVYO8}CdoSeM=;opH41t+oEJt@+h(OFVXW#nyU&0p@KnQPaN3adV%&2t#>x)L0Oexp z&Hi;Q=9e5rHv6NZ;(^`3sqDhmj)$th%NO7CM99tSM}-e4JYJ5^;^xsG-|;eI)l^KQ zmBk0_ReZcsLC6hEa$(BTbYY6`vb!0o1Hy*xEj;F)+D)<^u-w^c@}i-8g+B%Y2C#Z* zOB6%b&8S&)Z=8_HVy{SC;Ozi^QnoMOe(D$DGwWG zETbjzw&qeu5YG3%Fte%Ne^Dnf`D;s4kImXODv-x5_3qOLT1fQO-GBr}Y!jBAM)RJm zrI>qLpmKm|O66;DPP;&Mo6FjvKkI*k7k^gq^P=*&(8#e73Y>1he!c60kiY=N%Bk#0 zMdBxTEAJn`TU-{1u z`KKUhCU<&O`SJGD*D+f*-!OCjbKhk0n~(T+!qZblD%Rw9ja`$z28{^!AcXA5>m5q- zC0<~+)WmZqC{CfZ_`y^O*BLF60ecjrOyT2)R?B9dC(I9}euuNa!t|bXSq=^#Ahc{Oss6C& z%YVZ!6er3#$NL=av80Cn_nwj7ORs&&Y4a-Rg?;l^<-JcoeLq$Ebw~HTnpP#roNW^= zEoYNJ-o`z2goXzE7lsMQSoNFH;)rme3&8aeU63V`Afo~M6r{S!$1A9gen?m6Ms8D- z1=Rmfa&kQ26zp(D`$Xdjqf4GD@C4}kYjArdtt7>JJ5v^R)>UC-x1kt4#|@fHV%`k% z{msni7}9rRE4WwbXbZ#0!mJ*o!#GyF)xAB@MX=UnN5z6g8m5fyygyIZDZ%;3WZ=vB zWa=)4Kv!Szt^l33b@IZmXkO#2EZ~{>AME9o{}!oy zTga}f2sv}_@sh^%bfssN@Ak=7_W@stl*vQ%t@zdS7ZhcmrDNvSyO@oxu3R}62d99a zgVk~ob3jnBz$x8Kyq#AyDMBYLgr&Bc4MQPq@vw?FyU6G~;p#JqM$a`7tRqRiC>KvR zE|`ffulV#1`Q(7|k*~Dq$&1>p5P%Ar;mRMSN{1VKIBgxZkcbcOpWfo-%+-QvWNeAU zSVY;dl;1(N=vQ~O7)d3IM@S0>{{>(=Zv5acU(M)~bc&B#u$!%qUKBBydSLZODcI=hOyB7fw2N!+8 zr^MczMd-uz^@P++Yv-)$LY$XLZS}i;1!8p?`E>27wbH7Y@F<7F6CqC*PNrZG|H6Va zbNt@#W<>9QM<8RKj-i=~*LLZtOJP?rjMJD`8E&OXR~Xj189gy^VrQ81ns0jox33&j z0|?cM4dKDy+oxpBNuj&cDq_9VJ2xm*2A2Hr_G?rv^Um5Pf-jy-*dad1yI=^zyDV}j z=TlpMQOvd6laa-bTlhk4{K|`zFt~+c+^EbM%<2d>?B= zXdSS-)k%@m)jz`To=nMK5AXV9F-#2V{^{e|H?{y(2k(aTBhNJ?bEFDSTx8b1VV{jy z{r(lWjOz*~AEhn}4*7B>o^b^uYsJ6li`R2F&|k7P!&w+MJWUz1k_}yxBM1WR463c0 zptYHrzJDwEDqPfnJ0j&tDY@J7Hy5#6s1LZX8$8!zD=4(!1<7BMg*o9^cvb3KUqcfo z2lo;u^vC5K+4@O=Kel>&o%4y)k7Ztf>?N;B7P*e+HM@3k|3P{_bNNv^pcZ>KPCKLf zUaZFoxAn!Y1y)m-H_oYl;Lwk*@hKJBZ&N%S_L^Pft6;63kcdfv4+?H4o~?r6Uw?U? zv#M_ID{66{Z4lZKVmIXKj*^%LeTnaVC?YeeY5;5Lz0N*3|8i`7D+reK*y66jB8S;% z-S8fDpF+Le;F3*j_AT`uchnt$-@B3#Kqt?yKj1Q=A2_0=$CE>+ldVh`KSDir2@%oK zEqTi5k8{G#^2~SQ{}V|WLzzDEEGRw|ki$f2`h47=JP%ntNb5%Y@Vm@Xeg3<^u4M{R z^?^ihOf2%J{odG0`}ZU5Id&udBKCpNYTe%9yjZjJFXxZ7gzmk|#Osr$zn0udY5s6A zyc*rH=NZDx?uq!+JAM5zj}ch7r^-;YxPxybi9W&Ew@^_6ZZmz6u8D=;ZHt)NowS9< zp$oq+1d9x*hC@frTDI)>weI^|=vft0B$=R(U+2=257#`*b(b#%4GGJp=-)-8ZVvX< z4)jfF#{=A6Hqn9ILdfhFuI}rLK>Xb|FJzXjX*14;;e=b_VQm476E7I?`vw(`VLEUa zSR?lo`Ev~W=vn(cV~aNS2A&DLy$F#F;dCHTmz(CpOd=0-gaB`{YVyJ>*tuy2LqGK` z<(DRa7@>6P+T)-p4vmwKbFXY89UKLO+_cTRJJUEU;Po znkG{9Pl)Do#)aqPcNqRy5Z`Ex#xX<_pIq~bslVWx3@r@bvpusuBMP?<2!o=iCx&Up-yR993(7Sz&2MnCk38<1M6e}l zIAs}iH&`Fb(}r_2*%Up{=5+YUU)zKmY4X>fXHokQs8MX4gm*pwjvuCKbzbQB^}+bT zZ@hNF{^}I>=j^JFS^5|pJ&&f$3PwA}9?_N4#cp$*Qdebjd}#sK=AZc{%M>05*)X%{ z^XNH6KQ$YZDG=6=!m_L*6s>5TwDcUQo^_aVah$O4bIkUi_D)aA7h+iX zbAs9S?rF{N-qJ>*_hD`HEyKa2;Ca@ebG%i^NmVU|sf~|rX075c1O-gCAWoZOIa*Vo zRP+|gGz*}QAlocfGP;6fp49sA+rupdW&;a@6C$W{KkK_^)h*nq5S@PGpxbbCTQ_Y~ z{T4qPp@Uc{snI8lpPgaVhQ)^l?#eQA$1cv%W@3qMq%V9uQnG|=v-(@2D5M+ghrnwj zE?375IIwGz0^VBY5~529vU0AG7Ti(d)%Ej9g3qSm2@$)?&NUHyZA`7PMlkQ!IL|WX z$cYp(?U&OU(G1OlTDy`5BehXIP6M8mr z!HqM#C*MDLzG0Ht5{)MDv=}qY*;swP ztl)r8$6`8*o#NGAlU5~ewRhV`3>5vR88g;MZ*LC*)ku)1j8$AWNA(G4B#<|>} zE-3;FcEdKeK;2vZfjKJWf7>&Vy0EJ8CGMYgbj?aD-FqPX)zh)axr%vvi#b4fr=tjZ z#6Pda4tzu)P}s1A4^O_!@MHY}z-CLX;d=oS_%G6Abo1OT|c; z@nmM`e`T2DlDzmkY*)xWQ&E>9D{kq(3%y3Uw$4mUaEG+l{1MQpQ3pJkG=2`~X5tZi zT`S}Y-QC-bm$FW{aYDy3m;dcFwq??2zf;dUY>3GyE3^Fb#<-)R*6BJw*iAt??D9bJ z__q}8;^F{<1C-WmU>QHsZthqhs>O$QDda%`h=p0L_LIO%rqgIg5V_tGfUjh&PMC&O z;)$EuoWMKGz25J3iUr!%_2scCdTjSL!wE-Ue5Xx=p{T-5c!qx6I>N2U`ffjJ6&6%R z;18vb6W}H}UtofZ4+?%G9oB|ObD&yU^62hRS^0!oINgX_c z0Hx-_wH+Qka^t~|&Jctz#71A&hn8s<&^{Ufc`GJm-qj*fJ#IhGZd>oOE_LQ+{(!J? z@_xp00*(xO1f%}wDOLc?&$jD^t_<#R3uIZ-ZpPXmI*?AMzVozt!to55MGI$}W6k0(Q zdbdxYwI88;9+~~w5hqiKI?flK0&T(PsfyN`!+utl9u2sg59)8aw(`gxbZz- zEzbtqI4YhfEQr=e_;y8`>3%RH$^c)bXvVkBaTZBt{E_#>wlRHm2b{ z{fu)JOvs|O2qZEE!g3z6CcSg71^^ehkzhlG$%NPnDg!*YVS9DNxm~bgOiiF_bV*KcU zJq)pN`<(ABpo|vW$he9Fw_w3y7k#f#Uer}5ulEYtGJej;4Nx>r5qL=_D@tk%9t^%) zxIUD{BaB*pUj9fZ-ycVr%rDk4trRimFX{6TO3sQ8Zg>n-YwXz$|B zcUF};#|Y_yeit)bh~u-VzAqP$R7$}H?^S*dWFCJZhClp;;C}CBMVycUueo-9!Kvqj zaUOF{`HW{#8$WwV`V8^T%4Zbkf7#UI&Yi%ctl@r0r=veLKLH5>Js;bMofj%J*#na3 z85x;%7HAkU5oTXR4E-T;dHlWVURn}uIZ8MI4!W2lJ6;TFOX;MMN~Mgj((Bi z?zQ9_U+A(nDSA?)en4bKL4#NgDz(vDYXStO==fqeKa zf&g#ovVNDN&8j{2(JHPhihPW^B)Fvak3b=!Y>=(&NVo z9%0i*|DYz<27b$!B|A?0JkoM~IsG#Wn+a@X4*wTuv7n#-ocCYE#zEHVLG&|zv`I?H z6=oKZ+JYDPDC@_zCb?{nNNWxK?TM44KLHm?Pt>!vYk(JVOB6yju^h4C3Sl={JHbVkf~WT z?S$C(N^Q*I`DkH^=1ae3#4`D@@kW8Ax4%w$`4{r-%xfq9daj8`EXnnUABAEQb_mI| zwaHJnXAIghgxblUf7A(7gZaQIrinKvLWEPWOc*6;I#3KmD`uc`0(sl5o@!*nM)U+y>vA;IJJL5bN*O~}7<&M}flj2S&j`!4m zt4Z*uNaoz~pCTCr8il_^GQuPWS5c_~HJ*GXXALIX^&zPf%vAFaD&hUJzm|s`92D7; zPBHR)GKc3s^Ja=^wMTZJv%Z|utoxVFC8&VeJ{I|_jlK59`B_60!}0vgfMA^4A&zz&|mn5oPbsZ!@wtkFC)NSTkg#n#cu@*%Fs*F(49u*!|?s~-Hi*q;mQ~rK3>Xa zhdj{d%PyjLv)g0cdsTBOv5z)!U6Fqj5lY5~E=}gn@yWHB0+M+JyXgHT`6WS6-r4B# zu~FnLVl^sUJWlwRA}Bj!E%8NB4p2xNu~1Nv+=l&fQ%XgBqOUSA(VF98{T&SFrzO0| zJ2JtA?O9uGIzqZ?{npG&BtK1B+g%jLX%f`ZyUg@;1VWw^7p>3SICFpBuZC4j z3oQugWBI?f{(^Y-mz5rmt=Y$P*7emB}1S>GVM_+_4T;=jb>wFKpd=r1<5dK*+9S7%+OoMDfmdFazc}GJ8gg zv=r8iWI$4v{z^~%_@nN=VASEGsn%Q*6^Ds-ll-0dnYH%`XN`h3B>X+tkU6&_Cdc(* z8F#sIt_%v9pkkd*jSaN@xHV7A+S3ziM-*hgt!dMaN!YyrRO0wzPp@oh+?bj!sw3EZ zW@m7~&id|gaR`(wFFIadnz$(Nds1G+u}|h)q=(CvExXjU0&09?$^B71JSH*t$a5`4 z@nm|Z;#BJ8Z;`PPr$QA?zQy-|RsBuERU1B;6|;=+O!Etb>Mko@6X?_a-6~XSJW4e2^r&Plz;Z9? z$3D12Wb22Nz*z##dtUVt*>u_EcXC=Xsc<&jYG znI2%&CJt^Myun>PyPz`@U#?hWj9Sq5SrtJUWr`z=pr1H_LJ3tCHBJ20JjTgY*RS77 z!&33JgBkerVmy8?ZG670$0KNLIIeXFs} z>$HZaO?UGV{7dL=?4yA@6ru{x|0VNDDERi|Z(>9OX2mIO|bDf~l!69T(O%5+zAdR*-RGvD;4E*1$7nMkV(jN$rlIX-XRlx@b-&r`-Dt zRPUA&s~`8Z7t$uaZ+wZsy;6dss~Phgl{SCG0S19qdjj0Tiipb37uq_hvK(7qjf{!! z?i}#-NP~a6rzx4?yB{rY>VI?}Ey~H6##LNF`Lkk{0V7fUc%7c(4Boj$?;r%sioW)6 zW3AWY8=9Dvx-M886-<?art>o_-*O8 zl&e&>n@sb$B9Zfxee(wY66-L9BVQhiZ?(rhejO&hm4F^S--}M3NW>3t>91|k_I7zD zUD}>y5Ma0c!FbV8gf03>CAPXpJ^xZ9Xooa4@20^kM_+S!46pTWa42)q?Cx`egrDE# zCQSiF09biMB4!07YQvD8Xy$7zV5&GDhMCiEI2(>tl+6n-p64HV(vqn7lw9dF>nK9$ zB4pS{E_+z$xivvGmmVkv`(*{_Vxh=LA~}tAxlK%!@g4-Kd4-G|!CDRz~IK zQaF@r8H$z3G%&!bs;J;qSW$X=Uhmy%UfMn^)YJa_-u^bhd$7m-$CRW-I&Co~OIAfT zy#r|j7S7`fVY9sao5?jiF3fvxeT=emSKbNcmrJJK({(rsH3tx+PRq4yI!rHmd?S4Y z%3vPlPzmB$%=t^u0Gdj%rMdwq~p^A@wr@)<`^o+nO;SP7gT8lPff-}=S*z8rf{3qKTlWNwT@O@ zWp1~f$4i&^Bv`QGY|&r<^E&SE8#d*$+QYs31w+rG?b+Y|GNc}+2!f}a5vm@Yr5|Gg zPgaT_UapWQ?1u{6@OK3co#mWhAx)e2eBau&ceoHUbfNZX&Xo&yf5 z=)ZJ5xeWE9E}r0TNd6-G-{d;Yw$@0EsHle+pUn&{qz3noS z7s#GtQAM>1qhRjpEMnJeVU9w+!el+Ci1rKjzXdeMziyo^cWocFL=Nr;0;4s5aY1~d zx%wVi1I8cUEPfgT-}ys1G(U}A^g6WMZ~X}+ue!uuB`yvnfv-&)tQek0TLILkJLb0> zW(G8~+pHees!~a*b_P^5p8eiUaym|T-4ZQ=?h=nh$0QeJSLm80(Foi!4-k6Gs;;on zs@Yfmuu^5=Wt~w2Wfw8+sY&vKtY21LK^G&d)=VB73{PQcl~107h&0p=y&Nu9-{{%^ zBe$V7S6cj!iDjTCnR31ADrgVu;#w03@cy_4cS3U3UUcWk>e&$m{=g5bD%(OoD1*qL zwjBLP{>hiR|K>|p??I{yj?d-tjq?K2wxl!A-zS441il_~O<6YR?-mVEAcK6#uOza7 zGUl9C&zSouWZdsP7Xcak1)KOwkZhZkye8A)^Vy$8E9~(9&pY57B3{O&4ETXl>QxWh zU$Jpd6OOoe`}7;SN^)1xj1(5 z$(|3`u;tZ4$tNDC2B>}UN+JnE5Fd-Lk^1?# zs`fHfQ!j#4dKgx7?6&u3^Vg~wS=DM2$@FW8IC;XK&fFTl8%9_)VVf!LctAwUB{9=V zFFtTqDlFCMf_^Vle%Nos`=7ENN57%FXL)$5O`Y7fdnf(}<&1zW zObzE|)K|WCwmo&RJ&ClibhYTiF#U|v|2mnlzA>gV%hfbmvL3S|yWumj+G!iTW+Y{x zlEA$pws5r9I_IVV#p76${8E)in%4He96#p`&gxGl)mZlbf^~<##3uoZ zpb7QyovU-YXOoeIOVl}d>kTKeyRYB5tf(G0ZB>i?0JQE($<2I#?cDmJ{LsT?93zVK>8Yny!u@wD$oZ}E#AEp(Z*wf;-c6geg7 zC9gV3^Dy78V=1G>dw**RZI;A8t`^jJn$+>EAyHyj@tfoK-DXsz5xlOuI3o;lq!uQd=K;UDBaQ^*=w$i00ep|V z&aa%3+0J1k#sQ<5iocRDkrQ6kd0c$y-mk1m%zZdUu=WQR6Zq39!)1#np?B8MmvXdn zhWhp$hnsf?gK(o#14`~JLN=+d4>BTtu146aD+Ko-n)-1M)K$5vuqdru&A6t$Lz7br zVpqc-a=@Yi#T)!@^k_?zw)-yF@>LT%WX@~2qLVmgnB%-WIn&S;V9Yh+XK9H_?f7xa z=Dm-B`!&MQGOdCg>+93Ujlp6D7=KhM_)^VtV=Zj@MFx(NzUOvpgwQL2`Rc>sH%jpJ zKDM!Ny!5SjMRo(rG}Uq;TRGD)aOhYN75Q9zQQ4M6fF&JHfp4JD|B)QBD^diEQ3&0~ zBUQg^v{0<3f`hHXj}HltC2X2Qb*15jO*N{zDj`vnPBsbGRL82qR2Gkx1yGnALE&smcVCSumStect zS^+c^I?8tgH*PW956M81rqur*-rhT^$!!bYML_{UI?|;G0wP@nq=R&%2#D06fHdj7 zB%w<0UFk@b-g^+Fcck|odWR55!VR9i&)IvQGw!|Pj^DU{Q38SFo8Me(&1b&ve93xG zaCC>G_duqIb&l(`DKhk6-wX;He2p*WDgGuK!c)a(ctB|Mx{~GCTJtv$>Yl!T%%Lkx za*X$!_{WpC19xbx2=^<>P!*V1to`T#KREGtaKGAS!)m)#ay^^(r5(H@$|H-3*I&4= zr@Ly1_VCtV1+S~y)tANT*JiG4u6W>AIZOtJQ*=(k!71rD#5Iphu+cF*>c!`S=-LQe z0?MU%%B3j0*?4Z{yUBrZ#UKjHAAL8bLU1qm|9*79L1!F z!F-t&O{=2)A3k*dt?N)D8W7b9=khJjk?u^)?tgl0J}%86@Hg4ds`6pDH0=^b@BKBx zFUdG-5?k+kp+g!jHwfxD=iuF48niey<}`1eHJ*dHaG$e;d)SZ>DTl=@7v%$<5OdKv z1ik!WbBp0BXw7@iS%1r$j!aQD@u%wHXr-f7V_uP`Bb|`gmD*lLScQ1E%&xe1x`fM8 ziSM=&xfvd*SX|f6N8hSCr+M>kP8#bw0GA| zQ@am1A_ppkMhaoBgccZhy?>~CUR*wiVq(K1WWT4SxYv5QjVFB_gB%YRL3uO3Ry2Sd zjf~@4e?&H$0VD6af0kvvYJ&F08g|?RS@T2&vszinpl1JBc;-q7OBRoUc6LuxN%j~VnnG(FR`C~Ag7;@RzpIP`LFp8Sr++B_}g zs=2}0^2kOD_7%R!2Q1yZ9JGs@3S85!a$&yF;z-Uc<7{)o8l_nW{*1c(=6fET5j_vY zcwQwuY6SWq46Soqf}R#BnYeOuL)-omuRSVFWnP=0A~BwMNbJD18N>%U`5>K2_!E|{5OUO{F{`EBL<&JWX5 ze5Ef_YYHCQGH~Y@cUo+5ZkX|XYmF5&`tiR2Rbkqi1CaXefU;C>UsSq>*+f0V+{njy z%7P8R`r{qKwY)eQ26Sj~QJ9xTp&s3ha*Sg97tU%k0}tCorFqkGv6eSSaG)nV2->dI z5KOUtfhT+YCl_|BVhBs1cMOd1+wno?LLRNra5(shV(1{^sS1VkQ^THmx0wLq#(}BL zdB)Ly(WTQJ8R}NwV`<^HUe|4Sgypm>v189n%T=D|@-vnia>M|^Gi$Y!Uwot1Ik@I`lC`wZ)OCWVNd1aAN>M1&F8KwW6P`KJK z0)Bx#FAvFk&&iOpN6tWfOO9oZ>t60@B+IYXrI;hB4;G7g(Q@1m0&fdD;oxZwmzwX% zcdPo4Ya!IoLtDN<^i{ryOCX7#P#gy?nQ1JLvUmyi1v!}1az`!Z)r=8fH~_*=4RTCI z>nf*W_-wd^#7{rs-6L64VOnEV6P9xEMkqd=AdEwYikkf4zmpOX@hPdxe%c>W8&x~9 zOZKB!j2MZBIs_(W^7IF#Sl73D&zPFghi*wA^ULAjIB~Yq_&wk}^ukddxVJvuo>5nxyS480|lrqlWOb1c0gp*~yM&8aI2l!2i!1)t!Z2X#_t_56zxK_jr=+k5=H zMPbjd)sMXM<9mVfK+?En5!vT!@7CBleHGERe*c(#7G?MxwIl0Azt|2>9&3hplDKAl zFI!`6oqk5M)MaEhA)^qg|I6HBe?-SZN^3uMnVv+(J5_m=CDveZTGf|E*a5l6gWdes zI$2*3Y0tZqY<48%|5z>~X`<3GTrhU;7oKy{bc_Guyv2B_ICXpnpZ(LglC~pEI@f$CFA`fS0gZ+~pU>M(cBs!iN;D z#__tEtINAEI;VX7(r3QFn3=rPnq}D<0N)J)V@dZacq}PDtJISZxBYH!X2gl6TdP%0 zmgMgMY~uN>kyQp{y%$QPU#+Z^Tq~a+aaL}Ohu6=}$zyS zTcPX-`Lo16g%j+2ajA7*R4cZ}MBV{8E4ph+i;x2H6sg`{jmIG8a&;RnDf|j2VE=ft zW^bhaLSm`4d}$uz;OaqUiOtxD=M$ULS%fVKo=)OEGsG2`Q0-C?tL(`p&~u{gPc5X- zz+s?VF8B>1YV&46#olF|TRz&IYcA%A=8t%>`Mo!Xwd8IM&6IP!DQf{hd(0-e6h9C3`}_;&l>oRts&Q|o~d ztLGNZ%?F|zVn34o6cWQHO7%|WeZ2AF=#{ZQdJM}G|mATuk5ixM;Q80!(0?xnqaF&<5i0>2+b&2?!_du+V_NpAfF9YI|KXv zvOK?THxZ1Kc8K@S@Xn4=D0~k_Gy!zBWQJj6;`ysy)*<2JZ9B$eeG4#&{A&@(Gk4Z7mMZa7vHtEX-Ns`j-bn= zm?kwJiiiJH*PN2v=3243!y|-t6;TQF96EL>g9RAnss0UMEqyQehF-k(+C{@GJ?rbr z4;<>Xp@ANNskUhaW^3*(f{1woJ#hVlLhd;F(UXkRB^UL>uQ{$YojX;Qs0`D*gC@@2 z+wp7K|MX3(GF1SvgpxuD)xga0c$b*;o5@={RM%?``)>Ujki zJmug{s1@z&q-i4(_hpbz`lr*FanaEuiu3Lf_52n7Lfyi_!&&ESiKp;(-n$+kjC+eZ zpUQQGPushRW@Dlq&5QI)xf45K@l5CAeQvETB z=nuZa>}7td%Ut^D!+(EUDEyx<`9HwupXO@+Pa2*7kJ2O(VK&VpD(DxEA@gtIsg`KD z4i1S9nd0M;s^S73l}n8{BPHek*rPp?xj4Jzy<`BJeM75}ktbDt$WWYr`m)JB40!WZ zeRkUY>{HXWVD6)y=XA(+RqW6AQ_p%v)EH)tS!gRo+c&B3IWkK0j1F!O=dcOUMYdVQfO_Y=&d?@?b%4c54u(u1<%cP*@woxd~ z8M?`#3gtO^8qECdO8o-m&W8(W{*Vn004$=9hEI>8AdLT}a{g3yW7}^+K?UX)XOm5W z@eBsBabNV*HPrz(5=$KmaXfI01X}e`R@-yTsz=jKk))aJ04*P8#NIZB%OF-k8DlU-Xpi`t=-<l42{cV9-|EY+m5@S=3Nud)1tZkds+U8D4-x5AZvUJ;LolW(sq zCPK>3VHH>FJ3BR+>k7rCbLTHTuCp+w#or9?c;YZs{}eXJAEoh}CZ4K3aQf5?1n~p- z6!7@Pj|gP8BpptqUGK@$3yL!uWGY)GFHpoaf_WNp|EByo3&;P>VeJ)n#mK zOZiAw8*6pL+=!9@}yJui%WVo=>YP3QZ*a-3Ejhfum$S%qe2-=$HNvn!{-h$P!zg zL>mmFFo|G`#?)rFgU*Oir|swto(y{h5AoYb4YZG$KB;+C4~qfbgOjO%Q&J#v5c*Po z_;$LFkvd<@n&QFr^_GN6H>J5Dt>erx=BwF9;~pE>T7PtIHqE!1CE#ydw|)Aw-1 z_V+MHig@w39XiA7!e-glCAE|QbXwUI#Wk|w51Q*V^_8?np5&~MFbW3yZ&#hb+gqRs zf<4n#eT*fQhNUpV-QCLb8LQ&vp@p5f2D*q^SJI{eFPqL5*g_?bodE00wv;#HFZarI z3j1G*vH^<4yhYb(&D^9fz1KB8rHFR%I|z-y3Yt!@uX{&-d7Y%0NYIzjMu9)9d#5?< z(3y$&-{V2u5_XHc_d;b2?eM1D#=E;H-bLvojygQRhxli6F>%#C-2(soMQ*If)_b)$ z#elZ-1UbRZY`wf4SdS5ARuKzN{D$`e`5Q^;tT8qE@V*biqjQ_=ww$`ZjX4jM?M|GJ znrQbeEd&E7T&qo;I?xt&JvJ(^g&~4fZ6S4Mdi}O|YkEC;Y8GRe#ODOu#h>cvtT?i* zCrRb`LkWSBlWi+PYpocbtd+p>=I-7LwiQYUkB+DoOR^%LbIEX$z3vL!`T3PQrq~`u|aHLgv?IR z_<&74H1N8cyfm=YgPL#uotRMSKHaRQ>g-NUfdMF7Zk8sQpI(ITWC$;_@+r7j1e@ zsEf9VPA5C+l-~ZMMaP%T0V2isa9k>w9OC{fJ1D_p)>bC7ZJ@>Jd09kJdMI z-1g<<1Oye%2SpLNCP8V$c*|>!HQmZF4(jwYcGvs_d;2lQ@=`MWzxX_ocLpvg-`d6; z5A4ms0!XzQs8DaTk$%rRcyev1T>?6fC3o5U%tNBlcOd?R)Q0NU<|uV;%ONYn@;jCL zjs>RnNNIzZPYv>xUmOPJBI2_~>@4iYM*xIyQ=$hVbHABL4cCv48dprJk)6Sk5$%PiErankGpiUhzdiM2u*VNFS(Bowi>_>*sH%5D&N!kfP<|0HzxQTwPmYk!Ix{f1>w;gvSkJ+w&g4j%{^wC-8R_H z_H5^ze70D~FKFLxx{xnwB;Jo}cIUDvpU1c@zNUV!Ado7{OZq$(J=17|)^RS})n(Sm zoeUy@-)FV!J|0D$=F(gZS=Cyc*UzpR=Ixw(y*pTA_{=^J%UJg{tSX1=b(Cri=71WC~7EbQph*|rc-*o1L<@U)ojln{{+Nr2hy<--KZAl^WU8vzsY-m;q}9;G;0 zV{~T#&W|)f&=BfE^vA{8i%}MX-Hp?eFM0ewU3+>Gx8yQeYhM6)nuQD98vKJo{8vH9 zm7PrztHnDDfJc*oql=3+Va2Bae#-BL14UkGZj@c$#8WO23>_IS2jSHz?G`{Sk{(W_ zwiDgN>0s<5gv%FnOEn%b&)`tENW1hXHg%*=mxsFNL#!cg@A)NnjFiMU=%dW22kDnh z6mN(2+h$7|S%TvR?>UTZ6~r<0+=5Z8IiS#!xH!6xSeR+G!tlCLQK+T5*n>P}_y9lP z<0e#C5y`-?&od-IEuGWx{)&G#|GCT;%Eu{{tYdke`Q0KnVZ z((5aa`fU=;fX00`dXyGt2EKe0RnrNw)DBtw?|UW<2dxRTupi$_Mo_#xH%>ix@%|(S z8oGbvVZ-3x0gW^HdDx?7V0lutW1I@(^UJIPo0dO{92$&Wr+-JEB`TxB+{6gXa6si@ zcuGa>KFtb4+x8JFufTN!=pOzwCpA-)*s+5=N+JF#ZmjJxU0#Higbiik zqDIQ1#1W{Hy13QE%#RHe%e)g4j3e> z;qve(m3N=c8XS1uAr;QSy*u>|sI|jz_b z-NY!ZQx2z9bxt)z_YT`$E;>+%eMRjNZ+vzMr?IJBZ+fS_X>&e{uu`Opv_YTh3op{8 zf2IfYy?`I2A`-UbGV){-XAbSP;V-{pI3*XSi-25t++Td|4cO^n7z5ewbe~=ly3Fp} zyB3BZPJOQQ;upnEGTB+OO+Vy+Up2NZfGfh36xALPU^Clalg zutOZd1F8oitJ1vb`BZY-K7-sKXA>8{jayrGqfM^_>4Vi)_?ap*ijFV+CM%0pO?8Z3 zk}$c6#D=MoxCnOg*0o3gzD2IiS&K8sQiiRkr!Hdk!sG^j9Kg z36Cv-Pe`%zDI60P5l*MwW+hpO5>s>c7G-1*Rw5@m!kh-U5f)uJx;n>bq7zafPV7*W zx0nwoa4lU;=v!V2X>cHsLz;g8!+KW6;UT~Y~L#5I$QZQ;u&To*2DiyU@6qkM$ zf?hdN?cx@Svx#k{J_cI(-*;D^jhTJJn0f&`c(aUBX_`250sZ<2)p16#KyE7tzUR;3 z-UXKv6toO1>(CTFZ)epsUskf!{w^3Dih&3C02UHWonHu$SImy5Yp%{S`x)*Nez32~ zS>WqcBGsSc(pO1?+xhcYj?Q|h0juHa^DGyShqU5Z?2oOjJ1)STod)MqmjolnLb}_# zvWW*fN<1mJo^Q}Te?NWWW6Bu1GcN)*<%msFq`hy%o8iJO>M`gvP9~J;>jk}|ap?5E zQMC*LFwKXI2{Qkr83u5g(}#$e2=#&j>_iIhgwYqySB2O!N|muj0G?zZZ(XT%(y62M zpCPm?td!|Yb5RNwDPKKdHVJ28^Jk5*gVzLI!#nP;Tm?OLVy`-S6dL*8qkS2BG?kx% zT#bB?1CjYuy8GYrAEvqKzY*p;DXTl0U!xBmrF-*aB}Fjz!r15X*32cwDk7|(UySS| zIKPf=uwxg##0V4b@{E}epe`%l7Q1dS5Zbx0wT(mhWW;Y`-|GRBPqDN_u_N0h4|4Dd7pEG^=ZY%5-uDui4?l zc2`PG0`2>|=c;x*3ggAbFJ`wJoO`yM!Zd?}2Z8I~#{xeLk>c9Q)8~r}rxBH<0lL;|nD491VGT86h21PrMS_>8U-_k)1r-k!p?KrQnfd)es{|MCZ5D zHb6GzLFsbxH00yO{PkuexQ+L_y^HI&$1&G>O)>0`iA)Vf=z+EG1#>z3kGbZ=sue zn)X^<;vdr&O}6N0_lx`{j;J5+D<0&wzuAf>obfTLWmv;**C}WH*@9QmdMAaeWMi-9 zkS*&65x5$tf|7qMNifeHqnu3NL6QSuxXGNiy67?pzkzui5If&Jc{F#Rf{awItkyNIkUUcA`@`7VrJsAQ99TZ$W#<-9X^_W`)SPOutJ|K4CnReOgj>M{z1y~-8_Wc3@$!}lbjZJQ5k^Ca9 zm*DF(W0B>de*8j+Q*uiVEwAyyY{s-N^_{rxY(;Kkr)8egNdA~)O=D-fB-)W)rMo?B<#Q{kpbr zmF>)viax`3H}^~mh@!wezzY(#XjiG_`&-gaS*IKiSg%E>jm#7g@(zf-p>(}k#3It{ zAQPUbTM`GZSSJ&$W+fvGG=*FcR<%p6zQUVg%}2K;yxhP}m8AHQM>gPr3F8KoL@HiX zKNaQOVBKIvsz!_FC9dUOL*+Wk#SkPBx{emGT;nEo0lxmYH&!|eAkWb2g_=4deJWtw zqb5D*)hDN z+~JymlRF{k;}h0Y^_0V{JG?WqE?aGQ;X-(A6IVa+6m`b&_~_)F z$lS7CO-(w0Nor^oi+4Q@$t#Rig3+7!#?}vK z%QIfEP5FH9SI#ZePIAjo*76i2ic3T#!sc+Tm?z4@H#S?)u_<>2F9z_ zOQLlNB#96Ly)`BikGeT@%2}Y#oMv_i2jQr!2{={X0Nb@NUWB;V^V}UEO@tXmJ205g zMe$^5Ub=qj1fgEn!X?Al9k9{3LAb8e={J zVi1k@zQQX*c%$xlc+r{Yv+WV?5OxQ4a*qKr#I8$9iV3TE5nrDb7vIUPSkQvbKIss! z3%*I#vX{M%)`ipNL5*X_BgWRT%{x5X4ILkM(ZWUS*8Q9 z({GE;O7*p_zmQ0C?rg(J3u7v`fPsg_iPxu6#qfIqtYnLx?In>5=Rg}J= ziqy=DBg>pJamP&e{XVJdR9e*cuK40#Hanqj7L`O8fUOPz2vRQWQ!=4{9ZHoTPj>Cf z1c@1EScA|-9+4MN2atNl!vLY{Z1j+_WK?XFPMn4teA4&&Bf{1p31%b5S2nj5WEyyY!HU-Ba3xDm|AcZJp-}CLZ;rpJU%*4t~+qQ zcB#b|T+@E~yfbCCbnk20K^iysz<_E4a?ea}{&L%);ukU;PF&S|tLiXdM$0gIVZo(} zLf%Tl#$A01@qQW}4hAh$j>WOJ!~tRRCwc7?oSBdT&&!{0pFzrnYFezzcVbf^=(1XP z@m8&{oXx#FA_J2VrK?USrCbCH*-P1dXB&@@rxrkCXW@B(f!=K20YHnzouapluG{5N zk9Oa5WuEx3Z&*p5Q_+OxSWk)5k-$S~hl?-FkYR*8EKxiP2?qqR3GUcEjT|bg zNgnE>;h;+l$fJYH5kuhc$-U~Y@@%JL9r`w@^M;#GwOjU~QQpW8Au|ccl?iL>p<}KG z7MEBhq!)pMH51s}Q~_(gUF^;1uDz64QhGHY4%^^RzX)0aNqck01Ui9k?a3x%FS#qY zi=o$-z9fhJGi6&Fj}{?;U0c}IOZX4;=%yKf#5(p#V&Rdqx4mSIxH?!u>=`H%C3WV! zHK1`G-5a!7D0$wV{Zx9PQb^CXfC+_J>voOrbQG_;aBop-(Mj25vFA|F%kZEaXs;|1 zv&bk@M3|bHLHw;j%qy2+2Xid2w_X~bT_9bxsJIb=j;&6>ocytvwpD-A*@w3Msms0& z8x_JDGWUa$70vsl)H-^e1~vrv%muzYN`0ge${$b28b@`PU|t-|fOxSe(e?XuHx*Ec zbp#`sXUvM)#6%kU{y@iixr(^$>+<@Ve=I954#!wy6~Pi>KZOr{6mWf^LlqINXxhMv^K7(ZjmPl~`E~q| z?AG^0IYpc^LLutW#waOSWtl$a#(F-agq!>t!p`u@`I$ct${uky1AU=2&M`0c*AfXPW$t-vr~uTrA$*cp>7Lpx2^lYpy`NMp{iwp4mX$KIsSDaPu{PdBkL~KY_3cIUip_QidojAW$0gUyFakE;W3n>4-+w^%)X7mD!tJim&l8*c)t92b-e@_u< zxkKd3hrlQ>U+;$e=< z{lo2Mi2Dqq8#x* z3ov1?-i%}N#Fg7O`}dRn{v>KClZ7^t`oqb;Rbt&bSha55LU-F~qWz9;<|--Q*a!as z6AOQEK6HrqaVWcHByuyVi8SKh4+UA;g$AY=|NGVb@c$3G%%YxKVT4lR+rA>`{5@)F zIq2V

    ~@|2YR*^D5o)(ACe zFEzD|KF;J;JmjI!NY2@wD7UbuE=_NCq?C_zpZNQ$6rH;J68gx%Od8_ebb{NLyst6< zgV>v0C-*O5pL|d+byU70heZGWhGHe#J^S*Z63kwN#)GFk^M-K3&-qDJZM(k5O?Vv^ zJx8+5$E~741N0a+)0&4LfYs1@+4ImM+q(0FRI7pE1C9H*64^>cG>guqx*_+Qww~!~ z=tQCk;X&&B^{k8P9L{28o^1M&-A12$eu9(NudroBTj)4X%ZN&!)~ckJ+>xjJ8uk=4 zYL{k+?CMwMluujBB`tn9d*da1t$&8Au|aV3ZR|>QlPpE5-gRfg*}U;VeK*$Ziq7e3 zi}`5*!g;kmIB zj!h$%xqfIb<-aKxE(SfC6DWZ&%>r6libKcmj_em4uTHNBA{*sQv?2$D9Qb)Dp~s`c z5LVt&%PRHf*^L!Pbu?=;xX7FbS;(C9D&@94LaTJZ8n)}3RYKeVq-uzG@-+sS>#){Oy zwXz?-ImK3nmnkDz(Kvt8#S0dHBJM6W|9VNr=UBnx&E=h5V)c}*jQA(aX6bZ+QZ0n| zw$4OIg03O4i>rVK;+CDnnq1wCUGb6p)P!n~3kSh#*^ZB< z=$!b{@s!&Xg0O80hAY@2;Nm)_)9m6qn33~-Q;1tQAW85yyxx=W&%9s5Ds;SY`6&lF zcPFSOi67IR1wW_Tdp;5mG*}2QSoeA0616DJ0{D(T`r$lDyLI);XIFxi4XBwl@|j!& zp!e0h*y1aVRe{y@TC!Eoq~otc)JtTnU_0nr){srABf8f!(6QwO zWmrox(g=iqcZyrVdG+DViI|p07nA6?K%@3u7joJ$$z2O7`d58&%`DB~ki6h*&FwX-~JWN-|tVjLq zTE)K}e$DAE!t7(YL-y$QmjZwPkG|9(aX`z)qQ`B z66(yjItm1vU9ixC6&1PrBl+a7QA9^dDxh2)muq8;S;fuK4<^ z8CKbTF%?pHwM0^~1fRMbW~1V0D6)66Fm2y^r$=Som?kk`zlqoI;DNZV;SlG$t{GiG zUuiU0iA0cS2W1y+Q#K^>oxgj_|B_Hz6rn}AVq8 z1D6sF<(oz#GGy?c1739qitcD7&+}a2%eAh4;soDodBOb%Q7fu1uH||UzxM-Xl8$qU zXA7gQ(>6~H7kS8^4S9%(vOha!OnZ0IX-bnHL43;lac+;=OL#_$6)_V&EbHE0y|^U_I}ZRa3cm2G(Q&4KD(#Joxnl-@ohjID0=U99bj zF++XRzHW^AP~5w?JTQ}4TjWYgjo8dM-?6*Pv_(2tFP(;Kf;B>z7(F+rZR60?7I1!K z%!Yw%TbpziD^(L`wP%S=$sjg8aOki9O+- zH2s5HjiLs})A+6*whqqZuLSzxaSTFgQg-_#j6uwG50}^t8lgqkGOSJGE>@#o*dDUza6u>E}M+7V`LtZygY zrfpEeA@#V>2{K_g13}vR&9itjyZesI!DthLc<}&x^=PC?0A0fmnYb4P7QN3Mh2MrS zwdefMk^>jq;=-22dZuAJ@vF~J@tF~y)^JQS%co%w38*3N*nfPSF3z@S(gF8p|;7_DRvVZAY zBGZ`DP5IX5eb)}@SOyj_q*(xjM#e$ zn|z#%|JlEgBHeYb?eham?E<*iCFb6-94>`;wk7Md5orWKd-27FEcKasiWuPIbwBZG zWC^X)wBe+gOaK*WN!5V0s%SsaI!#5oaxwQaXws@pQ|0X4zU+(p67LL+hrWLf|`)(k39JPxH7dU*!Z6tz;B#C62qR&^Y!mJk4q0iZP z)2OV_IB?MEHraj9f)z+&)2=bv!D*B}DU!yoF7H(2U2wdo zy#?}tzRyQIi{J(>;C(l9UACT_k9nn=oi`Mj0a)9?(g2re+AL#Z9zWr zylwuX0~a~siBJFwp1~zUZkT{I!Nu5~}Dw$)#^pkEwoHRUh zbfpZLY(4gc=`}v-V7Tx$%Y6^vI+cF*^Z(*zCq0sXurUE^YqmXr&ThAHs{_h*GzjNo z?uTQ*yXi>hqb6l}v2S_rUQct&6I>sF%wED3;~&du8rH8)sfwZ3j$tGhQzSig86fT` z+l6+dPbPzISSJ!YAKf zS5>g(zX@4@9!cHtZ$##s_7_CPm;K*}Oy6G^tKL1tYJ~RwvtrE5#0&8W14e|Xp6 zXe%;iuD>pbXA#4%cfI>;l7}KqxAux72j#H7LibFqQPF}4?QY-LBK0dgID<5zF+0WN zJ#SnGpo<9Ev9-0$o_MMZZrc16`#oWXr}E|1`?i)D^tqwpkq1R0DVFEj#JpG~%BSS-zyRY+}m#Y^w$cuA!4RX$e z-Zi8!iifx|ot`*S!$=)1?3~LVEkayDtMim}r0Gd&?{z*b&MPa1g{p>yhJWif&h+1= zu$hlCy#8V1X&_mDvQBmxV5zz;cr5%P-<|S_c>F5IKd_WTO z2wZ;xAn2ZiRt?)}+z99^bWH&s@BB1ybnIa+of(SG|)T+`b2_xXK_Tc306Uoz{p} zy_ejc1*+VIIab?Vy@ql7g^Z$9ao@x}kq z2p}bG(B{CGJ!hmhVqxYN|4L_TrzjH}m-G*9%F{*#1z`L0Pj{#q8(pEf zZ|yHx(W#XUJ(+J?ql@lzGj=pG^UiUoEKP!oS=-0-rdam9!{8JtNRY%ibJ(L+?n4p$ z`1H{u7Q`RmNqR3$O57K8B!1A{MXlb_>E&o%Uz+9Nwqmca*WKT~(&-utW; zjO-GxkZ^|nN!}%kCebdvCwNYX1#-~SGNZ>NQdYq1e;#-)tXq995>-q9eyG{YEH(EY z#ecM5VHzUO-4iSzhdzgXn@CLm@mv}7tH`mi_z6qfrx5NIfr|H)n!$i{vzS7?u1_`N z&O0%`wkQjZWrgcSf`}#43O*c!Raor>cxv#fi^-@adqLuH)d`Twa{2`^>ldK=Q}$}> zp+;`lLnx5;Pr_sd#EJlyX2c^8A>IlduFmDFaga+XYRNC$_7Crxm(@P;!>riF15ca3l>`%csCBi;5jrm9A*`hza8iofZCi0lK;Swd3D>AcCpLx)Q5<+7 z!;Bo(aU+<{LFCt!OqqDK*bf2;bY9C*wgoyz3M$B1BnzTa71eQJA5+tH-hR?6Xtm-hIT@3D>x3tF+ry8%X_t`K2?+A3^e}t?k+?8V22UGMg+~S2Qu4^*X5ZNwzs6c-sodP|mnX zS6>g!+mIBEJ>pM$mQRTiR&SB0#Dm9YshZlO_0y?YyTnT^@nvA1!cDefd?wGhD@);c zRlUYQ^iM%Dey_BJfH?nN6rvxpe*ICvQ|j^3C%2x zdkTk&nDE|L-^4+eykp1*h(;%KNoDi%~fwS_cyfNk}Zjf%s>dWjcsFPsiiz0055 ziu3^mNMGMI{@|Q7bk3h`#kh z06`}_nxO4Y3deG1d$Wc;Mc--KXy@-kHT*8DTTkJ{hj?*i-fLD>%aflc5)#emb1B=b zI*XOQlfo|Oa5lM(5WZ)sy^w}Oc~pUbkAF;_qt9fzYCTwMR>zBA$3VldXqrr8if6x~ znPhPpXAFDg)u(lI)!~1z9^_LvUjDyw=K7)cxf$29A)wACB(@zaTwUQjV+~Y)qLfXW zdK2)9o)5d<28t6N9|+D~s?oGV>_=yGjh^fnIJXqP@zP7U3HYq&mIS@8mM^GRxT?KQ z-_4pF2!=M;l!Fb@Kb^AZXIu_b=^Hnkj&8uG4CATEZB*D=V4n@>n>j(C*u>oDxuPkK z%Gb&J>B`y((vLo*C-=TXd~)}!;yNt$!h(CJsX-)!T9a)c@r6G}8KXa9>E z!Oj}WYc_tkC&pfHAFX;pN;QRbU=e%3$zPHObkQ?qVGWb~-y69SJuNC4O)`R2lio@eJ0uN_lcOSgCDjY^c>UGf*;cP^mS#l9sS z%id=}C=VE7tIS#HQ2QiJtekwn_ESW$t={gX3G`q^yRf?aIno8%{o39#=&6UrVvvCyCHm)= z8)mAo1!39x^^4ySXyiD>MqG*p(|j%%g8ooJFMVs1A~)Z9dPvO1$}_=s#N`1ZD`!fB zH)0bM9dR$|UuA`=Fm|Zn(CJ1l^s8e#YT~L|V=G_dK(3oxt?hP9De=R5v%2(6CS)&$ z9_C$uo;{3#WE|m*Po_nIk=tzaZbFzSEo(Dt%^tr@nMSm;VaFi8C|A&NLhQD*JySJk z_v8sImT`UKB)Hlct*`2wxd^3EsK}!j@`Ws2GG9rrJ3(k4h9HxFTB*uee@72)3Lig8 ztY)t$njV=>53E~Mw;1x9-adp%8hD(<0l)DvHCRR=583*nV4nsj^a_b6>pHH?vO`O~ zj^AD^pb>t>mC9!kaT@35uJaDwBY99DMY-5IzRf>?P|riEk9=SArs5}jTLTG#*N20= zKR!{(IV_B>uAW@qXvJXYW!+ZTqG0X{L@3Jv3ZASFyV5Gcj~(!BwTO@Be`0! z#=XiD>Ee|gZFzG^h?@5X`b>=_DV}CK3^Btyo$T>F7ytN=Cr$qqZ}%UAyg{tL(*mRa z8~l>=m*3O$`+rS)DiiJ2K>r*H^-#C%D4+2~8x{PKUC_PCu7n}_3?-oG!v z<-c><3m{%|YZVkB`zxX$G--{k0O!x#k52&#R;5ORA9sNNMYXJRr+sx$eq`#a};-ON5dm1UQ8jv{n44ym-v3aw{IzLi-kl8?S5k(&~so zQ(B43GkPv1&!xfdgjfS)6W=;&)(MS#7u@Tby6W+*u(QQN**>1>*4G`)DEq4YO z`HCKfk$eM6Jsgs|KUO;2xh#G!Jc3t$Yp;pA6I(iOz3_9z>AT0L!RW8Sy342EMxY{TS2f!3zR6nW@=?`>&ACf~x9FPOLH#DUgmJqdl+)I$ zxo&07RAAz9!~VzQ9N8ho`p8v$+XhIG%iSMtI2P!y!U8pLIu35`;8;6^wx;YfH@kf& z{e2JPPf~+9{r-pa{>SnqBG32p=#%O3)$MgmAn1@1*m$HLdA@{C7xR}8J*CYI zn5U9zPkPe5@97{%&nzYrv>FsNBlzRwsl0sG z!5`1_3}ZlBO~~P~;MjwM^zbN*88@8=KZce&%Ptyvob^2-CW>{K)PovNX@kU(Xghk}Q8`Fs`^c&pd+C{bC1ZLmE_cb9G_hZJy3R644Npzjhsdp@2x_?F97q1x=o;j|9VIu5n`%B*jq(E5naW*&=hH zP3~M?uie8j9S2`S3Hlm}vWq)L7S=VLbXE^Xdq2;X*YM4_h)Pb78tC>_IV|IhM?L`w z)|IUwtonKe@t(cxmiiL` zAi?^${9}2P;5^CN?v!p%5$Ro{85!82J&*|Zf*j_ z+tiasuickp=<8^8en42^9ea2}PtO1!?IH>6VaIQb0mti3Jwv?rx+( zKxtUI8>DOLT4Ir|1)dw6GA>oNFjpAf;8wbjMy3aU@;st(M#}AOOY>P6J&VXm66!VzOq%q z@#$iWTvfT}M$mHa0m=aG8Yt*mNEA78tR5;I?9Oar?qg7xPm{4Gjd#fFw_Q9=;Qc({ zn@3lA)uTb^G;_p+KxwOepS%31VZ!10YW&GZ#^%^HHhnUK3fem}J=(_T(6=J@Ld`%3M*?NHK3 zD>ww055k12kT=;J%g;E&SV}%__bXJq5$CG!i5`ke0`lAkw1b#-Jr|>sWBtdgx05d_ zGQR(i;PH*4d2QIAD9;?SL$Mv5b5KsNXf;%jp_ja05*Xt-x)t5OQ%XyGq7Xz&`Ac+? z^Dj1HQh>?eg`U%K!i!_`y!ol9)yKBUcUhyw<>8mQGU{#;-2BJEiynXwPO|M6?sBQr zfszz#_;$aovljAYry#?zp5o+}t7|i|89?dled~i@Gm{~7Hmary#qjj%@!SJfXZ`UB z)J?VhuzN#QQJuLO&e`MRQ<;o_;<+J5~0Q{xQ`i|?F{ipT9OpF7GcDdFz;O&nOI@9}} zy+tBTdkQjs4^*#V{Wrhy`+-*idMECZbC0l!$Iqu~f-!rUI=3n_Ks}553Wo~vYsE0q zn9aXa&+Wvu{nA{xISOSDB@75k{7ljoL(8kTq|!QJ!mrDIZfZzqy3C$T-h^v;I6H*3 z(vZfviJxj&@#LB|*!LWv&$DDT$9_TWLp{FNO)V>2^8sz|b123!&6VnS@EfJx6Akz) z9v2i9QmoU9`par|p3#DcmDy{KD|4I@(o|d2Yp24;@1SE5zKOr2)Q_Q`$#NgarHZWlQ+DnW*l+bREbqti=gJOrkFN);SsuS@+r+T;6?f~H!oc{I5OAgx0 z6@@;C`vKZ<r;j*3z}cun~-J$ z=1{AGRPM!S#&38DC@5YivXWxz$5R}bXTm?nB4Uo+6sNgD+7EVL8qz;U9_MuV;P>oD z-sM){som?!pY=pt4P>D|2a1O8_@*8okodd6p?WeS<~v%(>6RY1$|G`DjVXngdkc>U zN>MoXP*EY+@HAwmK3tZC@Vv&}w{?ObN|jdLodPNq-Rja{Hn({xEx|Mp7<99+FjI93 zlf%(_2ozvadhQ<$A&ZG^=V8Kyo|0x_+UqA6W#-!5{T$6iWcO%ZPzrJVOY?%GS9eDR zB+3p+)iv}3dY7*^XuID9?QlZ_M&kOR?tqqHmbczFdpSRGe%J8mo8p3e*@6J+NuEqh zYvwkNG|qpiNPf(i$r(=H8j+=NL%rH7xYe!lm`QtqIBXsB+Y|oSn(tfw16%aD{iuLx z$J>b5ha)ct%A6Y05@z)Qt;<3k3hTXIH%8q@(O&lpp8cTr^?zllYp=_gbN?q2rbfuu zI;VKgZCt)Gt0C_Lg?Wwha^mPGQ)%0zk6^~R=(Y1JrkFXDM^n!)TvTdMy;d*|q36Y; z!pE1Qf&_m*Z^;=}3Nc&Xf&&VJQU1+TOO|76eNh3?)hHH2R0fRSQ>d4@q|$oQMl4mj z=`a~Kx_FW8PvA+~5g5;Wylo~8DW=;cKYrp9Rf`&wiu23?+zwDx&JE(4&=D!>CcYZQ zkYTim9hzQ1-x;A}mx{k#JQ{;W7_e09--l`_ae36-#S>bQL6P%ZLCaq&f&WOBDahkw zTy0ehbIV!u2DNLoRpsfX9K&khOCVJ6fC94-kOg3ZR7xO$yOiH{6Cx_y((fbDW*;-= zcXDRpvPGzNk=e@x3MfdD{dATBqCk8<_W55p|5zV@^RL`~$|rpsv-oBw)v{PFh%IF$ zWIycl^lJ@I!y4R3iof{;P85+#B!R*=g_646xUorXmW)<3g2zA771W$|5(lg6ZS?K= z$*46J_|0?YyL)|ky4qhcoqgdruvK)_a#^(msR%dVzWQ4lpfpg zzm9A3{tJXZSWs-Rm%)~{_qdF6=xbw9!TIrf^k0}gn6N398~?X>(A9M#j=A#wY=4gqM5IkiF88D@Jg;*B7$2G%>lm_bmd)-|ni(CtovwJLt?|op_zl zpa@;xV_Xr#TyvJW`Ph}ssNPu@?a_^=8XnAx*Yb}SKCJ~292P(9Me#)4WGYB~Hf-W? zn${h=io-`U@(dRf-{w)?^+!{49koX4v@VtFZq2_xvM=; zNLiCw6Zi5j-z!#741^3LU~HAlW8>SLbr_K!&$b-()NPWFmt^~m8^=HK_hL@9QlX&V znwM4(0b@Z@`MOh@m1RPELQLsp*=<9IZ#O4nxcoj|&!ra1)OS1#3vV(go7D!be^4kv z>2G?TF62f#dPJTG2S4?28mSurbJ7lEhV(R=CXp2-G+Kq~YFW?B1{*0N`#&l0FQ-X% zT#kCQ?I#}^4dx}b`F}~+dwOd_sfq&-yw*;&9GdQ0N*@+T`{`gzYaL%>2kQ{1ou1R>SlyRr*a1x;tGRJ61m&1%E)&D$5GHky% zC>hh5_#Ora*AQy_39FxYqi?nT6DVJV<_4asSJU?5J^Rj-9bxb5j3RW>gdTVmBzxWH zVCj+O#M-!TLAH>y_ywg4D3T5sygdp|f({fa+l4Qs^=qb^OO?4Vl}NJ6eFDo3RY=8e zyjKjf9}h$7fAGUVRBBIb9>uJogS@(^X$)tG@MLh-SSPN(g;Pt+lJ)ne{I66*Tu;UZ zsu0yxaaGDFN3`$=5~WIh&-V8LIvg`xTD0k5>yQ_4Z{?!PuExH<{osa=vZC^1_%AlV zQ?F)Vw`b8qiUGs@F?{$o$MhQ{LodVgtgGqpO~Q{DTqWi5G<4iEv)JW=hqP_r z_7B-2uO14@D<-w=#j9KwL}N&VfTnnALehxc^q$bh7!TB4&o;p?5Eq9vR%_0E0-sv^ z61ZPyBTfdkOZ(Ril6P&0?MC(+GU2^7(I;DDrB!iu#M~+rc{#9C{Tr_KJ!@(3*C;b( zl;xcyrO8HP@ZyQ%2w3{*pCHEcr0yWl{e|q>n7*v;EL8^Guw1#(eNe_Y;rAJ%47AM2j&VV`PpWxkznRJ0lo+8HuJCey*u%Qyen0NrO47FoBl7GBfwem) z3Ykc|R$>FL^6Jz2`CXzlg_WxO>}n0b!>nujynmMPqVjuU6mMu!je){68DJhaJqT}& zf4L8{j&!G3**aP<9QY5lg~uZFEJOCZw}W|5kWeUTVB-}ix2ol89-+-d#JBO=ANlqt zXIJ|{YzV1?U@9Yn=iT7j6U^=>VxP&EM-q!L-q);i@O{Vi5x$+--58={YG+r&w51v1 zKlHO-QXU;d`zLh0JQQAGO!4ie&r)su$O=Ovb;^_K;kRdk!~Vez?w(ZJW#8#EgquMs zovwRC-?0AYsJhGHR>hpKfXFzbfn0EbUp4B|BI}I@coS;ge_`=kwe%uXTQ}}za~cO2 zt;awauE<&;%`jAlL&EpGC@ug0nZN&k0f5a}mwT>5{|Pz&`3=5crSGb$#OHtWFbnxl zy!K9C@qeRM_`ih9@BX{2tY3cgdf!>(xEUbg;e=uFnEl1YeOT{qd@1we+q;?RsTj+) z)yyiDinVTn@4iOT4rEE-zM-V z|42Da!l;w#R5lm8tq7Is@0fr*ugA+m26fdP^(HT{pOjzc}^%t^iV>bh4VU(K0ynLI@^{nB& z;a**v?`3Hv0cn1qPG;>yr{T53cy;wjg4G!N44@x_$We8b2%pMbrFPG%lKTgl-8~ON zhb88PBq<`pnYOeUeW()X`np(vICxMz@+62!H;lW;%rQY19`qS9b5eqE{J9=hMx>Pr z-R+u{oPxdPT%A(+TaTd1^ji8Auu9qWj**3`)Q#}R*j=^HZW3K^tUlSXdh`?)zAr4~ zsv(qtpnLeusQ>o4K{>REp9fG(OBh8CN-ciZHo%{r+xsFhLj6{Q$2^r+bg~JWHR25 z{W2iXpP5>RF=z&pbnZ^_Z900`^X6Byeo!CM%g1NXJF&jxw$8|}K;Y0G}{_xZ!osQM)Mwia^tcdG|&f&>?Wr2LbwyyKQ$rOyFy5t@U zk;)67Cl<77h%q5KuoP~n z?Uro2V1-w}O4Gw_5dSt%4)Db(opTFTNcH$%1qLAWzOOgp07yi+$W7N#URp5g;?{=Bz^5d%J@UrHDMSDcIJ^0CAeW<#pt?IpIC9*&GqzO zBXA!V>M$FJDh58;CgDVY9ygKg6}__|Jh>plPH9gt?ZpY3OuYSU?NM25x^}c%J|=uy zEzF~L$(7e&(NWE)*PXZ(TRQx|0{GQD9!#0z|E*pSr}C?(UZ7b|_lVxe0Bg)@%b4u` zQ%kTT^Uk1l%8=%oE6;mFdpI8em~Ubu{}vIT?rP3{wD_c-ZZR|oS0l-5n&p=}(Q$aY zSidfpIE^?DnZzqjf!Pn^-bU>}S;P(n28VJk33*ldlr^~nY?G1FQEI^QpK9M7OlTQo zm7YV@T-rA7tB7I|2~p)u^piclU;ps4su)P`;LYlIs+ysa^(%KFW0nk>_GDNj`3146 zUCmFr#g1+@dskQ$O?LXLpZ8u9iLVebf4~TaCC1hxh~r_iK%HkvJWszY^OL>~7DJLR z$pE6yH2bamdJah9{_`3IPyNlKBFQXPoxcBCRckdXg1a!b27J z#AQxSZ7kMm1N6PJh+Xdwpf0D}!nUE-zj7H+pVN3Y6QKgs>0XI5d0dhmxT8$ z3A_hDh?nDsR}p=_y1(DlSNa#SBD*3D#q|GR`|FD#gC&{ef5827G6Hp+W1{j3JKk0t z`|y_yVnWMLsqsc^MEOi0vdNRFsv>o77IpFHlh8sj;%QTtxhwpA&6^E`XA4y|w?6!}5 z2z{nYJ&r$W`hE>6bGLw{-B5?%)Bi~8J&B8--zlF|pYNRIXT3O-CcK`Yt8Vel?)!pz zqBZlaMql`g@g1?zne(dIb)!G@lQN}JOD)>5YBfVue{AI((q`8=3`ri@ds1BXmH?oovs=ir_QIL9PuUO+W^%IcDe^$=azbP=L$d{|SUXR&D z*}^>~3Z!7WS>JoXu}v>n!CV&$TE57bFD=hkgGi9oxRdD{?FrntWh!Ma^4QdIt@yxU zs{3P){~Q1;04@D5qb%KgRojm2Wx}do>nPSHv^}k{GMzU6w+2-=; zD0*^J!1U#p5TK6w{1<-Tk%OWQTnQrtmcRvjI|LD~S=UK*p5A5J=eIR~nBe>B#DndE z_4{r&|MCHl4*!c57^H_U+1EDH2pmQJxf8&KLKplCPpQ{&r1|WjV(X4qJ4XiZg`n8B zP((aU@EkD%`ML4L`B`P$(JdNqy~_^)*ZV={F>9k<@pIN#m)~NQ5kc&&7?>)tdl+spA4G@l5`d? ztFj*iVLpwgJ%tVCbkv@A)K1Gt$vrA5IXjZ2E#>%Y53@aPii)<=6iM!_0SrZ!`;d@R5Ok7;j7YxBtgdROzbP1v}NW{Bc0(KySSTZz5Cb<$^+^Y%Nde|c*K zf0}ibjJH#;KCj&Yr(xZ-I;rA+oP;V5hI%&uSz3BAdrnwQ(dAbXFtc$mK6muND+|*w z>eIBKOLb4D?ltZ~8wlc3v36W%loy_C%S;GL#v?MEo?7I|V?%|~i{hvQf%+K;)I|}* z&AfgZO`PpaJ!9L_CDt%if%y8R$xFvmFw&qP!1?X0KMZ9LAZa0k$nYZ zFogU50=|ovFLeM_?q%=N@i7{->BI@f?P3IVh@KRr%2Rc)8uD!y=L(OQ27PXk3$c4xG zcs9TPxNZq*L*OleB zWbAy6kUVN`X9sK>c2Q=-{^m1$SDW_j?(Jb#?g;$m?S6HC1jp;<17!J}s#|27HBeDS76`0 z8!JJFC!nZP*E}(2dy+lH+6h*_=EyHq)ZweHaGf|d8BP8D*sCKcgESME&9{fDxb9wm z*^EUFaa>v22K{DWs%wgv7RcV!0cTbEj9|w0GTJ z6%|B)S;_eUqb@ZY$2K<|m8mQ9cMVCdJH^#eNlL!~pLN#>tJ4{(9u%vgZZw zHc;fQ-N0&6Q|;8dd=vnC05Lk!$5O&`i3GVl+?Ke>cE?%&AP!d2c=!5KSVPcl&7M)+ zUFjoaEThe0^rRR0W5rUR0SAlsU9q>jW0!go^~dA&e9gV10=)Cu`SWE(vb^?}1PDQI z?rqe^dsVxu5!Sd-%3Egk?{!Dx#_UeOh+SyH0%`6Q0L0 z|2;szm}Z)D596;q^UCgHCsqn-4;l9_3d)l=4h~vdpvevfX~Gl zVH~>WVvXT!c2Gd}gJV_|gn$Y=Yt*XP!n0q(z+`|QeIaBp=yFO~Wa|-HG)b*UEyU^X zd42G@B$B*v(p)GNq>PMWYA9xcUs5s&VdaN3@o(vEH74PA1r7dT7&aI2=*W8ZwEmup zH|D2H`;8-MH&59x+o>dsH501fPJ2{QQqcuN5p@z z5-(w>Ii|;LJVEEq?SoZj2W1s+_B^T;EX+OIZzqH=83eo`JbL{D;rlQ<$?JmLa$5Z#79t!uzJA547h|Fa-j8UOo_O8@-1H|4bP^bkyeAXadjK zd4q{2x1l$+iCdtqmBzoR?lb}4-VWQVyP6L~xzGCn>f=VYL9`k5t@y=yliJ?xyf1Dc z5?JuN&GcZmdZyd&fW1CcYHZ^sMWE0fOua*-0;Zz$5cfp8cuEsd*|nNAqEE3PknA_R0zMEc0v5-%rnNYp`=LWNhb|TFQh{+H2H+JvEjdhR@A;@lP-( zuk^1931x62k-(?kqF!g<^4chhFfI+}?H;BnVoqR(-KsQ>#*^bt+t#%w&eR#dGZ4I` zr(Zxm$3s*8LS&*<#~vXk|SxZpGEBk+v@sPyS0A?7u^|vkAKZ zK{7G=a0frnBq^s{v~WyEKFyC9w0Zu6^mU~fFjlfl`g&(Mco1n0T8aytc0pY0ksG^C z1+#iRj78tTSo7WX8+|(N7apf4IGKxfS>GPAz+6IC4t0~WNhDT_pu6D2vD`U0^xT+YdYOecT z#4=bjvflJk&K4>xKo;tn)5y#6W6~%ggh*h)n!m|zlgovheDiRQISc83SbgABd=xU* z`#S7a_2uPrZ*kob!H-qoCO6@0vZ3&&eCp~l6q-`_c55<4a2LfnJwv?#g!`iPFRda> z?2{3zYc8~lx&RtQ0{OWBrF^ilXQspW#vCePy@NSqaZ-1M>#O>3oA>QrMX78^f2+rJ zHFc(WG_o(pAFtSWJDyFwGok4RB1yoZGe7SBj$*4Vo%3F}hQ$Q3H5X@}&q~E>mA)J% zaTG|ko**r@Uc>CR^;3XYZ7^n+oY=p-I%v>(iYVX1Jdfvh+(O``pB`9N^wJl3?~q9* zJ;pmOKhtBzY%W#bXe&1&1+2@;>R=mpSoRHqd8q4b*L=u=MQ6w>BTo?ib6uXQ3QpA$+-Q{) z@a9G8#KvWR@ARN?T5gkmmTMh-Wehfls^h~%#=nDDKehtf2AA3w-tlD#zN+`&TN zChviUiMrEI6qT2#xg$j+Ef*=+^eHlKXR2ty<-MEMS;yMlI*}nj_1c1 zu7Q`WUjMQV!e<8-y?=Y|Xe;^X(nnD8`a{dV{EdR11DoqrAJy}`d~hvB{>_tZ>0h=& zl8=BbBn%H=BzQhIl)(4@_iZUJ?^d%A`XIsr#UPOZ1sS6M+iI3IwDA1LKi%%4;?K{T zptrmrO|aORR7a*rkHk3cgMS$`MFzIglH+$~f~k#0DAuU9G0^|zizL(W-w)^?pGjl% zfBMDw78%rkcFCXQ!e79ZWgcSDnlcm>_bIBpniw^tnl3g9PToE=$`PRg`tVzm|N3xy z1678quD;316e1%Hyw@psYwlk?J>OtXkU50H>c=$;9$#ZdWvZyX;^`7bxW;4H=3BKp zF`gE!#XGO^tp4vGi^;Tf9z#dPccs?a+20%&Ynpp2T`c@EP3F^yY{G3K09j-t>F+oW zJ5hHsg9+s%+K-o8mv1k=G+OZ-_J3B~i}l#krpg$MQ1CQ$*WQ_)jbCL=@X>zZs!k~X zGe~_Rac{}?%Zgqnu_X5V<80ji1t{o>GOp`VS-=9t!C zh8%msZEq3HCYSGd_Gvm<;~OBGS^w=f$kJzp!ZRm1O=hXp;Tk)QK}Dgbez07yh5e7DrVNUKNJgG_LOE7$sr|^_MaPWQqL4EuccqCRW0l3}NTW3) zcJ`>udgzhMa1(IyNeE))h8E?n zc6F~4ULkDlMf@)B#~Lz*W{g?wJ4-JNYYQa^F2cwg#1F3djf5h*A7|(bdz`qO!p~L* z{SF!wdqH1!x8m4JzqB1_Jje7Sto!obfZXUpeZl8Gu8=3|J7;y{;d_HsJfQ_YpGb$> z=7;ga<@STS56k(bs?9MO94}6WHxfWNNqZ-S>Hd!l6AvX35%XnHbNa#O!fk%iDbGMW z4_NHaguNH@7$&-F7o#?r^%{M@3^T=b+-8CB)?+EodfZ}uae7nsyn0gKab=zA@TOO15#;B(d$wjSw#TurQ zV)DA6kE`p3CP7I`uTh*B;xsO+T)Fn*`0~5ImKBBrb5hg&J$eUllTz{2LzRX5{%=IP z5+sIzw;y{pW9^Ve6n0R*cDT4HL^9mZ_4$I4IfEHKdAuAjWW;<{GbxZ7rBV>;)DEqV zi^{u-Io3?XE>a$I4mJ4r{>_V0{WkFeDltV1ry$zFcLRulMiWq0X=oWS=(A$g2Jtbw zg%hhL?#8}@+Y(whwBMFUi`>l8cqf056T7$^Rl0!G^Za!B^!Zp^Ej4<$9^dwSfY~s# zn)Io8NNt}Ml*D5~=e-DNm-JET$th`A=}n38&cxgIGQ@$X(m0I%6ItBN;9gmSNG66x zB_^owF+pK!)W+>EUakvmX-3cb#(C|WyrOj0J)0pR1M+ z@2vJCyh1Yq?vYZDq6^yG+x-?aShQ_WWt-I3@Rh_{pHcv;>Jw{(b%{=V;`+12$Cxhy z+M}d(no2EA`nff%D0en@F*%odzKoxPW>x%#h4~j2W%6JoF#_)6wKf4 zV+_lg|4fEvq>o%5yay%V6WWbyG)_Az!#Mf|m|iw6em#2jVVK3&+wJJ&`?|+>!Rf{COEl;t`L2w2EvIV;MzermoqyoO%P%EO8jf zCx7NYM{7ldnYrL)Z!#Lh*(h_;JLHaFb(xgfe@fE<=iIIzB%f8!->iAOa?b& zF}{UOHbz@=%|4M` z=@dJ69Q8}s7Q9GGT1gk_0_m!|Wb+vvZ>qL!c)fy6`0RrND_Dw%XR4a4_+5k_Szd!+ z00B>f{WB35-WuNFu;3o4p%Vv7L}!AbXxxMajzMGv_EK^b`bURI@P%x=i?uBScx!Qn zf7XC>pDBPQpEL)#9eq=Ea+Vhq$JMW(eAY40cPg%xJ5`3yI7iP@eL7#A#{4T>UruXW zxPIk_0M7F+izEb#{L%?ZPDKGQ%cW#HqnAMz3KKl06zO8hr`s%r`?N0$XA)t6=}ytG zfyu#0g(DMId=5_~(>IS_vxizijGLMr6bS8T;HYTiJl+&2IfAJU z+CT!Jgo=U)gPC1zvab+=?whW#E%1A0JzC;`_E`EHq&L0pX|FE-m)@51?AV{!_t?^9 z_J&|R$qRh0#uo?sDlr>pT2y3Idr{y7I`umj!le_1^WrJ64FB`qHij;xledLWrAReHRV7Xw<@6qe(!oBKk}e$-)}Z zqFv83%`(j*DcXp-?gejAU^Vi{6EbjQ;iA7VTh%b0YYRmPizIb+zRuaxr`wZW$(g+Y|9iy}gkXE@yFhh52 zXKiE6U!Vny^efZ`p>i-lw!HbC!^O{Jnw-RFqou`iCmUuzJ|q89_EL>j-|DKp%D~xN zYiQtoBhS)SVzb6^cdDy}Cw#_Fj=)2*B}xy6V#y(S+cx6;rY)`CQ#2F2X5LfV&=Nb|3Y{A>65n)!N0c!vYg*s)CAPa=)M)vMz6-?g*ei$At}w(N_R*Ro&#p7cV^DV9I0-uYx?KD~ca*&>y1n$$vJ%;HT~{U!x& zi8v*00a3K!1I3$|PFPhqn#bW_ji%do9Z+*(o|!$knzrtIrtQN{b{%}@V7+lio>>Y@ z$0cA)+2`_pq~QZ2@NiV4XrO1QoMqza2zAYvqmI5?yVv^21~!0*prA&Oyo464*y|8k zR|i?LVKM>ve zQHic%es;Fm%xm)WznIp|T&2KtbDobm!mg6(=J8|90)@`WCYT5QdQo=#eV0{u4L~Ou zMKl)VF&fX8QtQb~&=9@z%ayk!v?z$Muby2to|7Y0l7j6Ay897Pb7?Y^5WTeCzDE;u zSWm?~`wj71uW?dqP*%y$2zr4!thYz@-Z)97Iq}OEw}73j_+58P;+HpRva0k$b$i8$ z@M-_hmepmRjmIG0jr4Agqo11LsD!OT z)wAcv?C>dr@6+8ojjh;5o-#@Wq!*`hbF?um)PIiGI14fK3F^s%SoGX0?ARo@U$(M_ zS-{LiQ*VvF#5?x#s-eB$a|3v$C(3mQpFD(@JF>A&0aqGV{lEeHs7dxPnNU>(?>;a+ zlk*Mv&Cs}zZOrFkp+T11YfE?TH`2BOxZDe;>c4-QXSsfzYQq)AjIp47Lg{k|netjS zPw?T2@G>rqGYqspM6R3Y32;x9?i?YuB_+5i9MPXWa&I}%H&$bTzAzE^QEl(CbB-!q z1D7+ZvT&}ZWnvBWq7EswPpoFwh3+Quxr#PvP7RHv z@0be8(-rd~^h@pcMsq}TO!Hn`kcrAZ9>XBX#_hv)h*>p4_KPs+KPnCgVLhuhoUr@K zxGkPw3h6L6;gJ?egOe&EFOuIL}&#^Lvzb`C00t45Dq&Bw1#de)2e?=wVhesIbPThC%E11mF8!=~75nFay${-5#rDa8 zl;iknsISHPy(LjkIH|?nKB@fRE5b9CUzUv6{H^qkq&O1kBZt6BOgWfJaToC4f&f6JXc7+pI*wIQtTMqhN|39s}^wRE>b)QmyP~<`#R!i zsd%q_jbiH1snucLwoydN#`&eVXT)a3*wI7KUjq@&Yp&RAh%X6|ND9k@ zhEz2mSG&c>KVe{uKA-@$zusHH;;cV-@Coa1=EC3lx8*k)Q35khHhp4ec%74TmXHsY zHmxeZgrz-}yp*QtRqOd`i5c+iXGG;$eo zgy5I6?yr|`5Wg&3uNp!SWkJgb-ZjV4-@eH#6>|>1&u&_?OwYsjV?XkW(9r+9i}~66 zfUgklFmR=ev-Gfx;-fwJE9&Ka%-lY1zY}{ZY_M=U4e@~-59JAHL0J!(viDTNAh>dd~*bhn;gx zRMADxFY>&M6+`nr+rf}Ir^8`(XyI5~XK>v5GX7MRhj>44o+72J%*wxl_2pTXWO`U* z>GUJ7qV5@?x$!<;LecDW*f9(pRMK_*oSp_aBxi1hUeX(z9ldC0e$6~K=(=#9lReEE zyj^v}Q@mhbO$Nafn93COQE2R@LTqd;Xq^QNDNt(Sx7azmfy+;Z*Q0j)+Yswt!OG9# z!H4eezRg_K1<7`G?kCU_&Uie#Ec0BM5_-=12=xWMN0P7FWJpFE{jK_j}6`cNI>nmwdxkXAQLiQG&if ztN?#?V1Z#0yH>Q(fL8S@y~n~YNayyS5T2E>6C3F(>~O-d;L+? zWtI9L+@2r0vz|Sx!LAHhW8AOuTFA7|bcB5ENHUPT%o*K=ntyTwPn9p*P3LVRRm1nA zaNyl@ShhRj*K$kX3kk_5drqN_v^fDe(6Irf{mv2EXnbIAHmY=3d!+m_v|L0a9(K3_)%S>i6;m#j6mGy0>ZGa!nj2HA$A&doOdkd6Z_IFNCP9&zS06~xtDGEtJ)?qsAE3$n45%a(S;b-^SxU*#!_@z zoE%--nJ9g!zXH2RikjLPLt?&a1wFcr+aCE>s#LZNpnhX>4-F)CfeLK_g%*2lswJc~WSW7&883MDf{Wmslj# z>h!NN^#Eu8Y#vT$mmTOq%7fKw)9wJJgSpE^H%RC_LQ?XA3yvF~G^mwu?(m(e`NV+s zK;j`#P=|v}ZUbg%S@Q|+np)GqPe|z}_b~r3GMSwFXvE@(9#KyQeGB(Dn>Qgp!DmGR zf=$rSyyMmp`NbL6WJ|yH74*VkzJ`$>0_jr{AknLYD-zt)c)5a{P3Cqb!>az-_Y|$Hg_3s06jMb6N^{PkBkYU-{VC4fIZna-W&Y zzn3BFKF%H8+p~+^8q?CLz5M#**$3UR2&{8XVyL@02V?AIFe=pg^vg&0P?qdpm36lp zI5l-X!?!d1*9;=z{fl*ul)~*%Ap~_>>7z6YecrHFT((4r&(Ju8Afon7TC)7+*;$i^ zUTn@3t-wKnCR3u(6WAQJWuDDrW3P6I!=9hs=5vV)l#jCIIudsm6# zU2z8yb&J>+6}O|M5$p?EHeVfcvs`CCl6*J4;>qQjJYj(x?)!e@usvf&AUF_Veys@E zMeko~T3% zq9XUN?(;!%Qk}tH?<#4Ry&2M`xT3L7?{=;v&3UEn3EK4BC(Q++?iG~47ePs-9fwng zHN|zBx5sb!hkU6cYTcI^^(Pq4;w*1Bvz88|F{e8rA0IBcpv|Qff3y5-4ld`G9l>Rd z`|R0vo+np8;xM}E?67iDAKNd~Prm%9n|ciwOnM~oRQP&}jYmG*5K}A*D%@ZWc6&8R zVQ2EpkEls$>&E3ZR33B4aG}EU$9d%bl-J^=CGN?e^AZO3x@$iZ-eLV|Kcm9$k~7s4 zt;Anp`^8ORNp2-{bwx*w-J{g}Oe*%${)g@Gp9f9)+r3?frB4_2rP9XCgnvBZHtB>L zSCBk-je%VP5ikJ7R zwS3{_0JhnmZi7(fiP}v1)03)SUx~a;2$8o!A~h2WHCo}L_%7l_Q)XPkDHXGNhVx2h z77eQ}Au@a-%Q&eY9VsvS^7BB!d=K4G92YANc(u+g8Tpzyni{O#VcTo{6jrRk1kBRy zh9B%up{y&1XE41M@#L`-io`7pX13Vm%E_l!ST&s9S6qd8K38sVBBBa`#i_OH7Z$#E z&?E$4WOUj}y6Bw6`x}l2QM2OV;t%&F}fvcoH&^JVi> z0dVUn^L!unh)V3k3K(xw!&O!1o*e{Kf67B-Fp=w;L52!v=lc&1mCi#$GiyBVlwJ3_ ziRh)wiIjp`d*B4SQ%G}F@ru0R`?G2-;!8wo+2N<;TbG z3JgUra-(}37WQaYH<4OJWT97H23z|tju3?L@pf<4zDUq5$@~CXPN#$Zszx~f;)0M< z>zKxdbz@oY+LsLmSMhFTCHV{M5OTL&C4XC!coGPjHD)#TJqy988tA`q#LT-4f-jp* z+*e1ng&Oia+-mo3<*F#Wksl+B(TY0yVf|ND9uuRhTH+tpSw9(8&v2V3Ja>byZY)gp z_s{E2(>p=d0_PEG-be5JNo=dFZA4mUdd<#WKB z3Lou@AMr_nnq@}AN9mSHYD_cG*+CpuGa8uyU9({t$_ zzqT?Q#o)8^0>gCz8-_|?N4 zr;jR6g0pcI9@Dq?-lV!-UVO3rngt%|NqK$6XAB8^IAVa8$MjL?hdx)ejitHyHyw&1 zV$?r0fBKV-?Wlr#SnnyjyJ}{br7|?n+U!5soFQDqJr!P-C){zWX<#^qmvxaQ$Euhp zz7_u_@l|}Cq6_goa!4SEPx_L+k9Cj^S68Ml>{p!7a49&OPXDSiGPi34^f0M5P zO)F%{d#Q8ll53lw+oyMgD{>NYTxZe8@w)XA`{PQ32eExspWbNz!!Mi4l21f_KvMlNki82f4 zn2vuDyj^5Zpe|Q+taV2e^G*HFXW9*6!OER{KlA_a_7*@@@9o>Klz<>0h)4-YEJC_- z5h@|6geakefOJcTq;yLzx>LH9?(US9?geYXTI>Dc-uv16dEV!L&UAE3jr6k@hd)DFejH5g_yuu1Z3#1++szIVY4kGjP`Dgj7nUb3 z6y1E-=vYvqx~rnjT<`~dmF9(zwAGT$kHu0N(p-r3f36*`mfXzW)kpEQU0`EMMKWXj z+vfx+M-K;^5l2^YlIk>Fzm?b4gnLzel+YzjR=26ItLl<<+(cnMhkek}>anc)<^OHg4PcCN)PQw8#Q z?~vxy^GrDN2k01+%eVBP<0`EAa-`yte7g7({K$pIej4-H~@vKtH=ZlXWcLK zYm1+L;KT(`UwDOVwA_AH2TYDH6Vcob%K+n)XkZOO&FFeM6n_;iO5SRHEZ6$ziV zC^Qr-xA>aZ#2mO9@yZT1gP4S$reAj?Mo8-(b@!1uJ!dUPZJ#FRGUV7`%NHU&?Amv3 z&XYus(T79$3K`jn&Wn4n>5z}gbG2D`!DT~ zPUk99%Q{}d`!z7+ro}OX&dU}HVWU=Y)HWtU63MjbfLL+x-owkqaP>Y3*?oLd`>t^Y z0DWWL=xWqhezkm;Z=sIip31cfxF8>*o&-^UqkCfQ;$L5pl#V1vuYlWR81TJmz5#W? zXx%YSrM4a4e4EV1V{rV~-oF&!H@D)s+*TLg+FNkis719IfA)InIYpR2$9^OU!-Sq> ztI+H%G$Usg(Az`uBi%C=nS8NT2m|6+7K1{|QG@<%O~ISbpEAi`omiNdls0CV80S_N zTzodkseC=Sw4~nEu%bKPCpV6ecV%;ExW*tZXwGjlp`kuJ%*NCu_C(#mHfUb%Nra(X zKrg){i7OMZatCuT6;sTwm&TtIT{$0}TZ z%aAJLb%i_5%wf`T4jyC8=Ww<6(!~;M&mkM%uZ8rn64n?q-W!=`6}_g3#Ab!yecx}+ z4_)*6B5#(ooP+(s@go;Fwsfez^`|yvvsNTg-f;e|fSc*7)Fsq+4OYTP2%$UGz|xCx zI}Mx11u$tz;U9LlgnjJL3q_SKH1mmBu`Ox@FGA@eG8y4Mx8`#c%#+SvD~8P;yLx=^ zSl(GVfjmP)#l{jQV8HiSN^!mhM06UK6N zSo`GFuz5_Yx1@&m5A3`nwU3i zHK_3!6n+yK9P_4+T~>wKSXlNSC-nb#5>`Xj{mLRIAlF&h6FY`xx)C~}%pt8i)>?yH z;;Z=fA#r;3@54WCxz=pspB`2Cu~DOLh+u6<2zZs_2%%OHh|o! z^G8~6a|yD4$^TA)9*zWohmh@OO4S)GmE&>hdDYZ_U1e^5dhE6j92X}sPt-!a`%Ecb z8$6swcDZ|Py?*DDJy<^thJI*hm2cqEBx}{FRMC6D9_`=Yly@bXC&6JYQe0&H=8j== zE0ZQTEwdK3g=_Dzv}I@K%0!m{$)=G*sWzO%?mwmnSLl(u*(S)rv1n+8Qe5VQTXu|e z9`#SGWxm!&J>J&OVB3BTb^m0i)_?a$!QHadGgFVpoDmZTq?}46i^(sjVXPV-c2`Z{ z#{O>#%E*`ab%XEiqX z%Gas8Wz98~Ev!CSIXsB_kuess%(}RjpHKX{_DL?VS)Rg-=kU(;fdjwN??zu)CpkH! zN#>=BFLoz)DsGq8gl!3tydyrZGHy9%;uJ8V=<0zv`YntthgR6bdX;{#Y% zP1K#0u*aEVlAiiM+aG>Q#fl|i?skiNs$5Q`rVA+57CH*rs#>l}S=e6#FN+>&Q!_M&FpiuNg7yn+3oSPa;{iwgD`1}ypOtCK)p$&dQXx_X3T*9JjwT>766 zJ2;Pp<63jOaDkSN=NH&vSwf-L;E+NPq7x#=!2C0xM_`be!&!f|GBF%jip=>8a^F#kdKO!97P`sBwd zR}TdKX+^DMcc2A}Z|-$DUbbUVfjZ?YOM?$@UrugEkX_`Kno0?;IH7GTl&0{%k|_*- z2~xi(eUvnB54&awJ=9>w0~0y_ONDPb+nFYD$%O7DM-Ni*%`?Jy0~a@B;A(QKBV z$hgMW$ldBA+{T^&$9ncTp>1IAY?MBpec?UAS$FHl*1q7r|M8n@pSZ2@69`t*wGpB+ z!Sn?3RZid3 z(=-M1>^79_la4WUZZRa|s?c}Us_*N`-&9LYM3;%UAZ3iWY2y~KZH9CmpSV@n&kCHb zR9#qNm3!$!BB6k9!viRi3Z!-3KNq~CRMPt#l~|_n2M7yCeS7;ld_W}y zmLHP#RxHrm8h#j;cc+%nso;NOSf72-$L#nzhc$JE&NV147hUJ4Q`X$>%RbS^*SKD2 zYqBP8jz`-_$SvX#9T>V$YUwsi`Vv)gp75+m{i#d(bz4!Y6{xYx;fAVNwPi_Fq^JtF zwY~)2^&KhkZo5TkC5stz36A;mGB*3qakm zayf4XYu~1?=8WYi!#4Beeu{W{-)3Eacasc3@gkTQl@k$+fYhz)+rQ`wtpLB^z$K!x zF#@gr10P}J__FTK8o^l|7M2y1;8_|U3P-^{Oql52*LVJ8R9A>sN^hQ7J_`p&5;ns@ z{>xE6!e7meNzAJo`tsB}P8~xVl&CWEbDB~Ve0c#cTevEU-x+G?VQQ-dosCWK}Gv!{~s&h!#1I*$gj~K zi@7bitgUl$nDVljK%Mq>cSKE}H#w>SN6;>32#b{G*1Kvg?Qhh;%hW-OwU3hLgz~tJ1?h>D}^fnTY zz8d&t&ccx+ZVR{zt)*SD#E3fwoa?R`o|tj;@eFdT-%+y=hvFQI7=8;Ad6%Q97I!ea zLlVdBoxJoEZ|V#OZYiXV^v%ltjK6YeFN7ci^!E6_>8>?x7A>S13>+{_(dgv&HbBGf zt9CC~&C6<)p77R?#kF8p;qvQ#sNMQ`i1;kif0P;-G3p=N4&mHG3aLW6#QRvSAMrsg#oIFb>Z`|Ivh@8Jn_4eLFj~JW z_VyH7K)yX1s5bg?ctvL?y&(zw4HCY4p~<|)AzpjHQDvg~qX;Ak9wYXRG*6Id+(?Z} z%fGjDFN#zDCBb^_mN@mquo^N~jM__xo&oAV7BTXf;dik_DD=@&`N;sPgjq^4N6%Z7Ja6;mixqrU>X$WI724Bb* zPPap&0Jd!OKEXJLbA~cVZi?Fjs`>jSOq6xgZfFQM&d^5ThKor=25$@}WboE(vry@K zcl)|rPrHMNldgiCK;wPuEiV-f+}Z)jW}okO(`mH9bn9eW_v|^&$O%@?y16hOtlSEV z;c=yhlA*2EDJ-n~La}6d7;o&)8+Y;AbDElvyUek#e6myU>5^w&^m?BSy|c7}bHT|z zTNcFAGl6$bpHGF;gMt;}iAuQUQob6Wmzc{<-e@hm3wOg@Xl@u58i;^#tzXu2iw^VEEc$sJT3hgpo#}d4uDQcU1*#-m#%jAhj*qmgbFot zhMepgjZIt>%Q`qxiFe?Y0nyeaa9lPMg3gksvFjmP}k2p zFSn{WX_Wap?7i?rnT#+vM#AwAz6P&$=Vny&wlHnGK8g0GX9K_ABFNr~{DKbnHQGmq zBi@n8dFJb({G%{?a73eN5%#D;Vpe!f$#|LKgKh`=Tq1jMFIcIWd2ibWs zfjG4;#ZTu|Z#3C&o<<12NSf8AU#D_ydl6%J9x!_M9103h#2BTQr78Gvc+uVlmTrcL z5Zp5}x}KNnZr4?RIAJPzCZ0@QKT&t|KaQ-&m-w=EUQvG^RTyY6u>O5x3+*`%@b$+| zZuNc^h1>ZMX}d2zd!OQeU%brT*Q;VbP8rit3NL^Vdts$?M!owOsdveG((%98QzwEQ zOfp-l>hlo9UOZ(N;on<%vwveGIv!*CA5_uV7Na1Pi>_xPUqvrS$Q^l9C1rsq$XK>7(!S5;fEbq$;xz#e{rZ zFCaO>Ho4Oj_~ByV(EmE|so8Y*!{s6wT&->@Kvv3Qz*kt;CYrl({NMmz=+J);-tp`r zmT9{#V(phYD_5(t{@Hx@GkI6t)y^qx5Q5dF=AH(BO{KVYGD8agg9&SyTV!&o&8m&w z|3W(Z@@8R<&Ha#TQHuP5d5@6B#!xrI_vLP52a8c?6Kruhk{J_P_%oK35qN8c4&-6E zg9t(Am#}B_Ih(EA9jU_AW2xQLjk&Mv;fnu8aD0bT#*7yEo9oV%&}22F7r(|lYE337 zo6gZW(1uE9G~KE7Q}qr% z!S<2)URF3QJj(N%6ro99PqgBjbFQ>~p4Ckp+%ev(K29AR+z2F3nhDVE?}9FP9E20x zrb0vXT;g)K3PpT9%~4^A6gxp2&!ctA%UP>>t-Sc$rSS(i8r^zwzhSrCttKzQ0D&g^ zAJIu&7ntejwS{$$@K^kM6gq z>SDL{pOgr$P`%R73wLpD5hp6gIFaF{!E!8cLwsMb@64a>-dH5hCH!!ZYx`drkDrO^ zogL}!zP#$FMyv&`67$AEtNznFNNQ?3n4Vd+v5MeDUNU-~j5N?gKLmPQQ|+Y5uwe@? zTGB}DfwCbpUYggpDZj0rRm|SSi2v+AWkC)!FxEFP`|b$=cX7VKTK$EFOX!9!TT6Gt zH#prT=?DiFN}Djrb3T@ASgBu|ulpE##Cq6GB%4Oti{xKUH}6O3*Q*V`A7}aS3$HI6 zWC>H3uoA9UMXR1K5eC=GO(?BT+jgV=V*?XU??Q0?2frKbRttsv-+njcDn$)OoxM-B z+m~&rZ&zkNrSx0Z`kher&38?g3~^e4D*2GI!N0QZ-m#QA^_v9PE9|WHnt;~S2Xhjr zIF%!M&5~ZvNjQisQl4U@W(l-~-_9wmikRKGuzbxp$rhMVY-wOWf8J@foVb1Nfq3`Gg-w5HI)J>BXvkSp^zQJq(f7#Whp1e2j`Z<8eq(ocba_9251|5 z3V2U7{4OoyD#$>=+4{h7yd<%kR8>c{pk*wIeiC_Brugw;g-EUyZf*SK7}=D#y-De% z>eqLTY?p(RrHd7;B}rhRsTFbTyEsC2T2W^tNwWArvjH}4!o_n!HA>S;Jtl;^bN3e~ z`bb@2`pNtQ@w1s!eEoIXN|SD z%m|M6nzMigFrz&kLe(Rq=fu2Bw;PCD74tCjA$G0N8w~UJltEXYUk6x|*9t2S(PT@W z$n0-y(EWYYyQ;HPt1MaEXqcVmufH!+9XoW|ctj$NlHC}VxMc?j2i<3)^#6_5p!1Sg z9;9yBYbNhV9Z;Vs zR2O?@dtz0Gh)x*untoWVIomu2-|G+TQ$zQ3TN*dM=`ldYp@HMKzH!EuVxG~)EdRq2 z=hxO~_85RT-5&e3i&y7wAMdQ@^+Pd4>DvS}*dAp>lA!PVQYCwpf*Gb9hIpau_LQiX zpQCqW=ju|DMBA(C%PM;0goGKB-eHHuQf9HarIC(PTg!xgj<(tyT{_%Gvqv-*6l; zJHns!pTRT6ixO^N`3B2VSs=0tD9pzH9Rd9RjY@Dasb(R7%=Ayk-0y$1$#VSsG)Ghf zs89a<{~`MS&mDdLcRx5ol3Kn&Si;VWasDw#B=hr+&i?-s(_s*~bnRk#$`mYnPZ#%puuViXT;Wo5 z2RUi|I@yH`a_&>{%C7-fJ!<7lnw{~hMy5}oO}5C?@;4gf=cqf`BCwQ z{v&!^;0oQo?44zhVDZp!IN%N@3qvMid|mF=Zm11iGTLLtVwKI{<5Q&hGDhgHjTw8u zuJj!I!^vkyqYVll9iu~?*~cSoz7g%?RFhGtq+fdi7cS`MWe8o~$#kgEekJq96756_1+Alu9cTVdYGlJ;UJm2?8~8ect2PFxm~fuRGZjZ&^4+bqT;+$h?6nwR1k2sJ znaqUBL!g>Yp+mIVg7Hu<=VD(x6K46iT(&clqR)As+>=RVvm>a;WKr!ud__lbk*c#2 z^%d1yHTxDy7dnDpWJL*9SP4glcLwF8ep$#Xp}9Sz|f)o_m9VWa9t~+Wee)QqeU97iaPVFfo_ZX|h}*p0-HSRRk3E^Z@- z3L^TG33hjqzmIY5nt5`*w%mRWu5d|yK>;nL1m!Wg402x51-8r zCJ*;HBh2|P?x)aycR$s@Io&`9*{*a(>!7LrKyO07LED23koFJrHwBO*yvp{(ywtTK z<*tniMRATV=P*in)Za#_Qu7>vCa1Bi^I-2qHa%c$mtca(iALeTROj$n|DEcW`n*l% z4aD>lqE}6BeXAdI4kmk-@espVa`32Al9!(3Gnse(;5oh~;Fqw7ibx4r=;O!qgmI&M zf(@g8#DFo)5^6F65Ri*`@*yzNMJVmE398M6MbKz@Q`K|r^ngVK?aiw z6h^p3JB z)!Rd~5$oVtgD&fjm-rpPK3ZzdagC|S!XLA9_jzX6-?Oh=V)B5_ z+dC#TeskO)@zLnNok0WdH%!QG?P|U%5XhbD7^ulE1Ona;lyswXxrj~y@7XzFHe0>j zl;Yh25rtIM#YR_CtKp5)JiZ|+i*UZ&Z9WRa#nXsDP0t^FJg}-IrUN{biWk@_}SXRVGXO{ zIVvxfke+@im_7Sps>AofLFr!7updxgq&&lkr-L8Z-bKA2%({MSlGPtB*BH1B(yG3_ zdWo@{XyL31krW=Aa_PL?-P^Idb}*Pgssnk+SjqFm`9a@aBU-n;obq?V9}fAgDZh7D z*vN?#ECU*S6;)+~8BB3wV+1xB|LiaDX|;lGQ+TGqtnG8?Crq1h33;Jw#V*0;wcx-1BL6llu&yk<)O z!SShhxqI5kElgTaEQd}GhpW{wRcZ5ft5zBX&p?2-#06VHGY>3y|0VM|qxe~uy19=O zO^oM$=NFHrB}yIoO)`7v0sCZfD7A59JbjbMzLTrdg^s4HW#K%EIVxpn5^Ec<>6Yt# z*8{(4XvO7W%c{HxBtfg9k5F{$T$2}q1;c{@8av8Fx^aV?a8XFA)7BjSR^;Jup%;q= zyrZdbz;A?3MbYMaRO%76MW_Tb=YUdp$g%7=sk-(s)3{c~8dCG)7)=S!t1x6R1sxaK zx|V8ZYW(L4^QA+rk7KMK+3c>-7LWAL4W=I@tw*z6$PR>;akxT{2mAQMbND8bH(l{Q zPvmL9k)*q69kao_%>pU+Y0Xyyi6OmVz2#&XUG zUEKZRmarr~)U%yJdFTc6%$N2uerH+yW*0Ed?6SMGnDgq!jYbqhh)zNnaeYXOQ}Dzz zZa>Fmnh%)xW z)NeLNq?a$u2kOcRf^7xU?GEoXJpKdN$VWEnhoFbQhxJZ=eff{=VIc3Djou{>1yRpu zS;p}1r6-;IEb)MOCM{q*G!?_%z`Q0>AS0R3S2ji3tgZ#%OPyfs`@^HE`2O4=U6kd_ zJtp1n=YLK zX2e9-!dxLDI6kLEm0JeuKlJ9vtWKmNILxxgR?1{0(Nv!uNkcr4lji zw^Ode(Uv>@a4wBPjJ`(|(=eucF{j3X&)=YuHLQi_!&{p~`M+afyym>8>Y4r21LpPRzGuNCr}C4m<=^Pjvc`(M24 zW_7AI@ceT$xy$V9>%EcHN^G-U}^9yr-DaI~BE#q6%^iwitXU?J)ukCcFgXiQs^Ae7d#PDc9= z%7p~RCk_{ACvcF(sXx&B$=sk$t@eF1;xBs?%wJpkFc9Xi0c{DiOli7qs~ z7Vlf87Aj>rbgF>&N~=D&Ekmctzyf!}l%V|bTR);OskC9ZqD<*nl5)>{D15FXJv)NV z33LB)TKP}6aI_q;r>OVdwT21?SlO06&C%J{J#Ho@eco(93(IgtkSUO@D`Y}$iAw$N zbgMkc|G(0$qr*>+pY+fndf>Qpt@svdSh~@b*SBEw8%;C2atwH*Jx(o8uUVP-IH+?3 zk#i%S3RFPH;R5lL(cWZGmy=%%^W%3_c_i0_8S)%{u85Z{(;f1;|XY~CkSr}fp5*8RSvD_#+uIo zH!29rR964CncGG;iJ9k(6P3hfeK{Lh^8KiP7+k<7=37Tmy-B957spBH8%f8X zENoWrHw$B9{Vyy`kr`lNVl#iTFq-Wf7IuN3S{o4(eb&$sQc|f5*Z7G~4aAB3b_EH& zWzF{@9@!$>!X<%!ty+!7jQYT@?X1LUB~Qk650gp**U?bf_1a3gNiV^!-eh(S+#XeT z3#Witl|J&%Yu?N>&1vSg`K)cw7{#t@0vHB4dX-pv^}yMg{GzMv-9`3dqYT=OC#vaM zwRvwOeY4f4Gj*`)hL(}!;<+oo*a0oH?NvI$)jX-3r!=<4d{By#naKK#t^Y7kVY$Br znvFcrxyzx=&69R~C5!fAV?(zC31^V^gY`&Hqiesf`YEzsN75cGI1{}Ll@i6NIqTTS~hZmXAjaPeC%k` zZO4-7THae6h9v8%{@}$%`>O^Bgom8?Z4!bcto+b>2&>6$#BkRzW_$NkNL=D}IrBTe|a42?vLs|d!yN5=7&HP@CSlLf>nL;2SvRUhQ zKqRUnW`R1gxz$Kqry>zG0L8vcKk-SVV+O@4>+a}*xh{0Lcg}5HoCCH$CpMb3^-#Ts zNxEfRt2X|(oXC9i(-2FK_=CIKgI7fz;dV{2ABfCZNR(w=RgLb=|C9*neUk{;w+?Wv zF>uPGm~p;}Dvw_d4T784BC?i#plbzPGn&fD!})ThLNfGdnnJAx`VYTzA|2@pAUL8@ zf03_gv41CDGDhy(c)Mx!yjt#sTIGwR*PHg!2e0Ea2Wet=kJ7-5m9(I35MRW@mpA0z zb3QZhQDY3z%Z;*3*cZ!`kanj2N#Z0@&<4vDG~rHKSJ#mMR&H7$$ueX z^H~c;L4S}ilN%ByqdniTS+g55-oiUhc@}4620z}*QD|CdZfc#<+D9yvezm@N7}|Ix zM7_E*u_Slyo^tHB4pH??gxaxqdwGhcgJ0!{Cri27zjZzBh_Q=aq(3DYyQH^lq!~GQ z>e04A6h#U0S{>RoO9NLq=;DYEX`u6^+25eFE4mcr15N|-7GvahTf4P@`@jRCxk>DI z5#bMrX8s^)N6Tfk^PAX!At6sCVE6MGXnukIm=1h%!YedS@he%2f<>V-|+b*t{jm6N)}XL<}3?um(X3?S}QYC1=(3+zFs#;p*;VK{K#@&FoA8e2Ex6O#GP z%)#bU_o0vWhz+B?&5EIyC@WdoR!F6TwVXBjQ&Bs)Yo%PO|IDy-Kb({Xf(1Z5ge$Ie zZV1(F9_04c6T}K8-Q2y8oP24Luujr@=OoX+AN$d2Mx;33UsA&4hF6Ikunp`lmE9&$ zBLnN!(9DqE1DpZ9J^m>VgZWcWLp}Nzx~wTsdB?-&g29WV3YI#`gQ@T57N%bt#SW-v z;yI}*)N?m{+tTJps`fASal6Q!fA zMz2Ni<&LeeM5s9H#9GWX4W2*i)axnLc%%%cXcUPoaNJ%Dh+2W)IJDPHs5+F0a31$G z>-NAl<`KeQ5Po29v%ewQ79kEX^?&+zZZf|A`1AifuJMnr{D0pQg&JOJbmot;2t!$C zYpK|YFv&dnBu8L_qtAgMv~XMP(v}1>;ql{4X67|6=AZr{enqsb>0faka?lac;^GEn zZMnQ}Js9#=Zg7eTNGF26fq>Ir>Q=iQ>P|K~Icq_l|Mfu=one~+ah!gC$8kQU{3~)3 z6Cp(bL~gG99l6=_msJf1h}8_pr&0WeQR~}Z0h!VxG}%BvCduCcnL!r+<=c&7_ z71HUK4weH#I+>`3IqsJT#MV{;bGwQnD|JQ__?-!xeo_CjD46IMArhtttkTLBB6XV9 zs!0S(z+E$HJwNE?;lv3f)Fxu3}md^*Z2t* zkr_Quh02dxITDX+S&OK<+Ly^Xb?fMkYCzj zJK+3*En;rSEbwJEpYneFbSNXMFY63f?Jro@oFqT}Cs*H>T!lM7me{yc)Lg@~E}s$P z4Ae=K*VQ0WtE0N-ynN`Y0;p1n+~h;?6cQ2NuZ;K>+_MT` zQg7nghgVK_z)W2!&Im2?oQj84Iqh$QINoI)1DhVtGnGlyXmy?ieuzPs)d>=#Rrf41zGof?@3`>-p*a+*uuhPr1(#> zlu8$gMSd>geP&{|c|G(-p~`3jyjT1O&K%yr?RTQ8 z!UYHq-*cAruM3p1po){>W8>uAB71v}oo6z%(znod`Yj=^^%YRra2?@nRE#1uQ&=g+Ya@3?+XMnExJklcrj&=j`RxXP;8m}+T7|vVO6&y3^%2u1`pk$@j zI_{dC*0`;#_Y3uf9<~tq&3M#takExyrp}u=y>H`=Rt?q6g5K>Jfjspo=E-TvYt+-{ zo!BDeGjGC6TNrZ5D-dRnuZ~j4c`!U7mkp3ZE{?~P=`v1aB(t6ynFSB^s#PX_Nh5^a zVe~^P6$zLbCpccjVHYvC`K&|sl}EXs4NpzyiS9OTE<5o#d7TV6-)A*r%;B0Ku$y zRimWK$aw;i&MCpTn+x{-bjeKCK!++&H{0i0!HNoJaLd566w(t(LNU#krD~ht8TSUV zEMi`buLY7_iRYAkv-*t8B75{`s#53^+p8i?MlA^emnW%Y7`*il^Ci>|zzX_H*0rtm z=10pPS4o(YIs4W=nS{t&rOcKS+&BO3G!x}`*lc3EZj~mXoUAu*cJex``i(!aRiL{+ z`h2ju2M1b}sH339U~hYH_PPqW1vV@8T;LhYy3hh;bl^({Jxb@Y7SFF`5tF(4ht+#Y zk2Vx6QqU(8<|Tl+WMFlgF_b)8;5C(yK3(?;AsEL4akO(1BW;Tnb$5hrulH#hVl`=r_Hg-L#!F( z$nzAPCgu?Mg9Hf*{Xxz*OHPh0uA;3_qNJIFIhj-ff4&HzwE(dX}Y7T_{#;wkO@CXpvi4seO3o?OcDA>HW{utXA-#0*a+` z35$(1w-lD3oUdnHo|Wwt&6Q%~)7%X-J%yEr(6E-oJ#z-kB!BUcw`&PlA>`KjlrbLE zBzX30%JW--gRqJgeG*n0rPtG!?l6b4!?LFrK(P-UI zjI-ui711t@mk6y| z06h7Y^%=kjD~=?d0o;#{kZ*}T!1NI`9>bMBRCe1X+0tlrI;Sq^X1$iA~IY=+OZoP5J^dJ_yIkBr#^sS4k{ z1XE{xqFp`pewcYpyoEP7pROOZDoGIaD0|D^p!KCK7H+{YTXbNNN<-R?Sx#&UL%Qw$ zRov7E!Qm%fkBSvUXy=*v!=J7tl%$+K4C-ds!=w8Mu0qGR^>-0iAtv7~Qn|dVCluH! zzWdcoDf$Q)S)zV`(0cnpXYO+3E9*?{n?yAi)O%qR^W&Tzi$p}5@a?;`F>uoH$IW!?Dc zty|u=rG~z9Pm{TH77 z(Y?*5)fBNL5}fHNn{(QHQ+cKjb6UP#ou(D+i|K>wSN0G3Y-9?b=kQEO=+9Ih`#hN_ zdu_+5O52a<16$;nf3?LS`}Cy)E+63t^Z}li$QK>f?#*Hs&PtKL8WA}9#t0HPeY0gV z+@H}1`)Hl+QhlV%jf1t>uuiC$=6199&!jri=#{&LgoEwgOW%)ihUX0$PyM-b zJoeh)+6WR}TTb@i{%oRpyDx8#sxb!IRfU}=GWI&V6iafO<&hrEIB;-2w�m>iWci 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

    WM)a4bEe%* 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 Date: Wed, 3 Jul 2024 15:08:54 +0700 Subject: [PATCH 336/341] [Antora] [PGSQL] Architecture section for postgres doc --- .../images/specialized-instances-postgres.png | Bin 0 -> 369508 bytes .../assets/images/storage_james_postgres.png | Bin 0 -> 215597 bytes .../assets/images/storage_james_postgres.svg | 1 + docs/modules/servers/nav.adoc | 3 +++ .../architecture/consistency-model.adoc | 11 +++++++++++ ...sistency_model_data_replication_extend.adoc | 1 + .../architecture/implemented-standards.adoc | 6 ++++++ .../pages/postgres/architecture/index.adoc | 15 +++++++++++++-- .../mailqueue_combined_extend.adoc | 1 + .../architecture/specialized-instances.adoc | 7 +++++++ 10 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 docs/modules/servers/assets/images/specialized-instances-postgres.png create mode 100644 docs/modules/servers/assets/images/storage_james_postgres.png create mode 100644 docs/modules/servers/assets/images/storage_james_postgres.svg create mode 100644 docs/modules/servers/pages/postgres/architecture/consistency-model.adoc create mode 100644 docs/modules/servers/pages/postgres/architecture/consistency_model_data_replication_extend.adoc create mode 100644 docs/modules/servers/pages/postgres/architecture/implemented-standards.adoc create mode 100644 docs/modules/servers/pages/postgres/architecture/mailqueue_combined_extend.adoc create mode 100644 docs/modules/servers/pages/postgres/architecture/specialized-instances.adoc diff --git a/docs/modules/servers/assets/images/specialized-instances-postgres.png b/docs/modules/servers/assets/images/specialized-instances-postgres.png new file mode 100644 index 0000000000000000000000000000000000000000..9b1d226257c992f4ecb9280df4b40c9f4de6eae0 GIT binary patch literal 369508 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

    o+qSNj%19(=OKq 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 ~~DV!+!0VlGWGHMlbplat}N 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

  • YqoGU9X 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 z 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