diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d12ced7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 120 +tab_width = 4 +ij_continuation_indent_size = 8 + +[*.yml] +indent_size = 2 + +[*.md] +max_line_length = off diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b8afbf6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf encoding=utf-8 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..37e1824 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,43 @@ +name: Publish package to GitHub Packages +on: + workflow_dispatch: + inputs: + version: + description: "Version name to publish (eg: x.x.x)" + type: string + required: true + +jobs: + publish: + timeout-minutes: 15 + runs-on: ubuntu-22.04 + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v3 + + - uses: aboutbits/github-actions-java/setup-and-install@v1 + + - name: Set Version + run: sed -i 's|BUILD-SNAPSHOT|${{ github.event.inputs.version }}|g' pom.xml + + - name: Publish package + run: mvn --batch-mode deploy + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + tag: + timeout-minutes: 5 + runs-on: ubuntu-22.04 + needs: publish + steps: + - uses: actions/checkout@v3 + + - name: Tag Deployment + uses: aboutbits/github-actions-base/git-create-or-update-tag@v1 + with: + tag-name: ${{ github.event.inputs.version }} + user-name: 'AboutBits' + user-email: 'info@aboutbits.it' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..427ac49 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac ### +.DS_Store + +### Local Development ### +application-local.yml diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000..cb28b0e Binary files /dev/null and b/.mvn/wrapper/maven-wrapper.jar differ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..462686e --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.3/apache-maven-3.9.3-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar diff --git a/checkstyle-suppressions.xml b/checkstyle-suppressions.xml new file mode 100644 index 0000000..15eaff5 --- /dev/null +++ b/checkstyle-suppressions.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/checkstyle.xml b/checkstyle.xml new file mode 100644 index 0000000..8621aa5 --- /dev/null +++ b/checkstyle.xml @@ -0,0 +1,207 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/license.md b/license.md new file mode 100644 index 0000000..21586f5 --- /dev/null +++ b/license.md @@ -0,0 +1,7 @@ +Copyright About Bits GmbH + +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. diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..66df285 --- /dev/null +++ b/mvnw @@ -0,0 +1,308 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.2.0 +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "$(uname)" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + JAVA_HOME="$(/usr/libexec/java_home)"; export JAVA_HOME + else + JAVA_HOME="/Library/Java/Home"; export JAVA_HOME + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=$(java-config --jre-home) + fi +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$JAVA_HOME" ] && + JAVA_HOME=$(cygpath --unix "$JAVA_HOME") + [ -n "$CLASSPATH" ] && + CLASSPATH=$(cygpath --path --unix "$CLASSPATH") +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] && + JAVA_HOME="$(cd "$JAVA_HOME" || (echo "cannot cd into $JAVA_HOME."; exit 1); pwd)" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="$(which javac)" + if [ -n "$javaExecutable" ] && ! [ "$(expr "\"$javaExecutable\"" : '\([^ ]*\)')" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=$(which readlink) + if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then + if $darwin ; then + javaHome="$(dirname "\"$javaExecutable\"")" + javaExecutable="$(cd "\"$javaHome\"" && pwd -P)/javac" + else + javaExecutable="$(readlink -f "\"$javaExecutable\"")" + fi + javaHome="$(dirname "\"$javaExecutable\"")" + javaHome=$(expr "$javaHome" : '\(.*\)/bin') + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="$(\unset -f command 2>/dev/null; \command -v java)" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=$(cd "$wdir/.." || exit 1; pwd) + fi + # end of workaround + done + printf '%s' "$(cd "$basedir" || exit 1; pwd)" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + # Remove \r in case we run on Windows within Git Bash + # and check out the repository with auto CRLF management + # enabled. Otherwise, we may read lines that are delimited with + # \r\n and produce $'-Xarg\r' rather than -Xarg due to word + # splitting rules. + tr -s '\r\n' ' ' < "$1" + fi +} + +log() { + if [ "$MVNW_VERBOSE" = true ]; then + printf '%s\n' "$1" + fi +} + +BASE_DIR=$(find_maven_basedir "$(dirname "$0")") +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR +log "$MAVEN_PROJECTBASEDIR" + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" +if [ -r "$wrapperJarPath" ]; then + log "Found $wrapperJarPath" +else + log "Couldn't find $wrapperJarPath, downloading it ..." + + if [ -n "$MVNW_REPOURL" ]; then + wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + else + wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + fi + while IFS="=" read -r key value; do + # Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' ) + safeValue=$(echo "$value" | tr -d '\r') + case "$key" in (wrapperUrl) wrapperUrl="$safeValue"; break ;; + esac + done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" + log "Downloading from: $wrapperUrl" + + if $cygwin; then + wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath") + fi + + if command -v wget > /dev/null; then + log "Found wget ... using wget" + [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet" + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + else + wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + log "Found curl ... using curl" + [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent" + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" + else + curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" + fi + else + log "Falling back to using Java to download" + javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java" + javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaSource=$(cygpath --path --windows "$javaSource") + javaClass=$(cygpath --path --windows "$javaClass") + fi + if [ -e "$javaSource" ]; then + if [ ! -e "$javaClass" ]; then + log " - Compiling MavenWrapperDownloader.java ..." + ("$JAVA_HOME/bin/javac" "$javaSource") + fi + if [ -e "$javaClass" ]; then + log " - Running MavenWrapperDownloader.java ..." + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath" + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +# If specified, validate the SHA-256 sum of the Maven wrapper jar file +wrapperSha256Sum="" +while IFS="=" read -r key value; do + case "$key" in (wrapperSha256Sum) wrapperSha256Sum=$value; break ;; + esac +done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" +if [ -n "$wrapperSha256Sum" ]; then + wrapperSha256Result=false + if command -v sha256sum > /dev/null; then + if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c > /dev/null 2>&1; then + wrapperSha256Result=true + fi + elif command -v shasum > /dev/null; then + if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c > /dev/null 2>&1; then + wrapperSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." + echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties." + exit 1 + fi + if [ $wrapperSha256Result = false ]; then + echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2 + echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2 + echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2 + exit 1 + fi +fi + +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$JAVA_HOME" ] && + JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME") + [ -n "$CLASSPATH" ] && + CLASSPATH=$(cygpath --path --windows "$CLASSPATH") + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR") +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +# shellcheck disable=SC2086 # safe args +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..95ba6f5 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,205 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM https://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.2.0 +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %WRAPPER_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM If specified, validate the SHA-256 sum of the Maven wrapper jar file +SET WRAPPER_SHA_256_SUM="" +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B +) +IF NOT %WRAPPER_SHA_256_SUM%=="" ( + powershell -Command "&{"^ + "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^ + "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^ + " Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^ + " Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^ + " Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^ + " exit 1;"^ + "}"^ + "}" + if ERRORLEVEL 1 goto error +) + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..aab3aae --- /dev/null +++ b/pom.xml @@ -0,0 +1,171 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.1.4 + + + + it.aboutbits.springboot + emailservice + BUILD-SNAPSHOT + Spring Boot Email Service + + + 17 + 1.5.3.Final + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-mail + + + org.springframework.boot + spring-boot-starter-validation + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.mapstruct + mapstruct + ${mapstruct.version} + + + org.postgresql + postgresql + + + com.vladmihalcea + hibernate-types-60 + 2.21.1 + + + + com.fasterxml.jackson.core + jackson-databind + + + + com.github.javafaker + javafaker + 1.0.2 + test + + + org.testcontainers + testcontainers + 1.16.0 + test + + + org.testcontainers + junit-jupiter + 1.16.0 + test + + + org.testcontainers + postgresql + 1.16.3 + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + ${java.version} + ${java.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + org.projectlombok + lombok + ${lombok.version} + + + org.projectlombok + lombok-mapstruct-binding + 0.2.0 + + + + -Amapstruct.defaultComponentModel=spring + -Amapstruct.unmappedTargetPolicy=ERROR + + + + + org.apache.maven.plugins + maven-checkstyle-plugin + 3.2.1 + + checkstyle.xml + + checkstyle-suppressions.xml + + true + + true + true + false + + + + validate + validate + + check + + + + + + com.puppycrawl.tools + checkstyle + 10.3.4 + + + + + + + + + github + GitHub Packages + https://maven.pkg.github.com/aboutbits/spring-boot-email-service + + + diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..5adf7b4 --- /dev/null +++ b/readme.md @@ -0,0 +1,101 @@ +# Spring Boot EMail Service + +A reusable mailer service to send emails. + +## Setup + +Add the mailer service to the classpath by adding the following maven dependency. Versions haven be found [here](../../packages) + +```xml + + + it.aboutbits.springboot + emailservice + x.x.x + +``` + +### Attachments + +If you want to use attachments, you will have to create a bean implementing this interface: [AttachmentDataSource.java](src%2Fmain%2Fjava%2Fit%2Faboutbits%2Fspringboot%2Femailservice%2Flib%2FAttachmentDataSource.java) +This step is optional. + +## Usage + +### Sending an Email + +Use the `ManageEmail` service to schedule an email. + +#### Example + +```java +// @formatter:off +public void sendMail(final String to,final String subject,final String htmlBody,final String plainTextBody) { + manageEmail.schedule( + EmailParameter.builder() + .scheduleAt(OffsetDateTime.now()) + .email(EmailParameter.Email.builder() + .fromName(fromName) + .fromAddress(fromAddress) + .recipient(to) + .subject(subject) + .textBody(plainTextBody) + .htmlBody(htmlBody) + .build()) + .build() + ); +} +// @formatter:on +``` + +### Querying Emails + +To read email datasets from the database use this class: [QueryEmail.java](src%2Fmain%2Fjava%2Fit%2Faboutbits%2Fspringboot%2Femailservice%2Flib%2Fapplication%2FQueryEmail.java) + +### Reporting callback + +If you want to receive a report after each run of the scheduler, create a Bean implementing [EmailSchedulerCallback.java](src%2Fmain%2Fjava%2Fit%2Faboutbits%2Fspringboot%2Femailservice%2Flib%2FEmailSchedulerCallback.java) + +### Configuration + +To enable this service just add `@EnableEmailService` to your main class. You must also enable `@EnableScheduling` to allow the email queue to be processed. + +```java + +@SpringBootApplication +@EnableEmailService +@EnableScheduling +public class App { + public static void main(final String[] args) { + SpringApplication.run(App.class, args); + } +} +``` + +The following configuration options are available: + +| Name | Default | Description | +|----------------------------------------|-------------|-----------------------------------------------------------------------| +| `lib.emailservice.migrations.enabled` | true | Enables database migrations. | +| `lib.emailservice.scheduling.enabled` | true | Enables the scheduler sending the emails. | +| `lib.emailservice.scheduling.interval` | 30000 | Specifies the milliseconds delay between runs of the scheduler. | + +## Building and releasing a new version: + +To create a new version of this package and push it to the maven registry, you will have to use the GitHub actions workflow and manually trigger it. + +## Information + +About Bits is a company based in South Tyrol, Italy. You can find more information about us on [our website](https://aboutbits.it). + +### Support + +For support, please contact [info@aboutbits.it](mailto:info@aboutbits.it). + +### Credits + +- [All Contributors](../../contributors) + +### License + +The MIT License (MIT). Please see the [license file](license.md) for more information. diff --git a/src/main/java/it/aboutbits/springboot/emailservice/EmailServiceConfiguration.java b/src/main/java/it/aboutbits/springboot/emailservice/EmailServiceConfiguration.java new file mode 100644 index 0000000..ae58265 --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/EmailServiceConfiguration.java @@ -0,0 +1,72 @@ +package it.aboutbits.springboot.emailservice; + +import it.aboutbits.springboot.emailservice.lib.AttachmentCleanerCallback; +import it.aboutbits.springboot.emailservice.lib.AttachmentDataSource; +import it.aboutbits.springboot.emailservice.lib.EmailSchedulerCallback; +import it.aboutbits.springboot.emailservice.lib.application.CleanupAttachmentFiles; +import it.aboutbits.springboot.emailservice.lib.application.EmailAttachmentMapper; +import it.aboutbits.springboot.emailservice.lib.application.EmailAttachmentMapperImpl; +import it.aboutbits.springboot.emailservice.lib.application.EmailMapper; +import it.aboutbits.springboot.emailservice.lib.application.EmailMapperImpl; +import it.aboutbits.springboot.emailservice.lib.application.EmailServiceMigrator; +import it.aboutbits.springboot.emailservice.lib.application.ManageEmail; +import it.aboutbits.springboot.emailservice.lib.application.QueryEmail; +import it.aboutbits.springboot.emailservice.lib.application.SendScheduledEmails; +import it.aboutbits.springboot.emailservice.lib.application.UnavailableAttachmentDataSource; +import it.aboutbits.springboot.emailservice.lib.jpa.EmailRepository; +import jakarta.persistence.EntityManager; +import org.springframework.boot.autoconfigure.AutoConfigurationPackage; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.mail.javamail.JavaMailSender; + +import java.util.List; + +@AutoConfigurationPackage +public class EmailServiceConfiguration { + @Bean(initMethod = "migrate") + @ConditionalOnProperty(value = "aboutbits.emailservice.migrations.enabled", matchIfMissing = true) + public EmailServiceMigrator emailServiceMigrator(JdbcTemplate jdbcTemplate) { + return new EmailServiceMigrator(jdbcTemplate); + } + + @Bean + public EmailAttachmentMapper emailAttachmentMapper() { + return new EmailAttachmentMapperImpl(); + } + + @Bean + public EmailMapper emailMapper() { + return new EmailMapperImpl(); + } + + @Bean + public QueryEmail queryEmail(EmailRepository emailRepository, EmailMapper emailMapper, EntityManager entityManager) { + return new QueryEmail(emailRepository, emailMapper, entityManager); + } + + @Bean + public ManageEmail manageEmail(EmailRepository emailRepository, JavaMailSender javaMailSender, AttachmentDataSource attachmentDataSource, EmailMapper emailMapper) { + return new ManageEmail(emailRepository, javaMailSender, attachmentDataSource, emailMapper); + } + + @Bean + @ConditionalOnProperty(value = "aboutbits.emailservice.scheduling.enabled", matchIfMissing = true) + public SendScheduledEmails sendScheduledEmails(QueryEmail queryEmail, ManageEmail manageEmail, List callbacks) { + return new SendScheduledEmails(queryEmail, manageEmail, callbacks); + } + + @Bean + @ConditionalOnProperty(value = "aboutbits.emailservice.scheduling.cleanup.enabled", matchIfMissing = true) + public CleanupAttachmentFiles cleanupAttachments(QueryEmail queryEmail, ManageEmail manageEmail, List callbacks) { + return new CleanupAttachmentFiles(queryEmail, manageEmail, callbacks); + } + + @Bean + @ConditionalOnMissingBean(AttachmentDataSource.class) + public AttachmentDataSource attachmentDataSource() { + return new UnavailableAttachmentDataSource(); + } +} diff --git a/src/main/java/it/aboutbits/springboot/emailservice/EnableEmailService.java b/src/main/java/it/aboutbits/springboot/emailservice/EnableEmailService.java new file mode 100644 index 0000000..eb9f9bc --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/EnableEmailService.java @@ -0,0 +1,14 @@ +package it.aboutbits.springboot.emailservice; + +import org.springframework.context.annotation.Import; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Import({EmailServiceConfiguration.class}) +public @interface EnableEmailService { +} diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/AttachmentCleanerCallback.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/AttachmentCleanerCallback.java new file mode 100644 index 0000000..680804e --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/AttachmentCleanerCallback.java @@ -0,0 +1,12 @@ +package it.aboutbits.springboot.emailservice.lib; + +public interface AttachmentCleanerCallback { + void report(Report report); + + record Report( + int total, + int cleaned, + int errors + ) { + } +} diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/AttachmentDataSource.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/AttachmentDataSource.java new file mode 100644 index 0000000..f05b85a --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/AttachmentDataSource.java @@ -0,0 +1,13 @@ +package it.aboutbits.springboot.emailservice.lib; + +import it.aboutbits.springboot.emailservice.lib.exception.AttachmentException; + +import java.io.InputStream; + +public interface AttachmentDataSource { + InputStream getAttachmentPayload(long fileReference) throws AttachmentException; + + long storeAttachmentPayload(InputStream payload) throws AttachmentException; + + void releaseAttachment(long fileReference) throws AttachmentException; +} diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/EmailAttachmentDto.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/EmailAttachmentDto.java new file mode 100644 index 0000000..3f138df --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/EmailAttachmentDto.java @@ -0,0 +1,16 @@ +package it.aboutbits.springboot.emailservice.lib; + +import it.aboutbits.springboot.emailservice.lib.model.Email; + +public record EmailAttachmentDto( + long id, + + Email email, + + String fileName, + + String contentType, + + long fileReference +) { +} diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/EmailDto.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/EmailDto.java new file mode 100644 index 0000000..82b818b --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/EmailDto.java @@ -0,0 +1,33 @@ +package it.aboutbits.springboot.emailservice.lib; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Set; + +public record EmailDto( + long id, + + EmailState state, + + String subject, + + String fromAddress, + String fromName, + + List recipients, + + String textBody, + String htmlBody, + + Set attachments, + + OffsetDateTime scheduledAt, + OffsetDateTime sentAt, + + OffsetDateTime errorAt, + String errorMessage, + + OffsetDateTime createdAt, + OffsetDateTime updatedAt +) { +} diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/EmailSchedulerCallback.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/EmailSchedulerCallback.java new file mode 100644 index 0000000..675b6ca --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/EmailSchedulerCallback.java @@ -0,0 +1,12 @@ +package it.aboutbits.springboot.emailservice.lib; + +public interface EmailSchedulerCallback { + void report(Report report); + + record Report( + int total, + int sent, + int errors + ) { + } +} diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/EmailState.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/EmailState.java new file mode 100644 index 0000000..f053425 --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/EmailState.java @@ -0,0 +1,7 @@ +package it.aboutbits.springboot.emailservice.lib; + +public enum EmailState { + PENDING, + SENT, + ERROR +} diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/application/CleanupAttachmentFiles.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/CleanupAttachmentFiles.java new file mode 100644 index 0000000..af3954d --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/CleanupAttachmentFiles.java @@ -0,0 +1,51 @@ +package it.aboutbits.springboot.emailservice.lib.application; + + +import it.aboutbits.springboot.emailservice.lib.AttachmentCleanerCallback; +import it.aboutbits.springboot.emailservice.lib.exception.AttachmentException; +import lombok.AllArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.scheduling.annotation.Scheduled; + +import java.util.List; + +@AllArgsConstructor +@Log4j2 +public class CleanupAttachmentFiles { + private static final String JOB_DESCRIPTION = "Cleanup attachments of sent Emails."; + + private final QueryEmail queryEmail; + private final ManageEmail manageEmail; + private final List callbacks; + + @Scheduled(initialDelayString = "${aboutbits.emailservice.scheduling.interval:30000}", fixedDelayString = "${aboutbits.emailservice.scheduling.interval:30000}") + void cleanupAttachments() { + log.info("Start: " + JOB_DESCRIPTION); + + var emailsToCleanup = queryEmail.readyToCleanup(); + + var countCleaned = 0; + var countError = 0; + for (var email : emailsToCleanup) { + try { + manageEmail.cleanupAttachments(email); + countCleaned++; + } catch (AttachmentException e) { + countError++; + } + } + + log.info("Finished: " + JOB_DESCRIPTION); + log.info("Cleaned: {}, Errors: {}", countCleaned, countError); + + for (var callback : callbacks) { + callback.report(new AttachmentCleanerCallback.Report( + emailsToCleanup.size(), + countCleaned, + countError + )); + } + } + + +} diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/application/EmailAttachmentMapper.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/EmailAttachmentMapper.java new file mode 100644 index 0000000..e8d572a --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/EmailAttachmentMapper.java @@ -0,0 +1,14 @@ +package it.aboutbits.springboot.emailservice.lib.application; + +import it.aboutbits.springboot.emailservice.lib.EmailAttachmentDto; +import it.aboutbits.springboot.emailservice.lib.model.EmailAttachment; +import org.mapstruct.Mapper; + +import java.util.List; + +@Mapper +public interface EmailAttachmentMapper { + EmailAttachmentDto toDto(EmailAttachment model); + + List toDto(List model); +} diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/application/EmailMapper.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/EmailMapper.java new file mode 100644 index 0000000..c5aa008 --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/EmailMapper.java @@ -0,0 +1,24 @@ +package it.aboutbits.springboot.emailservice.lib.application; + +import it.aboutbits.springboot.emailservice.lib.EmailDto; +import it.aboutbits.springboot.emailservice.lib.model.Email; +import org.mapstruct.Mapper; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; + +import java.util.List; + +@Mapper(uses = EmailAttachmentMapper.class) +public interface EmailMapper { + EmailDto toDto(Email model); + + List toDto(List model); + + default Page toDto(Page model) { + return new PageImpl<>( + toDto(model.getContent()), + model.getPageable(), + model.getTotalElements() + ); + } +} diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/application/EmailParameter.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/EmailParameter.java new file mode 100644 index 0000000..d6e1856 --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/EmailParameter.java @@ -0,0 +1,60 @@ +package it.aboutbits.springboot.emailservice.lib.application; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.Builder; +import lombok.NonNull; +import lombok.Singular; + +import java.io.InputStream; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Set; + +@Builder +public record EmailParameter( + @NonNull + OffsetDateTime scheduledAt, + @NonNull + Email email +) { + + @Builder + public record Email( + @NonNull + @NotBlank + String subject, + + @Singular + @NonNull + @NotEmpty + List recipients, + + @NonNull String textBody, + @NonNull String htmlBody, + + @NonNull + @NotBlank + String fromAddress, + @NonNull + @NotBlank + String fromName, + + @Singular + @NonNull + Set attachments + ) { + @Builder + public record Attachment( + @NonNull + InputStream payload, + @NonNull + @NotBlank + String fileName, + @NonNull + @NotBlank + String contentType + ) { + } + } +} diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/application/EmailServiceMigrator.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/EmailServiceMigrator.java new file mode 100644 index 0000000..1223d40 --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/EmailServiceMigrator.java @@ -0,0 +1,70 @@ +package it.aboutbits.springboot.emailservice.lib.application; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; + +@Slf4j +public class EmailServiceMigrator { + private final JdbcTemplate jdbcTemplate; + + public EmailServiceMigrator(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + void migrate() { + log.info("EmailService: running DB migrations..."); + + jdbcTemplate.execute(""" + + create table if not exists email_service_emails + ( + id bigint generated by default as identity + primary key, + state text default 'PENDING'::text not null, + subject text not null, + recipients jsonb not null, + from_address text not null, + from_name text not null, + text_body text not null, + html_body text not null, + attachments jsonb default '[]'::jsonb, + attachments_cleaned bool default false not null, + scheduled_at timestamp with time zone default now() not null, + sent_at timestamp with time zone, + error_at timestamp with time zone, + error_message text, + created_at timestamp with time zone default now() not null, + updated_at timestamp with time zone default now() not null, + constraint email_service_emails_one_body_not_empty + check ((text_body <> ''::text) OR (html_body <> ''::text)) + ); + + create index if not exists email_service_emails_state_index + on email_service_emails (state); + + create index if not exists email_service_emails_scheduled_at_index + on email_service_emails (scheduled_at); + + create index if not exists email_service_emails_attachments_cleaned_index + on email_service_emails (attachments_cleaned); + + create table if not exists email_service_email_attachments + ( + id bigint generated by default as identity + primary key, + email_id bigint not null + constraint email_service_email_attachments_email_id + references email_service_emails, + file_name text not null, + content_type text not null, + file_reference bigint not null + ); + + create index if not exists email_service_email_attachments_email_id_index + on email_service_email_attachments (email_id); + + """); + + log.info("EmailService: migrations done!"); + } +} diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/application/ManageEmail.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/ManageEmail.java new file mode 100644 index 0000000..6b0f224 --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/ManageEmail.java @@ -0,0 +1,172 @@ +package it.aboutbits.springboot.emailservice.lib.application; + + +import it.aboutbits.springboot.emailservice.lib.AttachmentDataSource; +import it.aboutbits.springboot.emailservice.lib.EmailDto; +import it.aboutbits.springboot.emailservice.lib.EmailState; +import it.aboutbits.springboot.emailservice.lib.exception.AttachmentException; +import it.aboutbits.springboot.emailservice.lib.exception.EmailException; +import it.aboutbits.springboot.emailservice.lib.jpa.EmailRepository; +import it.aboutbits.springboot.emailservice.lib.model.Email; +import it.aboutbits.springboot.emailservice.lib.model.EmailAttachment; +import jakarta.mail.MessagingException; +import jakarta.validation.Valid; +import lombok.NonNull; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.mail.MailException; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.validation.annotation.Validated; + +import java.io.IOException; +import java.time.OffsetDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + + +@Validated +@Slf4j +public class ManageEmail { + private final EmailRepository emailRepository; + private final JavaMailSender mailSender; + private final AttachmentDataSource attachmentDataSource; + private final EmailMapper emailMapper; + + public ManageEmail( + EmailRepository emailRepository, + JavaMailSender mailSender, + AttachmentDataSource attachmentDataSource, + final EmailMapper emailMapper) { + + this.emailRepository = emailRepository; + this.mailSender = mailSender; + this.attachmentDataSource = attachmentDataSource; + this.emailMapper = emailMapper; + } + + public EmailDto schedule(@NonNull @Valid EmailParameter parameter) throws EmailException { + Email email; + try { + email = fromParameter(parameter); + } catch (AttachmentException e) { + throw new EmailException(e); + } + + var savedEmail = emailRepository.save(email); + + return emailMapper.toDto(savedEmail); + } + + public EmailDto sendOrFail(@NonNull @Valid EmailParameter parameter) throws EmailException { + Email email; + try { + email = fromParameter(parameter); + } catch (AttachmentException e) { + throw new EmailException(e); + } + + var savedEmail = send(email); + + if (savedEmail.hasFailed()) { + throw new EmailException(savedEmail.getErrorMessage()); + } + + return emailMapper.toDto(savedEmail); + } + + Email send(Email email) { + if (EmailState.SENT.equals(email.getState())) { + return email; + } + + try { + sendMail(email); + email.setState(EmailState.SENT); + email.setErrorMessage(""); + email.setSentAt(OffsetDateTime.now()); + } catch (MailException | MessagingException | AttachmentException | IOException e) { + log.error("Failed to send email: " + email.getId(), e); + email.setErrorMessage(e.getMessage()); + email.setState(EmailState.ERROR); + } + + return emailRepository.save(email); + } + + void cleanupAttachments(final Email email) throws AttachmentException { + for (var attachment : email.getAttachments()) { + attachmentDataSource.releaseAttachment(attachment.getFileReference()); + } + email.setAttachmentsCleaned(true); + emailRepository.save(email); + } + + private Email fromParameter(EmailParameter parameter) throws AttachmentException { + var emailData = parameter.email(); + + final var email = new Email(); + email.setState(EmailState.PENDING); + email.setScheduledAt(parameter.scheduledAt()); + email.setSubject(emailData.subject()); + email.setTextBody(emailData.textBody()); + email.setHtmlBody(emailData.htmlBody()); + email.setRecipients(emailData.recipients()); + email.setFromAddress(emailData.fromAddress()); + email.setFromName(emailData.fromName()); + + var attachments = new HashSet(); + for (var attachment : parameter.email().attachments()) { + var reference = attachmentDataSource.storeAttachmentPayload(attachment.payload()); + + var emailAttachment = new EmailAttachment(); + emailAttachment.setEmail(email); + emailAttachment.setContentType(attachment.contentType()); + emailAttachment.setFileName(attachment.fileName()); + emailAttachment.setFileReference(reference); + + attachments.add(emailAttachment); + } + + email.setAttachments(attachments); + + return email; + } + + private void sendMail(Email notification) throws MessagingException, IOException, AttachmentException { + sendMail( + notification.getFromAddress(), + notification.getFromName(), + notification.getRecipients(), + notification.getSubject(), + notification.getHtmlBody(), + notification.getTextBody(), + notification.getAttachments() + ); + } + + private void sendMail(String fromAddress, String fromName, List recipients, String subject, String htmlBody, String plainTextBody, Set attachments) throws MessagingException, IOException, AttachmentException { + var message = mailSender.createMimeMessage(); + var helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setFrom(fromAddress, fromName); + helper.setTo(recipients.toArray(String[]::new)); + helper.setSubject(subject); + + if (!htmlBody.isBlank()) { + helper.setText(plainTextBody, htmlBody); + } else { + helper.setText(plainTextBody); + } + + for (var attachment : attachments) { + var payload = attachmentDataSource.getAttachmentPayload(attachment.getFileReference()); + helper.addAttachment(attachment.getFileName(), new ByteArrayResource(payload.readAllBytes())); + payload.close(); + } + + + mailSender.send(message); + } +} diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/application/QueryEmail.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/QueryEmail.java new file mode 100644 index 0000000..9714345 --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/QueryEmail.java @@ -0,0 +1,64 @@ +package it.aboutbits.springboot.emailservice.lib.application; + + +import it.aboutbits.springboot.emailservice.lib.EmailDto; +import it.aboutbits.springboot.emailservice.lib.EmailState; +import it.aboutbits.springboot.emailservice.lib.jpa.EmailRepository; +import it.aboutbits.springboot.emailservice.lib.model.Email; +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; + +import java.time.OffsetDateTime; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +public class QueryEmail { + private final EmailRepository emailRepository; + private final EmailMapper emailMapper; + private final EntityManager entityManager; + + public Page paginatedByState(EmailState state, PageRequest pageParameter) { + var pageRequest = PageRequest.of(pageParameter.getPageNumber(), pageParameter.getPageSize(), Sort.by("updatedAt")); + + return emailMapper.toDto(emailRepository.findByState(state, pageRequest)); + } + + public List byIds(Collection ids) { + if (ids.isEmpty()) { + return Collections.emptyList(); + } + return emailMapper.toDto(emailRepository.findByIdIn(ids)); + } + + List readyToSend() { + var entityGraph = entityManager.getEntityGraph("email_service_emails-entity-graph"); + return entityManager.createQuery(""" + SELECT e from Email e WHERE e.scheduledAt < :scheduledBefore AND e.state IN ( + it.aboutbits.springboot.emailservice.lib.EmailState.PENDING, + it.aboutbits.springboot.emailservice.lib.EmailState.ERROR + ) + """, Email.class) + .setParameter("scheduledBefore", OffsetDateTime.now()) + .setHint("jakarta.persistence.fetchgraph", entityGraph) + .getResultList(); + } + + List readyToCleanup() { + var entityGraph = entityManager.getEntityGraph("email_service_emails-entity-graph"); + return entityManager.createQuery(""" + SELECT e from Email e WHERE e.attachmentsCleaned=false AND e.state=it.aboutbits.springboot.emailservice.lib.EmailState.SENT + """, Email.class) + .setHint("jakarta.persistence.fetchgraph", entityGraph) + .getResultList(); + } + + public Optional byId(long id) { + return emailRepository.findById(id).map(emailMapper::toDto); + } +} diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/application/SendScheduledEmails.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/SendScheduledEmails.java new file mode 100644 index 0000000..4f6a767 --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/SendScheduledEmails.java @@ -0,0 +1,50 @@ +package it.aboutbits.springboot.emailservice.lib.application; + + +import it.aboutbits.springboot.emailservice.lib.EmailSchedulerCallback; +import lombok.AllArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.scheduling.annotation.Scheduled; + +import java.util.List; + +@AllArgsConstructor +@Log4j2 +public class SendScheduledEmails { + private static final String JOB_DESCRIPTION = "Sending open and failed email notifications."; + + private final QueryEmail queryEmail; + private final ManageEmail manageEmail; + private final List callbacks; + + @Scheduled(initialDelayString = "${aboutbits.emailservice.scheduling.interval:30000}", fixedDelayString = "${aboutbits.emailservice.scheduling.interval:30000}") + void sendEmails() { + log.info("Start: " + JOB_DESCRIPTION); + + var emailsToSend = queryEmail.readyToSend(); + + var countSent = 0; + var countError = 0; + for (var email : emailsToSend) { + var updatedEmail = manageEmail.send(email); + switch (updatedEmail.getState()) { + case ERROR -> countError++; + case SENT -> countSent++; + default -> log.warn( + "Email job produced an invalid notification result state: {}.", + updatedEmail.getState().name()); + } + } + + log.info("Finished: " + JOB_DESCRIPTION); + log.info("Sent: {}, Errors: {}", countSent, countError); + + for (var callback : callbacks) { + callback.report(new EmailSchedulerCallback.Report( + emailsToSend.size(), + countSent, + countError + )); + } + } +} diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/application/UnavailableAttachmentDataSource.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/UnavailableAttachmentDataSource.java new file mode 100644 index 0000000..6d7bd52 --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/UnavailableAttachmentDataSource.java @@ -0,0 +1,23 @@ +package it.aboutbits.springboot.emailservice.lib.application; + +import it.aboutbits.springboot.emailservice.lib.AttachmentDataSource; +import it.aboutbits.springboot.emailservice.lib.exception.AttachmentException; + +import java.io.InputStream; + +public final class UnavailableAttachmentDataSource implements AttachmentDataSource { + @Override + public InputStream getAttachmentPayload(long fileReference) throws AttachmentException { + throw new AttachmentException("attachments not available"); + } + + @Override + public long storeAttachmentPayload(InputStream payload) throws AttachmentException { + throw new AttachmentException("attachments not available"); + } + + @Override + public void releaseAttachment(long fileReference) throws AttachmentException { + throw new AttachmentException("attachments not available"); + } +} diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/application/package-info.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/package-info.java new file mode 100644 index 0000000..20c8675 --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/application/package-info.java @@ -0,0 +1,6 @@ +@NonNullApi +@NonNullFields +package it.aboutbits.springboot.emailservice.lib.application; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/exception/AttachmentException.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/exception/AttachmentException.java new file mode 100644 index 0000000..f836e33 --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/exception/AttachmentException.java @@ -0,0 +1,23 @@ +package it.aboutbits.springboot.emailservice.lib.exception; + +public class AttachmentException extends Exception { + public AttachmentException() { + super(); + } + + public AttachmentException(final String message) { + super(message); + } + + public AttachmentException(final String message, final Throwable cause) { + super(message, cause); + } + + public AttachmentException(final Throwable cause) { + super(cause); + } + + protected AttachmentException(final String message, final Throwable cause, final boolean enableSuppression, final boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/exception/EmailException.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/exception/EmailException.java new file mode 100644 index 0000000..9bc9855 --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/exception/EmailException.java @@ -0,0 +1,23 @@ +package it.aboutbits.springboot.emailservice.lib.exception; + +public class EmailException extends Exception { + public EmailException() { + super(); + } + + public EmailException(String message) { + super(message); + } + + public EmailException(String message, Throwable cause) { + super(message, cause); + } + + public EmailException(Throwable cause) { + super(cause); + } + + protected EmailException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/jpa/EmailRepository.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/jpa/EmailRepository.java new file mode 100644 index 0000000..718edf8 --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/jpa/EmailRepository.java @@ -0,0 +1,37 @@ +package it.aboutbits.springboot.emailservice.lib.jpa; + + +import it.aboutbits.springboot.emailservice.lib.EmailState; +import it.aboutbits.springboot.emailservice.lib.model.Email; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.lang.NonNull; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface EmailRepository extends JpaRepository { + @Override + @NonNull + @EntityGraph(value = Email.DEFAULT_ENTITY_GRAPH) + Optional findById(@NonNull Long id); + + @Override + @NonNull + @EntityGraph(value = Email.DEFAULT_ENTITY_GRAPH) + List findAllById(@NonNull Iterable ids); + + @Override + @NonNull + @EntityGraph(value = Email.DEFAULT_ENTITY_GRAPH) + List findAll(); + + @EntityGraph(value = Email.DEFAULT_ENTITY_GRAPH) + Page findByState(EmailState state, PageRequest pageRequest); + + @EntityGraph(value = Email.DEFAULT_ENTITY_GRAPH) + List findByIdIn(Collection ids); +} diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/model/Email.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/model/Email.java new file mode 100644 index 0000000..a8ee7f9 --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/model/Email.java @@ -0,0 +1,91 @@ +package it.aboutbits.springboot.emailservice.lib.model; + +import it.aboutbits.springboot.emailservice.lib.EmailState; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.NamedAttributeNode; +import jakarta.persistence.NamedEntityGraph; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.UpdateTimestamp; +import org.hibernate.type.SqlTypes; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Set; + +import static it.aboutbits.springboot.emailservice.lib.model.Email.DEFAULT_ENTITY_GRAPH; + +@NamedEntityGraph( + name = "email_service_emails-entity-graph", + attributeNodes = { + @NamedAttributeNode("attachments") + } +) +@Entity +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "email_service_emails") +@NamedEntityGraph(name = DEFAULT_ENTITY_GRAPH, attributeNodes = @NamedAttributeNode("attachments")) +public class Email { + public static final String DEFAULT_ENTITY_GRAPH = "graph.EmailServiceEmail.default"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + private EmailState state; + + private String subject; + + private String fromAddress; + private String fromName; + + @JdbcTypeCode(SqlTypes.JSON) + private List recipients; + + private String textBody; + private String htmlBody; + + @OneToMany(cascade = CascadeType.PERSIST, mappedBy = "email", orphanRemoval = true) + private Set attachments; + + @Builder.Default + private boolean attachmentsCleaned = false; + + private OffsetDateTime scheduledAt; + private OffsetDateTime sentAt; + + private OffsetDateTime errorAt; + private String errorMessage; + + @CreationTimestamp + private OffsetDateTime createdAt; + + @UpdateTimestamp + private OffsetDateTime updatedAt; + + public boolean isSent() { + return EmailState.SENT.equals(state); + } + + public boolean hasFailed() { + return EmailState.ERROR.equals(state); + } +} diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/model/EmailAttachment.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/model/EmailAttachment.java new file mode 100644 index 0000000..9de7abb --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/model/EmailAttachment.java @@ -0,0 +1,31 @@ +package it.aboutbits.springboot.emailservice.lib.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +@Entity +@Getter +@Setter +@Table(name = "email_service_email_attachments") +public class EmailAttachment { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "email_id") + private Email email; + + private String fileName; + + private String contentType; + + private long fileReference; +} diff --git a/src/main/java/it/aboutbits/springboot/emailservice/lib/package-info.java b/src/main/java/it/aboutbits/springboot/emailservice/lib/package-info.java new file mode 100644 index 0000000..48744f3 --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/emailservice/lib/package-info.java @@ -0,0 +1,6 @@ +@NonNullApi +@NonNullFields +package it.aboutbits.springboot.emailservice.lib; + +import org.springframework.lang.NonNullApi; +import org.springframework.lang.NonNullFields; diff --git a/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 0000000..6653b27 --- /dev/null +++ b/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,28 @@ +{ + "properties": [ + { + "name": "aboutbits.emailservice.migrations.enabled", + "type": "java.lang.Boolean", + "description": "Enables database migrations.", + "defaultValue": true + }, + { + "name": "aboutbits.emailservice.scheduling.enabled", + "type": "java.lang.Boolean", + "description": "Enables the scheduler sending the emails.", + "defaultValue": true + }, + { + "name": "aboutbits.emailservice.scheduling.cleanup.enabled", + "type": "java.lang.Boolean", + "description": "Enables cleanup of attachment files after sending.", + "defaultValue": true + }, + { + "name": "aboutbits.emailservice.scheduling.interval", + "type": "java.lang.Long", + "description": "Specifies the milliseconds delay between runs of the scheduler.", + "defaultValue": 30000 + } + ] +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..3ece23f --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,14 @@ +spring: + mail: + default-encoding: UTF-8 + host: ${MAIL_HOST:localhost} + username: ${MAIL_USERNAME:null} + password: ${MAIL_PASSWORD:null} + port: ${MAIL_PORT:1025} + protocol: smtp + properties: + mail: + smtp: + auth: ${MAIL_SSL:false} + starttls: + enable: ${MAIL_SSL:false} diff --git a/src/test/java/it/aboutbits/springboot/emailservice/TestApplication.java b/src/test/java/it/aboutbits/springboot/emailservice/TestApplication.java new file mode 100644 index 0000000..20454aa --- /dev/null +++ b/src/test/java/it/aboutbits/springboot/emailservice/TestApplication.java @@ -0,0 +1,13 @@ +package it.aboutbits.springboot.emailservice; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SuppressWarnings("checkstyle:HideUtilityClassConstructor") +@SpringBootApplication +@EnableEmailService +public class TestApplication { + public static void main(final String[] args) { + SpringApplication.run(TestApplication.class, args); + } +} diff --git a/src/test/java/it/aboutbits/springboot/emailservice/lib/application/ManageEmailTest.java b/src/test/java/it/aboutbits/springboot/emailservice/lib/application/ManageEmailTest.java new file mode 100644 index 0000000..eeb62f6 --- /dev/null +++ b/src/test/java/it/aboutbits/springboot/emailservice/lib/application/ManageEmailTest.java @@ -0,0 +1,200 @@ +package it.aboutbits.springboot.emailservice.lib.application; + +import it.aboutbits.springboot.emailservice.lib.AttachmentDataSource; +import it.aboutbits.springboot.emailservice.lib.EmailState; +import it.aboutbits.springboot.emailservice.lib.exception.AttachmentException; +import it.aboutbits.springboot.emailservice.lib.exception.EmailException; +import it.aboutbits.springboot.emailservice.support.database.WithPostgres; +import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.mail.MailSendException; +import org.springframework.mail.javamail.JavaMailSender; + +import java.io.ByteArrayInputStream; +import java.time.OffsetDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringBootTest +@WithPostgres +class ManageEmailTest { + @SpyBean + JavaMailSender javaMailSender; + + @MockBean + AttachmentDataSource attachmentDataSource; + + @Autowired + private ManageEmail manageEmail; + + @Test + void givenRequiredParameters_schedule_shouldCreateNewNotification() throws EmailException { + var parameter = getValidParameterWithoutAttachment(); + + var result = manageEmail.schedule(parameter); + + assertThat(result.id()).isPositive(); + assertThat(result.state()).isEqualTo(EmailState.PENDING); + assertThat(result.subject()).isEqualTo(parameter.email().subject()); + assertThat(result.fromAddress()).isEqualTo(parameter.email().fromAddress()); + assertThat(result.fromName()).isEqualTo(parameter.email().fromName()); + assertThat(result.recipients()).containsAll(parameter.email().recipients()); + assertThat(result.textBody()).isEqualTo(parameter.email().textBody()); + assertThat(result.htmlBody()).isEqualTo(parameter.email().htmlBody()); + assertThat(result.attachments()).isEmpty(); + assertThat(result.scheduledAt()).isEqualTo(parameter.scheduledAt()); + } + + @Test + void givenRequiredParameterWithAttachedFiles_schedule_shouldCreateNewNotification() throws EmailException, AttachmentException { + when(attachmentDataSource.storeAttachmentPayload(any())).thenReturn(33L); + + var parameter = getValidParameterWithAttachment(); + + var result = manageEmail.schedule(parameter); + + assertThat(result.id()).isPositive(); + assertThat(result.state()).isEqualTo(EmailState.PENDING); + assertThat(result.subject()).isEqualTo(parameter.email().subject()); + assertThat(result.fromAddress()).isEqualTo(parameter.email().fromAddress()); + assertThat(result.fromName()).isEqualTo(parameter.email().fromName()); + assertThat(result.recipients()).containsAll(parameter.email().recipients()); + assertThat(result.textBody()).isEqualTo(parameter.email().textBody()); + assertThat(result.htmlBody()).isEqualTo(parameter.email().htmlBody()); + assertThat(result.attachments()).hasSize(1); + assertThat(result.scheduledAt()).isEqualTo(parameter.scheduledAt()); + } + + @Test + void givenRequiredParameters_sendOrFail_shouldCreateNewNotification() throws EmailException { + var parameter = getValidParameterWithoutAttachment(); + + var result = manageEmail.sendOrFail(parameter); + + assertThat(result.id()).isPositive(); + assertThat(result.state()).isEqualTo(EmailState.SENT); + assertThat(result.subject()).isEqualTo(parameter.email().subject()); + assertThat(result.fromAddress()).isEqualTo(parameter.email().fromAddress()); + assertThat(result.fromName()).isEqualTo(parameter.email().fromName()); + assertThat(result.recipients()).containsAll(parameter.email().recipients()); + assertThat(result.textBody()).isEqualTo(parameter.email().textBody()); + assertThat(result.htmlBody()).isEqualTo(parameter.email().htmlBody()); + assertThat(result.attachments()).isEmpty(); + assertThat(result.scheduledAt()).isEqualTo(parameter.scheduledAt()); + } + + @Test + void givenRequiredParameterWithAttachedFiles_sendOrFail_shouldCreateNewNotification() throws EmailException, AttachmentException { + when(attachmentDataSource.storeAttachmentPayload(any())).thenReturn(33L); + when(attachmentDataSource.getAttachmentPayload(anyLong())).thenReturn(new ByteArrayInputStream(new byte[0])); + + var parameter = getValidParameterWithAttachment(); + + var result = manageEmail.sendOrFail(parameter); + + assertThat(result.id()).isPositive(); + assertThat(result.state()).isEqualTo(EmailState.SENT); + assertThat(result.subject()).isEqualTo(parameter.email().subject()); + assertThat(result.fromAddress()).isEqualTo(parameter.email().fromAddress()); + assertThat(result.fromName()).isEqualTo(parameter.email().fromName()); + assertThat(result.recipients()).containsAll(parameter.email().recipients()); + assertThat(result.textBody()).isEqualTo(parameter.email().textBody()); + assertThat(result.htmlBody()).isEqualTo(parameter.email().htmlBody()); + assertThat(result.attachments()).hasSize(1); + assertThat(result.scheduledAt()).isEqualTo(parameter.scheduledAt()); + } + + @Test + void givenRequiredParameters_sendOrFail_shouldSendImmediately() throws EmailException { + EmailParameter parameter = getValidParameterWithoutAttachment(); + + manageEmail.sendOrFail(parameter); + + verify(javaMailSender, times(1)).send(any(MimeMessage.class)); + } + + @Test + void givenRequiredParameterWithAttachedFiles_sendOrFail_shouldSendImmediately() throws EmailException, AttachmentException { + when(attachmentDataSource.storeAttachmentPayload(any())).thenReturn(33L); + when(attachmentDataSource.getAttachmentPayload(anyLong())).thenReturn(new ByteArrayInputStream(new byte[0])); + + var parameter = getValidParameterWithAttachment(); + + manageEmail.sendOrFail(parameter); + + verify(javaMailSender, times(1)).send(any(MimeMessage.class)); + } + + @Test + void givenAttachmentError_sendOrFail_shouldFail() throws EmailException, AttachmentException { + when(attachmentDataSource.storeAttachmentPayload(any())).thenThrow(new AttachmentException()); + + var parameter = getValidParameterWithAttachment(); + + assertThatExceptionOfType(EmailException.class).isThrownBy( + () -> manageEmail.sendOrFail(parameter) + ); + } + + @Test + void givenMailSenderError_sendOrFail_shouldFail() throws EmailException, AttachmentException { + when(attachmentDataSource.storeAttachmentPayload(any())).thenReturn(33L); + when(attachmentDataSource.getAttachmentPayload(anyLong())).thenReturn(new ByteArrayInputStream(new byte[0])); + + doThrow(new MailSendException("any")).when(javaMailSender).send(any(MimeMessage.class)); + + var parameter = getValidParameterWithAttachment(); + + assertThatExceptionOfType(EmailException.class).isThrownBy( + () -> manageEmail.sendOrFail(parameter) + ); + } + + private static EmailParameter getValidParameterWithoutAttachment() { + return EmailParameter.builder() + .scheduledAt(OffsetDateTime.now()) + .email(EmailParameter.Email.builder() + .subject("Example email subject") + .textBody("Email body") + .htmlBody("

Html email body

") + .recipient("person1@example.com") + .recipient("person2@example.com") + .fromAddress("somebody@aboutbits.it") + .fromName("somebody") + .build() + ).build(); + } + + private static EmailParameter getValidParameterWithAttachment() { + return EmailParameter.builder() + .scheduledAt(OffsetDateTime.now()) + .email(EmailParameter.Email.builder() + .subject("Example email subject") + .textBody("Email body") + .htmlBody("

Html email body

") + .recipient("person1@example.com") + .recipient("person2@example.com") + .attachment( + EmailParameter.Email.Attachment.builder() + .contentType("image/png") + .fileName("x.png") + .payload(new ByteArrayInputStream(new byte[0])) + .build() + ) + .fromAddress("somebody@aboutbits.it") + .fromName("somebody") + .build() + ).build(); + } +} diff --git a/src/test/java/it/aboutbits/springboot/emailservice/lib/application/QueryEmailTest.java b/src/test/java/it/aboutbits/springboot/emailservice/lib/application/QueryEmailTest.java new file mode 100644 index 0000000..ab7e5e4 --- /dev/null +++ b/src/test/java/it/aboutbits/springboot/emailservice/lib/application/QueryEmailTest.java @@ -0,0 +1,95 @@ +package it.aboutbits.springboot.emailservice.lib.application; + +import it.aboutbits.springboot.emailservice.lib.EmailDto; +import it.aboutbits.springboot.emailservice.lib.EmailState; +import it.aboutbits.springboot.emailservice.lib.jpa.EmailRepository; +import it.aboutbits.springboot.emailservice.support.database.WithPostgres; +import it.aboutbits.springboot.emailservice.support.database.factory.EmailFactory; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageRequest; +import org.springframework.mail.javamail.JavaMailSender; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + + +@SpringBootTest +@WithPostgres +class QueryEmailTest { + @MockBean + JavaMailSender javaMailSender; + + @Autowired + EmailRepository emailRepository; + + @Autowired + QueryEmail queryEmail; + + @Test + void givenEmailNotification_byState_shouldSuccess() { + emailRepository.saveAll(Set.of( + EmailFactory.once().build(), + EmailFactory.once().state(EmailState.SENT).build(), + EmailFactory.once().state(EmailState.SENT).build() + )); + + var result = queryEmail.paginatedByState( + EmailState.SENT, + PageRequest.of(0, 20) + ); + + assertThat(result.getTotalElements()).isEqualTo(2L); + } + + @Test + void givenEmailNotificationWithPagination_byState_shouldSuccess() { + emailRepository.saveAll(Set.of( + EmailFactory.once().build(), + EmailFactory.once().state(EmailState.SENT).build(), + EmailFactory.once().state(EmailState.SENT).build() + )); + + var result = queryEmail.paginatedByState( + EmailState.SENT, + PageRequest.of(1, 1) + ); + + assertThat(result.getTotalPages()).isEqualTo(2); + assertThat(result.getTotalElements()).isEqualTo(2L); + assertThat(result.getContent()).hasSize(1); + } + + @Test + void givenEmailNotification_byIds_shouldSuccess() { + var notificationA = emailRepository.save(EmailFactory.once().build()); + var notificationB = emailRepository.save(EmailFactory.once().build()); + emailRepository.save(EmailFactory.once().build()); + + var result = queryEmail.byIds(Set.of(notificationA.getId(), notificationB.getId())); + + assertThat(result) + .hasSize(2) + .map(EmailDto::id) + .containsExactlyInAnyOrder(notificationA.getId(), notificationB.getId()); + + } + + @Test + void givenEmailNotification_byIdOrFail_shouldSuccess() { + var notification = emailRepository.save(EmailFactory.once().build()); + + var result = queryEmail.byId(notification.getId()); + + assertThat(result).isPresent(); + assertThat(result.get().id()).isEqualTo(notification.getId()); + } + + @Test + void givenNoEmailNotification_byIdOrFail_shouldFail() { + assertThat(queryEmail.byId(123L)).isNotPresent(); + } +} diff --git a/src/test/java/it/aboutbits/springboot/emailservice/support/database/PostgresTestcontainer.java b/src/test/java/it/aboutbits/springboot/emailservice/support/database/PostgresTestcontainer.java new file mode 100644 index 0000000..90bc89e --- /dev/null +++ b/src/test/java/it/aboutbits/springboot/emailservice/support/database/PostgresTestcontainer.java @@ -0,0 +1,112 @@ +package it.aboutbits.springboot.emailservice.support.database; + +import lombok.Data; +import lombok.extern.log4j.Log4j2; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.StringJoiner; + +@Log4j2 +public class PostgresTestcontainer implements BeforeAllCallback, AfterEachCallback { + public static final PostgreSQLContainer POSTGRES_CONTAINER; + private static final Set TABLES_TO_IGNORE = Set.of( + + ); + + static { + POSTGRES_CONTAINER = new PostgreSQLContainer<>( + DockerImageName.parse("public.ecr.aws/ubuntu/postgres:14-22.04_edge") + .asCompatibleSubstituteFor("postgres")) + .withDatabaseName("app") + // See https://www.postgresql.org/docs/current/non-durability.html for details + .withCommand("postgres -c max_connections=1000 -c fsync=off -c synchronous_commit=off -c full_page_writes=off -c max_wal_size=2GB -c checkpoint_timeout=20min") + .withUsername("admin") + .withPassword("password") + .withEnv("TZ", "UTC") // Container's time zone + .withEnv("PGTZ", "UTC") // PostgreSQL's time zone + .withReuse(true); + + POSTGRES_CONTAINER.start(); + } + + @Override + public void beforeAll(ExtensionContext extensionContext) { + System.setProperty("spring.datasource.driver-class-name", "org.postgresql.Driver"); + System.setProperty("spring.datasource.url", POSTGRES_CONTAINER.getJdbcUrl()); + System.setProperty("spring.datasource.username", POSTGRES_CONTAINER.getUsername()); + System.setProperty("spring.datasource.password", POSTGRES_CONTAINER.getPassword()); + } + + @Override + public void afterEach(ExtensionContext extensionContext) throws Exception { + var connection = DriverManager.getConnection( + POSTGRES_CONTAINER.getJdbcUrl(), + POSTGRES_CONTAINER.getUsername(), + POSTGRES_CONTAINER.getPassword() + ); + + try { + connection.setAutoCommit(false); + var tablesToClean = loadTablesToClean(connection); + cleanTablesData(tablesToClean, connection); + connection.commit(); + } catch (SQLException exception) { + exception.printStackTrace(); + } + } + + private List loadTablesToClean(final Connection connection) throws SQLException { + var databaseMetaData = connection.getMetaData(); + var resultSet = databaseMetaData.getTables( + connection.getCatalog(), null, null, new String[]{"TABLE"}); + + var tablesToClean = new ArrayList(); + while (resultSet.next()) { + var table = new TableData( + resultSet.getString("TABLE_SCHEM"), + resultSet.getString("TABLE_NAME") + ); + + if (!TABLES_TO_IGNORE.contains(table.getName())) { + tablesToClean.add(table); + } + } + + return tablesToClean; + } + + private void cleanTablesData(List tablesToClean, Connection connection) throws SQLException { + if (tablesToClean.isEmpty()) { + return; + } + + log.debug("Cleaning Database tables {}", tablesToClean); + + var allTables = new StringJoiner(", "); + for (TableData table : tablesToClean) { + allTables.add(table.getFullyQualifiedName()); + } + String statement = String.format("TRUNCATE %s RESTART IDENTITY CASCADE", allTables); + connection.prepareStatement(statement).execute(); + } + + @Data + private static class TableData { + private final String schema; + private final String name; + + public String getFullyQualifiedName() { + return schema + "." + name; + } + } +} diff --git a/src/test/java/it/aboutbits/springboot/emailservice/support/database/WithPostgres.java b/src/test/java/it/aboutbits/springboot/emailservice/support/database/WithPostgres.java new file mode 100644 index 0000000..6fdf3a8 --- /dev/null +++ b/src/test/java/it/aboutbits/springboot/emailservice/support/database/WithPostgres.java @@ -0,0 +1,14 @@ +package it.aboutbits.springboot.emailservice.support.database; + +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.parallel.ResourceAccessMode; +import org.junit.jupiter.api.parallel.ResourceLock; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith({PostgresTestcontainer.class}) +@ResourceLock(value = "Database", mode = ResourceAccessMode.READ_WRITE) +public @interface WithPostgres { +} diff --git a/src/test/java/it/aboutbits/springboot/emailservice/support/database/factory/EmailFactory.java b/src/test/java/it/aboutbits/springboot/emailservice/support/database/factory/EmailFactory.java new file mode 100644 index 0000000..bb8566e --- /dev/null +++ b/src/test/java/it/aboutbits/springboot/emailservice/support/database/factory/EmailFactory.java @@ -0,0 +1,29 @@ +package it.aboutbits.springboot.emailservice.support.database.factory; + +import com.github.javafaker.Faker; +import it.aboutbits.springboot.emailservice.lib.EmailState; +import it.aboutbits.springboot.emailservice.lib.model.Email; + +import java.time.OffsetDateTime; +import java.util.List; + +public final class EmailFactory { + private static final Faker FAKER = new Faker(); + + private EmailFactory() { + } + + public static Email.EmailBuilder once() { + var body = FAKER.lorem().paragraph(); + + return Email.builder() + .state(EmailState.PENDING) + .subject("Email subject") + .textBody(body) + .htmlBody("

" + body + "

") + .scheduledAt(OffsetDateTime.now()) + .fromAddress(FAKER.internet().emailAddress()) + .fromName(FAKER.name().fullName()) + .recipients(List.of(FAKER.internet().emailAddress(), FAKER.internet().emailAddress())); + } +}