diff --git a/.github/workflows/macOSBuild.yml b/.github/workflows/macOSBuild.yml
index 9c03d618..da9b9138 100644
--- a/.github/workflows/macOSBuild.yml
+++ b/.github/workflows/macOSBuild.yml
@@ -1,72 +1,374 @@
name: macOS deployment
-#on: [push, pull_request]
-
on:
workflow_dispatch:
push:
- branches:
- - master
+ branches:
+ - master
+ tags:
+ - 'v*'
jobs:
macos-build:
- name: MacOS Build
- strategy:
- matrix:
- os: [macos-12, macos-13]
-
- runs-on: ${{ matrix.os }}
-
- steps:
- - name: Install Dependencies
- run: |
- unset HOMEBREW_NO_INSTALL_FROM_API
- brew update
- brew upgrade || true
- brew install qt6
- brew install qt6-webengine
- brew link qt6 --force
- brew link qt6-webengine --force
- brew install hamlib
- brew link hamlib --force
- brew install qtkeychain
- brew install dbus-glib
- brew install brotli
- brew install icu4c
- brew install pkg-config
- - name: Checkout Code
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
- - name: Get version from tag
- run : |
- TAGVERSION=$(git describe --tags)
- echo "TAGVERSION=${TAGVERSION:1}" >> $GITHUB_ENV
-
- - name: Configure and compile
- run: |
- mkdir build
- cd build
- qmake -config release ..
- make -j4
- - name: Build dmg
- run: |
- cd build
- macdeployqt qlog.app -executable=./qlog.app/Contents/MacOS/qlog
- cp `brew --prefix`/lib/libhamlib.dylib qlog.app/Contents/Frameworks/libhamlib.dylib
- cp `brew --prefix`/lib/libqt6keychain.dylib qlog.app/Contents/Frameworks/libqt6keychain.dylib
- cp `brew --prefix`/lib/libdbus-1.dylib qlog.app/Contents/Frameworks/libdbus-1.dylib
- cp `brew --prefix brotli`/lib/libbrotlicommon.1.dylib qlog.app/Contents/Frameworks/libbrotlicommon.1.dylib
- cp `brew --prefix`/opt/icu4c/lib/libicui18n.74.dylib qlog.app/Contents/Frameworks/libicui18n.74.dylib
- install_name_tool -change `brew --prefix`/lib/libhamlib.dylib @executable_path/../Frameworks/libhamlib.dylib qlog.app/Contents/MacOS/qlog
- install_name_tool -change `brew --prefix`/lib/libqt6keychain.dylib @executable_path/../Frameworks/libqt6keychain.dylib qlog.app/Contents/MacOS/qlog
- install_name_tool -change @loader_path/libbrotlicommon.1.dylib @executable_path/../Frameworks/libbrotlicommon.1.dylib qlog.app/Contents/MacOS/qlog
- install_name_tool -change /usr/local/opt/icu4c/lib/libicui18n.74.dylib @executable_path/../Frameworks/libicui18n.74.dylib qlog.app/Contents/MacOS/qlog
- otool -L qlog.app/Contents/MacOS/qlog
- macdeployqt qlog.app -dmg
- - name: Copy artifact
- uses: actions/upload-artifact@v4
- with:
- name: QLog-${{ env.TAGVERSION }}-${{ matrix.os }}
- path: /Users/runner/work/QLog/QLog/build/qlog.dmg
+ name: macOS Build
+ runs-on: macos-latest
+
+ env:
+ APP_NAME: QLog
+ BUNDLE_BIN: qlog
+ QT_PREFIX: /opt/homebrew
+
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ submodules: recursive
+
+ - name: Install Dependencies
+ run: |
+ set -euo pipefail
+ unset HOMEBREW_NO_INSTALL_FROM_API
+ brew update
+ brew upgrade || true
+ brew install \
+ qt6 \
+ hamlib \
+ qtkeychain \
+ dbus-glib \
+ brotli \
+ icu4c \
+ pkg-config \
+ autoconf \
+ automake \
+ libtool \
+ libusb
+ # qt6 is keg-only; many Qt-aware tools rely on the linkage being visible
+ brew link qt6 --force || true
+ brew link hamlib --force || true
+
+ - name: Get version from tag
+ run: |
+ set -euo pipefail
+ # If we're on a tag like v0.50.0, use that; otherwise fall back to describe.
+ if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
+ TAGVERSION="${GITHUB_REF#refs/tags/v}"
+ else
+ RAW="$(git describe --tags --always 2>/dev/null || echo dev)"
+ TAGVERSION="${RAW#v}"
+ fi
+ echo "TAGVERSION=${TAGVERSION}" >> "$GITHUB_ENV"
+ echo "Version: ${TAGVERSION}"
+
+ - name: Ensure QtSvg module is enabled
+ run: |
+ set -euo pipefail
+ PRO="qlog.pro"
+ if ! grep -q ' svg' "$PRO"; then
+ echo "Adding svg to QT modules"
+ awk '
+ BEGIN { added=0 }
+ /^[[:space:]]*QT[[:space:]]/ && !added {
+ print $0 " svg"; added=1; next
+ }
+ { print }
+ ' "$PRO" > "$PRO.tmp" && mv "$PRO.tmp" "$PRO"
+ else
+ echo "QtSvg already present"
+ fi
+
+ - name: Configure and compile
+ run: |
+ set -euo pipefail
+ mkdir -p build
+ cd build
+ qmake CONFIG+=release ..
+ make -j"$(sysctl -n hw.ncpu)"
+
+ - name: Run macdeployqt
+ run: |
+ set -euo pipefail
+ cd build
+ APP="$APP_NAME.app"
+ # macdeployqt builds Contents/Frameworks and rewrites Qt links.
+ macdeployqt "$APP" \
+ -always-overwrite \
+ -verbose=2 \
+ -executable="$APP/Contents/MacOS/$BUNDLE_BIN" \
+ -libpath="$QT_PREFIX/opt/qtbase/lib" \
+ -libpath="$QT_PREFIX/opt/qtdeclarative/lib" \
+ -libpath="$QT_PREFIX/opt/qtwebengine/lib" \
+ -libpath="$QT_PREFIX/opt/qtsvg/lib"
+
+ - name: Bundle rigctld and fix linkage
+ run: |
+ set -euo pipefail
+ cd build
+ APP="$APP_NAME.app"
+ MACOS_DIR="$APP/Contents/MacOS"
+ FRAMEWORKS_DIR="$APP/Contents/Frameworks"
+ mkdir -p "$MACOS_DIR" "$FRAMEWORKS_DIR"
+
+ RIGCTLD_SRC="$(command -v rigctld)"
+ [ -n "$RIGCTLD_SRC" ] || { echo "ERROR: rigctld not found"; exit 1; }
+ echo "Using rigctld from: $RIGCTLD_SRC"
+ cp -f "$RIGCTLD_SRC" "$MACOS_DIR/rigctld"
+ chmod 0755 "$MACOS_DIR/rigctld"
+
+ install_name_tool -add_rpath "@executable_path/../Frameworks" \
+ "$MACOS_DIR/rigctld" 2>/dev/null || true
+
+ # Recursively bundle any /opt/homebrew or /usr/local dependency, fix
+ # install IDs to @rpath, and rewrite the parent's link.
+ bundle_deps() {
+ local target="$1"
+ local dep base
+ otool -L "$target" | awk 'NR>1 {print $1}' | while read -r dep; do
+ case "$dep" in
+ /opt/homebrew/*|/usr/local/*)
+ base="$(basename "$dep")"
+ if [ ! -f "$FRAMEWORKS_DIR/$base" ]; then
+ echo " -> bundling $base (from $dep)"
+ cp -f "$dep" "$FRAMEWORKS_DIR/$base"
+ chmod u+w "$FRAMEWORKS_DIR/$base"
+ install_name_tool -id "@rpath/$base" "$FRAMEWORKS_DIR/$base"
+ bundle_deps "$FRAMEWORKS_DIR/$base"
+ fi
+ install_name_tool -change "$dep" "@rpath/$base" "$target"
+ ;;
+ esac
+ done
+ }
+
+ bundle_deps "$MACOS_DIR/rigctld"
+ bundle_deps "$MACOS_DIR/$BUNDLE_BIN"
+
+ echo "rigctld linkage:"
+ otool -L "$MACOS_DIR/rigctld"
+ echo "$BUNDLE_BIN linkage:"
+ otool -L "$MACOS_DIR/$BUNDLE_BIN"
+
+ - name: Fix QtWebEngineProcess rpaths
+ run: |
+ set -euo pipefail
+ cd build
+ APP="$APP_NAME.app"
+ QWEP="$APP/Contents/Frameworks/QtWebEngineCore.framework/Versions/A/Helpers/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess"
+ [ -f "$QWEP" ] || { echo "QtWebEngineProcess not found at $QWEP"; exit 1; }
+
+ install_name_tool -add_rpath \
+ "@executable_path/../../../../Frameworks" "$QWEP" 2>/dev/null || true
+
+ # Rewrite any remaining hardcoded Qt framework references to @rpath.
+ otool -L "$QWEP" | awk 'NR>1 {print $1}' \
+ | grep '^/opt/homebrew/.*\.framework/' \
+ | while read -r dep; do
+ fw="$(basename "$dep")"
+ fwdir="$(basename "$(dirname "$(dirname "$(dirname "$dep")")")")"
+ install_name_tool -change "$dep" \
+ "@rpath/$fwdir/Versions/A/$fw" "$QWEP" || true
+ done
+
+ echo "QtWebEngineProcess after fixup:"
+ otool -L "$QWEP"
+
+ - name: Scan for hardcoded Homebrew paths
+ run: |
+ set -euo pipefail
+ cd build
+ APP="$APP_NAME.app"
+ BAD=0
+ while IFS= read -r -d '' BIN; do
+ if otool -L "$BIN" 2>/dev/null \
+ | awk 'NR>1 {print $1}' \
+ | grep -E '^(/opt/homebrew/|/usr/local/Cellar/|/usr/local/opt/)' >/dev/null; then
+ echo "Hardcoded Homebrew path in: $BIN"
+ otool -L "$BIN" | awk 'NR>1 {print " " $1}' \
+ | grep -E '^[[:space:]]+(/opt/homebrew/|/usr/local/Cellar/|/usr/local/opt/)'
+ BAD=1
+ fi
+ done < <(find "$APP" -type f -perm +111 -print0)
+ if [ "$BAD" -eq 1 ]; then
+ echo "ERROR: hardcoded Homebrew paths remain. Bundle would fail on user machines."
+ exit 1
+ fi
+
+ - name: Codesign app bundle
+ env:
+ MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }}
+ MACOS_CERTIFICATE_PWD: ${{ secrets.PROD_MACOS_CERTIFICATE_PWD }}
+ MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }}
+ MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }}
+ run: |
+ set -euo pipefail
+ APP="$GITHUB_WORKSPACE/build/$APP_NAME.app"
+ ENT="$GITHUB_WORKSPACE/entitlements.xml"
+ KEYCHAIN="$RUNNER_TEMP/build.keychain-db"
+
+ test -f "$ENT" || { echo "Missing entitlements.xml at $ENT"; exit 1; }
+
+ security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN"
+ security set-keychain-settings -lut 21600 "$KEYCHAIN"
+ security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN"
+ security list-keychains -d user -s "$KEYCHAIN"
+ security default-keychain -d user -s "$KEYCHAIN"
+
+ echo "$MACOS_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12"
+ security import "$RUNNER_TEMP/cert.p12" -k "$KEYCHAIN" \
+ -P "$MACOS_CERTIFICATE_PWD" \
+ -T /usr/bin/codesign -T /usr/bin/security
+ security set-key-partition-list -S apple-tool:,apple: \
+ -s -k "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN"
+
+ security find-identity -v -p codesigning "$KEYCHAIN" || true
+
+ # 1) Sign every Mach-O inside the bundle (no entitlements), skipping
+ # QtWebEngineProcess which we sign with entitlements below.
+ while IFS= read -r -d '' F; do
+ if file "$F" | grep -q "Mach-O"; then
+ if [[ "$F" == *"/QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" ]]; then
+ continue
+ fi
+ /usr/bin/codesign --force --timestamp --options runtime \
+ -s "$MACOS_CERTIFICATE_NAME" "$F"
+ fi
+ done < <(find "$APP" -type f -print0)
+
+ # 2) Sign QtWebEngineProcess binaries WITH entitlements
+ while IFS= read -r -d '' BIN; do
+ echo "Signing WebEngine helper: $BIN"
+ /usr/bin/codesign --force --timestamp --options runtime \
+ --entitlements "$ENT" \
+ -s "$MACOS_CERTIFICATE_NAME" "$BIN"
+ done < <(find "$APP" -path "*QtWebEngineProcess.app/Contents/MacOS/QtWebEngineProcess" -type f -print0)
+
+ # 3) Sign QtWebEngineProcess.app bundles WITH entitlements
+ while IFS= read -r -d '' HAPP; do
+ echo "Signing WebEngine helper app: $HAPP"
+ /usr/bin/codesign --force --timestamp --options runtime \
+ --entitlements "$ENT" \
+ -s "$MACOS_CERTIFICATE_NAME" "$HAPP"
+ done < <(find "$APP" -path "*QtWebEngineProcess.app" -type d -print0)
+
+ # 4) Re-seal every framework so the framework's seal includes any
+ # nested helper apps we just re-signed. Without this, signing the
+ # outer app fails with "nested code is modified or invalid".
+ while IFS= read -r -d '' FW; do
+ echo "Re-signing framework: $FW"
+ /usr/bin/codesign --force --timestamp --options runtime \
+ -s "$MACOS_CERTIFICATE_NAME" "$FW"
+ done < <(find "$APP/Contents/Frameworks" -maxdepth 1 -type d -name "*.framework" -print0)
+
+ # 5) Sign the outer app bundle WITH entitlements
+ /usr/bin/codesign --force --timestamp --options runtime \
+ --entitlements "$ENT" \
+ -s "$MACOS_CERTIFICATE_NAME" "$APP"
+
+ /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP"
+ spctl -a -t exec -vv "$APP" || true
+
+ - name: Notarize app bundle
+ env:
+ PROD_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }}
+ PROD_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }}
+ PROD_MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }}
+ run: |
+ set -euo pipefail
+ APP="$GITHUB_WORKSPACE/build/$APP_NAME.app"
+
+ xcrun notarytool store-credentials "notarytool-profile" \
+ --apple-id "$PROD_MACOS_NOTARIZATION_APPLE_ID" \
+ --team-id "$PROD_MACOS_NOTARIZATION_TEAM_ID" \
+ --password "$PROD_MACOS_NOTARIZATION_PWD"
+
+ ditto -c -k --sequesterRsrc --keepParent "$APP" "notarization.zip"
+
+ xcrun notarytool submit "notarization.zip" \
+ --keychain-profile "notarytool-profile" \
+ --wait --output-format json > notarization_log.json
+ cat notarization_log.json
+
+ NOTARY_ID=$(python3 -c 'import json; print(json.load(open("notarization_log.json"))["id"])')
+ NOTARY_STATUS=$(python3 -c 'import json; print(json.load(open("notarization_log.json"))["status"])')
+
+ xcrun notarytool log "$NOTARY_ID" --keychain-profile "notarytool-profile" || true
+
+ if [ "$NOTARY_STATUS" != "Accepted" ]; then
+ echo "Notarization failed: $NOTARY_STATUS"
+ exit 1
+ fi
+
+ xcrun stapler staple "$APP"
+ xcrun stapler validate "$APP"
+
+ - name: Build DMG
+ run: |
+ set -euo pipefail
+ VERSION="${TAGVERSION%%-*}"
+ DMG_NAME="$APP_NAME.v${VERSION}.dmg"
+ echo "DMG_NAME=$DMG_NAME" >> "$GITHUB_ENV"
+ echo "VERSION=$VERSION" >> "$GITHUB_ENV"
+
+ STAGING="$GITHUB_WORKSPACE/build/dmg-staging"
+ rm -rf "$STAGING"
+ mkdir -p "$STAGING"
+ cp -R "$GITHUB_WORKSPACE/build/$APP_NAME.app" "$STAGING/"
+ ln -s /Applications "$STAGING/Applications"
+
+ hdiutil create \
+ -volname "$APP_NAME Installer" \
+ -srcfolder "$STAGING" \
+ -ov -format UDZO \
+ "$GITHUB_WORKSPACE/build/$DMG_NAME"
+ ls -lh "$GITHUB_WORKSPACE/build/$DMG_NAME"
+
+ - name: Codesign DMG
+ env:
+ MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }}
+ MACOS_CI_KEYCHAIN_PWD: ${{ secrets.PROD_MACOS_CI_KEYCHAIN_PWD }}
+ run: |
+ set -euo pipefail
+ DMG="$GITHUB_WORKSPACE/build/$DMG_NAME"
+ KEYCHAIN="$RUNNER_TEMP/build.keychain-db"
+ security unlock-keychain -p "$MACOS_CI_KEYCHAIN_PWD" "$KEYCHAIN"
+ /usr/bin/codesign --force --timestamp \
+ -s "$MACOS_CERTIFICATE_NAME" "$DMG"
+ /usr/bin/codesign --verify --verbose=2 "$DMG"
+
+ - name: Notarize DMG
+ env:
+ PROD_MACOS_NOTARIZATION_APPLE_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_APPLE_ID }}
+ PROD_MACOS_NOTARIZATION_TEAM_ID: ${{ secrets.PROD_MACOS_NOTARIZATION_TEAM_ID }}
+ PROD_MACOS_NOTARIZATION_PWD: ${{ secrets.PROD_MACOS_NOTARIZATION_PWD }}
+ run: |
+ set -euo pipefail
+ DMG="$GITHUB_WORKSPACE/build/$DMG_NAME"
+
+ xcrun notarytool store-credentials "notarytool-profile" \
+ --apple-id "$PROD_MACOS_NOTARIZATION_APPLE_ID" \
+ --team-id "$PROD_MACOS_NOTARIZATION_TEAM_ID" \
+ --password "$PROD_MACOS_NOTARIZATION_PWD"
+
+ xcrun notarytool submit "$DMG" \
+ --keychain-profile "notarytool-profile" \
+ --wait --output-format json > notarization_dmg.json
+ cat notarization_dmg.json
+
+ NOTARY_ID=$(python3 -c 'import json; print(json.load(open("notarization_dmg.json"))["id"])')
+ NOTARY_STATUS=$(python3 -c 'import json; print(json.load(open("notarization_dmg.json"))["status"])')
+ xcrun notarytool log "$NOTARY_ID" --keychain-profile "notarytool-profile" || true
+
+ if [ "$NOTARY_STATUS" != "Accepted" ]; then
+ echo "DMG notarization failed: $NOTARY_STATUS"
+ exit 1
+ fi
+
+ xcrun stapler staple "$DMG"
+ xcrun stapler validate "$DMG"
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: QLog-${{ env.TAGVERSION }}-macos
+ path: ${{ github.workspace }}/build/${{ env.DMG_NAME }}
+ if-no-files-found: error
diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml
new file mode 100644
index 00000000..f13cc4e7
--- /dev/null
+++ b/.github/workflows/windows.yml
@@ -0,0 +1,343 @@
+# ============================================================
+# QLog Windows Build — GitHub Actions
+#
+# Orignal from HB9VQQ
+#
+# Builds QLog for Windows (MSVC 2022 x64) and creates:
+# - A portable ZIP (QLog-Portable-Windows)
+# - A Qt IFW installer (QLog-Installer-Windows)
+#
+# Triggers:
+# - push to master → build + artifacts (for testing)
+# - push a tag v* → build + artifacts + GitHub Release
+# - manual via Actions tab
+# ============================================================
+name: Windows Build
+
+on:
+ push:
+ branches: [master]
+ tags: ['v*']
+ paths-ignore:
+ - '*.md'
+ - 'doc/**'
+ - 'LICENSE'
+ - '.gitignore'
+ workflow_dispatch: # manual trigger button in Actions tab
+
+env:
+ QT_VERSION: '6.10.2'
+ HAMLIB_VERSION: '4.7.1'
+
+jobs:
+ build:
+ runs-on: windows-2022
+ timeout-minutes: 60
+
+ steps:
+ # —— 1. Checkout source ———————————————————————————————————
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ # —— 2. MSVC 2022 x64 environment ————————————————————————
+ - name: Setup MSVC
+ uses: TheMrMilchmann/setup-msvc-dev@v4
+ with:
+ arch: x64
+
+ # —— 3. Install Qt 6 with required modules ———————————————
+ - name: Install Qt
+ uses: jurplel/install-qt-action@v4
+ with:
+ version: ${{ env.QT_VERSION }}
+ host: windows
+ target: desktop
+ arch: win64_msvc2022_64
+ modules: >-
+ qtwebengine qtcharts qtserialport
+ qtwebsockets qtwebchannel qtpositioning
+ tools: tools_ifw
+ source: true
+ src-archives: qtbase
+ cache: true
+
+ # —— 4. Download Hamlib w64 binary ————————————————————————
+ - name: Download Hamlib
+ shell: pwsh
+ run: |
+ $zip = "hamlib-w64-${{ env.HAMLIB_VERSION }}.zip"
+ $url = "https://github.com/Hamlib/Hamlib/releases/download/${{ env.HAMLIB_VERSION }}/$zip"
+ Invoke-WebRequest $url -OutFile $zip
+ Expand-Archive $zip -DestinationPath C:\
+ Rename-Item "C:\hamlib-w64-${{ env.HAMLIB_VERSION }}" C:\hamlib
+
+ $msvc = "C:\hamlib\lib\msvc"
+ if (!(Test-Path $msvc)) { New-Item -ItemType Directory $msvc | Out-Null }
+ if (!(Test-Path "$msvc\libhamlib-4.lib")) {
+ $def = Get-ChildItem C:\hamlib -Recurse -Filter "libhamlib-4.def" | Select -First 1
+ if ($def) {
+ $dest = "$msvc\libhamlib-4.def"
+ if ($def.FullName -ne $dest) { Copy-Item $def.FullName $dest }
+ Push-Location $msvc
+ lib /machine:X64 /def:libhamlib-4.def /out:libhamlib-4.lib
+ Pop-Location
+ }
+ }
+
+ # —— 5. pthreads + zlib via fresh vcpkg clone —————————————
+ - name: Install vcpkg dependencies
+ shell: cmd
+ run: |
+ git clone --depth 1 https://github.com/microsoft/vcpkg.git C:\vcpkg
+ cd /d C:\vcpkg
+ call bootstrap-vcpkg.bat
+ vcpkg install pthreads:x64-windows zlib:x64-windows openssl:x64-windows
+
+ # —— 6. Build QtKeychain from source ——————————————————————
+ - name: Build QtKeychain
+ shell: cmd
+ run: |
+ git clone --depth 1 https://github.com/frankosterfeld/qtkeychain.git C:\qtkeychain-src
+ cd /d C:\qtkeychain-src
+ cmake -B build -G "NMake Makefiles" ^
+ -DCMAKE_BUILD_TYPE=Release ^
+ -DBUILD_WITH_QT6=ON ^
+ -DCMAKE_PREFIX_PATH="%QT_ROOT_DIR%" ^
+ -DCMAKE_INSTALL_PREFIX=C:\qtkeychain
+ cmake --build build --config Release
+ cmake --install build
+
+ # —— 7. Locate OpenSSL ————————————————————————————————————
+ - name: Locate OpenSSL
+ shell: pwsh
+ run: |
+ $candidates = @(
+ "C:\Program Files\OpenSSL-Win64",
+ "C:\Program Files\OpenSSL",
+ "C:\OpenSSL-Win64"
+ )
+ $ssl = $candidates | Where-Object { Test-Path $_ } | Select -First 1
+ if (!$ssl) {
+ choco install openssl -y --no-progress
+ $ssl = $candidates | Where-Object { Test-Path $_ } | Select -First 1
+ }
+ if ($ssl) {
+ echo "OPENSSL_DIR=$ssl" >> $env:GITHUB_ENV
+ } else {
+ echo "OPENSSL_DIR=" >> $env:GITHUB_ENV
+ }
+
+ # —— 7c. Install OmniRig v1 + v2 —————————————————————————
+ - name: Install OmniRig
+ shell: pwsh
+ run: |
+ # --- OmniRig v1: InnoSetup installer (works silently) ---
+ Write-Host "=== OmniRig v1 ==="
+ Invoke-WebRequest "http://www.dxatlas.com/OmniRig/Files/OmniRig.zip" -OutFile OmniRig.zip
+ Expand-Archive OmniRig.zip -DestinationPath C:\omnirig-v1-tmp
+ $setup = Get-ChildItem C:\omnirig-v1-tmp -Recurse -Filter "OmniRigSetup.exe" | Select -First 1
+ if ($setup) {
+ Start-Process -FilePath $setup.FullName -ArgumentList "/VERYSILENT","/SUPPRESSMSGBOXES","/NORESTART" -Wait
+ Start-Sleep -Seconds 2
+ }
+ $v1path = "C:\Program Files (x86)\Afreet\OmniRig\OmniRig.exe"
+ if (Test-Path $v1path) {
+ Write-Host "v1 OK: $v1path"
+ } else {
+ Write-Error "v1 FAILED"
+ }
+
+ # --- OmniRig v2: try installer with timeout, fall back to v1 .tlb ---
+ Write-Host "=== OmniRig v2 ==="
+ $v2installed = $false
+ Invoke-WebRequest "https://www.hb9ryz.ch/downloads/install_omnirigv21.zip" -OutFile omnirigv2.zip
+ Expand-Archive omnirigv2.zip -DestinationPath C:\omnirig-v2-tmp
+
+ $installer = Get-ChildItem C:\omnirig-v2-tmp -Recurse -Filter "*.exe" | Select -First 1
+ if ($installer) {
+ Write-Host "Trying /S (NSIS) with 30s timeout..."
+ $proc = Start-Process -FilePath $installer.FullName -ArgumentList "/S" -PassThru
+ $finished = $proc.WaitForExit(30000)
+ if (!$finished) {
+ Write-Host "Installer timed out — killing"
+ $proc.Kill()
+ }
+ $v2path = "C:\Program Files (x86)\Omni-Rig V2\omnirig2.exe"
+ if (Test-Path $v2path) {
+ Write-Host "v2 installed via /S"
+ $v2installed = $true
+ }
+ }
+
+ if (!$v2installed) {
+ # Fallback: use v1 .tlb and patch source to remove v2-only features
+ Write-Host "v2 installer failed — using v1 .tlb fallback with Rig3/Rig4 patch"
+ Invoke-WebRequest "https://raw.githubusercontent.com/VE3NEA/OmniRig/master/OmniRig.tlb" -OutFile "C:\omnirig-v1.tlb"
+
+ $v2file = "rig\drivers\Omnirigv2RigDrv.cpp"
+ $content = Get-Content $v2file -Raw
+
+ # Patch #import to use v1 .tlb
+ $content = $content.Replace(
+ 'C:\\Program Files (x86)\\Omni-Rig V2\\omnirig2.exe',
+ 'C:\\omnirig-v1.tlb')
+
+ # Remove get_Rig3/get_Rig4 calls (v1 only has Rig1+Rig2)
+ # Change case 3/4 to fall through to default (E_INVALIDARG)
+ $content = $content.Replace(
+ 'case 3: hr = omniInterface->get_Rig3(&rig); break;',
+ 'case 3: /* Rig3 not available in v1 fallback */')
+ $content = $content.Replace(
+ 'case 4: hr = omniInterface->get_Rig4(&rig); break;',
+ 'case 4: /* Rig4 not available in v1 fallback */')
+
+ Set-Content $v2file $content -NoNewline
+
+ Write-Host "Patched v2 source:"
+ Select-String '#import' $v2file | ForEach-Object { $_.Line.Trim() }
+ Select-String 'case 3:|case 4:' $v2file | ForEach-Object { $_.Line.Trim() }
+ }
+
+ # —— 8. Build QLog ————————————————————————————————————————
+ - name: Build QLog
+ shell: cmd
+ run: |
+ set "QTKC_INC=C:\qtkeychain\include"
+ set "VCPKG_INC=C:\vcpkg\installed\x64-windows\include"
+ set "VCPKG_LIB=C:\vcpkg\installed\x64-windows\lib"
+
+ mkdir build
+ cd build
+ qmake ..\QLog.pro -spec win32-msvc ^
+ "CONFIG+=release" ^
+ "HAMLIBINCLUDEPATH=C:\hamlib\include" ^
+ "HAMLIBLIBPATH=C:\hamlib\lib\msvc" ^
+ "HAMLIBVERSION_MAJOR=4" ^
+ "HAMLIBVERSION_MINOR=7" ^
+ "HAMLIBVERSION_PATCH=1" ^
+ "QTKEYCHAININCLUDEPATH=%QTKC_INC%" ^
+ "QTKEYCHAINLIBPATH=C:\qtkeychain\lib" ^
+ "PTHREADINCLUDEPATH=%VCPKG_INC%" ^
+ "PTHREADLIBPATH=%VCPKG_LIB%" ^
+ "ZLIBINCLUDEPATH=%VCPKG_INC%" ^
+ "ZLIBLIBPATH=%VCPKG_LIB%" ^
+ "OPENSSLINCLUDEPATH=%VCPKG_INC%" ^
+ "OPENSSLLIBPATH=%VCPKG_LIB%"
+ nmake
+
+ # —— 9. Package with windeployqt ——————————————————————————
+ - name: Deploy
+ shell: pwsh
+ run: |
+ $deploy = "C:\qlog-deploy"
+ New-Item -ItemType Directory $deploy -Force | Out-Null
+
+ $exe = Get-ChildItem build -Recurse -Filter "qlog.exe" | Select -First 1
+ if (!$exe) { Write-Error "qlog.exe not found!"; exit 1 }
+ Copy-Item $exe.FullName $deploy\
+
+ # Copy qt6keychain.dll to Qt bin dir so windeployqt can resolve it
+ $qtBin = Join-Path $env:QT_ROOT_DIR "bin"
+ Copy-Item C:\qtkeychain\bin\qt6keychain.dll "$qtBin\" -Force -ErrorAction SilentlyContinue
+ Copy-Item C:\qtkeychain\lib\qt6keychain.dll "$qtBin\" -Force -ErrorAction SilentlyContinue
+
+ Copy-Item C:\hamlib\bin\*.dll $deploy\
+ Copy-Item C:\qtkeychain\bin\*.dll $deploy\ -ErrorAction SilentlyContinue
+ Copy-Item C:\qtkeychain\lib\*.dll $deploy\ -ErrorAction SilentlyContinue
+
+ $vcpkgBin = "C:\vcpkg\installed\x64-windows\bin"
+ if (Test-Path $vcpkgBin) {
+ Copy-Item "$vcpkgBin\*.dll" $deploy\ -ErrorAction SilentlyContinue
+ }
+
+ if ($env:OPENSSL_DIR -and (Test-Path $env:OPENSSL_DIR)) {
+ $sslBin = Join-Path $env:OPENSSL_DIR "bin"
+ if (Test-Path $sslBin) {
+ Copy-Item "$sslBin\libssl*.dll" $deploy\ -ErrorAction SilentlyContinue
+ Copy-Item "$sslBin\libcrypto*.dll" $deploy\ -ErrorAction SilentlyContinue
+ }
+ }
+
+ Push-Location $deploy
+ windeployqt --release --no-translations qlog.exe
+ Pop-Location
+
+ Write-Host "Deploy contents:"
+ Get-ChildItem $deploy | Format-Table Name, Length
+
+ # —— 10. Create Qt IFW installer ——————————————————————————
+ - name: Create Installer
+ shell: pwsh
+ run: |
+ $bc = $null
+ if ($env:IQTA_TOOLS) {
+ $bc = Get-ChildItem $env:IQTA_TOOLS -Recurse -Filter "binarycreator.exe" -ErrorAction SilentlyContinue | Select -First 1
+ }
+ if (!$bc) {
+ $bc = Get-ChildItem "$env:RUNNER_TOOL_CACHE" -Recurse -Filter "binarycreator.exe" -ErrorAction SilentlyContinue | Select -First 1
+ }
+ if (!$bc) {
+ Write-Warning "binarycreator not found — skipping installer"
+ exit 0
+ }
+
+ Copy-Item installer -Destination installer-build -Recurse
+ $pkgData = "installer-build\packages\de.dl2ic.qlog\data"
+ New-Item -ItemType Directory $pkgData -Force | Out-Null
+ Copy-Item C:\qlog-deploy\* $pkgData\ -Recurse
+
+ & $bc.FullName -f `
+ -c installer-build\config\config.xml `
+ -p installer-build\packages `
+ qlog-installer.exe
+
+ if (Test-Path qlog-installer.exe) {
+ Write-Host "Installer created: qlog-installer.exe"
+ }
+
+ # —— 11. Create portable ZIP ——————————————————————————————
+ - name: Create Portable ZIP
+ if: startsWith(github.ref, 'refs/tags/v')
+ shell: pwsh
+ run: |
+ $tag = "${{ github.ref_name }}"
+ Compress-Archive -Path C:\qlog-deploy\* -DestinationPath "QLog-Portable-Windows-${tag}.zip"
+ Write-Host "Portable ZIP: QLog-Portable-Windows-${tag}.zip"
+
+ # —— 12. Upload artifacts (always — for testing) ——————————
+ - name: Upload Installer
+ if: ${{ hashFiles('qlog-installer.exe') != '' }}
+ uses: actions/upload-artifact@v4
+ with:
+ name: QLog-Installer-Windows
+ path: qlog-installer.exe
+
+ - name: Upload Portable
+ uses: actions/upload-artifact@v4
+ with:
+ name: QLog-Portable-Windows
+ path: C:\qlog-deploy\
+
+ # —— 13. Create GitHub Release (only on tag push) —————————
+ - name: Create GitHub Release
+ if: startsWith(github.ref, 'refs/tags/v')
+ uses: softprops/action-gh-release@v2
+ with:
+ name: "QLog ${{ github.ref_name }}"
+ body: |
+ ## QLog ${{ github.ref_name }}
+
+ **Downloads:**
+ - **Installer** (recommended) — run `qlog-installer.exe`
+ - **Portable ZIP** — extract anywhere and run `qlog.exe`
+
+ Built with Qt ${{ env.QT_VERSION }}, Hamlib ${{ env.HAMLIB_VERSION }}, MSVC 2022 x64.
+ draft: false
+ prerelease: false
+ files: |
+ qlog-installer.exe
+ QLog-Portable-Windows-${{ github.ref_name }}.zip
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/QLog.pro b/QLog.pro
index c40c6844..0bd37498 100644
--- a/QLog.pro
+++ b/QLog.pro
@@ -163,6 +163,7 @@ SOURCES += \
service/GenericCallbook.cpp \
service/GenericQSLDownloader.cpp \
service/GenericQSOUploader.cpp \
+ service/emailqsl/EmailQSLService.cpp \
service/cloudlog/Cloudlog.cpp \
service/clublog/ClubLog.cpp \
service/eqsl/Eqsl.cpp \
@@ -212,6 +213,8 @@ SOURCES += \
ui/ModeSelectionController.cpp \
ui/NewContactWidget.cpp \
ui/OnlineMapWidget.cpp \
+ ui/EmailQSLDialog.cpp \
+ ui/EmailQSLSettingsWidget.cpp \
ui/PaperQSLDialog.cpp \
ui/ProfileImageWidget.cpp \
ui/QSLImportStatDialog.cpp \
@@ -229,6 +232,7 @@ SOURCES += \
ui/WsjtxFilterDialog.cpp \
ui/WsjtxWidget.cpp \
ui/component/BaseDoubleSpinBox.cpp \
+ ui/component/CardEditorWidget.cpp \
ui/component/EditLine.cpp \
ui/component/FreqQSpinBox.cpp \
ui/component/ModeSubmodeDelegate.cpp \
@@ -366,6 +370,7 @@ HEADERS += \
service/GenericCallbook.h \
service/GenericQSLDownloader.h \
service/GenericQSOUploader.h \
+ service/emailqsl/EmailQSLService.h \
service/cloudlog/Cloudlog.h \
service/clublog/ClubLog.h \
service/eqsl/Eqsl.h \
@@ -391,6 +396,8 @@ HEADERS += \
ui/DevToolsDialog.h \
ui/DownloadQSLDialog.h \
ui/DxFilterDialog.h \
+ ui/EmailQSLDialog.h \
+ ui/EmailQSLSettingsWidget.h \
ui/DxWidget.h \
ui/DxccTableWidget.h \
ui/EditActivitiesDialog.h \
@@ -437,6 +444,7 @@ HEADERS += \
i18n/datastrings.tri \
ui/component/BaseDoubleSpinBox.h \
ui/component/ButtonStyle.h \
+ ui/component/CardEditorWidget.h \
ui/component/EditLine.h \
ui/component/FreqQSpinBox.h \
ui/component/ModeSubmodeDelegate.h \
@@ -466,6 +474,8 @@ FORMS += \
ui/DevToolsDialog.ui \
ui/DownloadQSLDialog.ui \
ui/DxFilterDialog.ui \
+ ui/EmailQSLDialog.ui \
+ ui/EmailQSLSettingsWidget.ui \
ui/DxWidget.ui \
ui/EditActivitiesDialog.ui \
ui/CabrilloExportDialog.ui \
@@ -640,7 +650,17 @@ macx: {
LIBS += -L/usr/local/lib -L/opt/homebrew/lib -lhamlib -lsqlite3 -lz -L/opt/local/lib -lssl -lcrypto
equals(QT_MAJOR_VERSION, 6): LIBS += -lqt6keychain
equals(QT_MAJOR_VERSION, 5): LIBS += -lqt5keychain
- DISTFILES +=
+
+ # Custom Info.plist — sets a real bundle identifier
+ # (io.github.foldynl.qlog) and declares NSLocalNetworkUsageDescription
+ # so macOS 14+ will actually prompt for / grant LAN access (needed
+ # for WSJT-X UDP, network rigs, rotators, etc.). Without this, qmake
+ # generates a default Info.plist with CFBundleIdentifier =
+ # com.yourcompany.qlog and no local-network key.
+ QMAKE_INFO_PLIST = $$PWD/res/macos/Info.plist
+ QMAKE_TARGET_BUNDLE_PREFIX = io.github.foldynl
+
+ DISTFILES += res/macos/Info.plist
}
win32: {
diff --git a/core/LogParam.cpp b/core/LogParam.cpp
index 2e4421f9..9ef6a591 100644
--- a/core/LogParam.cpp
+++ b/core/LogParam.cpp
@@ -622,6 +622,106 @@ void LogParam::setKSTChatUsername(const QString &username)
setParam("services/kst/chat/username", username);
}
+QString LogParam::getEmailQSLSmtpHost()
+{
+ return getParam("services/emailqsl/smtpHost").toString();
+}
+
+void LogParam::setEmailQSLSmtpHost(const QString &host)
+{
+ setParam("services/emailqsl/smtpHost", host);
+}
+
+int LogParam::getEmailQSLSmtpPort()
+{
+ return getParam("services/emailqsl/smtpPort", 587).toInt();
+}
+
+void LogParam::setEmailQSLSmtpPort(int port)
+{
+ setParam("services/emailqsl/smtpPort", port);
+}
+
+int LogParam::getEmailQSLSmtpEncryption()
+{
+ return getParam("services/emailqsl/smtpEncryption", 2).toInt();
+}
+
+void LogParam::setEmailQSLSmtpEncryption(int enc)
+{
+ setParam("services/emailqsl/smtpEncryption", enc);
+}
+
+QString LogParam::getEmailQSLSmtpUsername()
+{
+ return getParam("services/emailqsl/smtpUsername").toString();
+}
+
+void LogParam::setEmailQSLSmtpUsername(const QString &username)
+{
+ setParam("services/emailqsl/smtpUsername", username);
+}
+
+QString LogParam::getEmailQSLFromAddress()
+{
+ return getParam("services/emailqsl/fromAddress").toString();
+}
+
+void LogParam::setEmailQSLFromAddress(const QString &addr)
+{
+ setParam("services/emailqsl/fromAddress", addr);
+}
+
+QString LogParam::getEmailQSLFromName()
+{
+ return getParam("services/emailqsl/fromName").toString();
+}
+
+void LogParam::setEmailQSLFromName(const QString &name)
+{
+ setParam("services/emailqsl/fromName", name);
+}
+
+QString LogParam::getEmailQSLSubjectTemplate(const QString &defaultValue)
+{
+ return getParam("services/emailqsl/subjectTemplate", defaultValue).toString();
+}
+
+void LogParam::setEmailQSLSubjectTemplate(const QString &tmpl)
+{
+ setParam("services/emailqsl/subjectTemplate", tmpl);
+}
+
+QString LogParam::getEmailQSLBodyTemplate(const QString &defaultValue)
+{
+ return getParam("services/emailqsl/bodyTemplate", defaultValue).toString();
+}
+
+void LogParam::setEmailQSLBodyTemplate(const QString &tmpl)
+{
+ setParam("services/emailqsl/bodyTemplate", tmpl);
+}
+
+QString LogParam::getEmailQSLCardImagePath()
+{
+ return getParam("services/emailqsl/cardImagePath").toString();
+}
+
+void LogParam::setEmailQSLCardImagePath(const QString &path)
+{
+ setParam("services/emailqsl/cardImagePath", path);
+}
+
+QByteArray LogParam::getEmailQSLCardOverlays()
+{
+ return getParam("services/emailqsl/cardOverlays").toByteArray();
+}
+
+void LogParam::setEmailQSLCardOverlays(const QByteArray &json)
+{
+ setParam("services/emailqsl/cardOverlays", json);
+}
+
QString LogParam::getLoTWCallbookUsername()
{
return getParam("services/lotw/callbook/username").toString().trimmed();
diff --git a/core/LogParam.h b/core/LogParam.h
index a1f5bacc..70070686 100644
--- a/core/LogParam.h
+++ b/core/LogParam.h
@@ -201,6 +201,30 @@ class LogParam : public QObject
static QString getKSTChatUsername();
static void setKSTChatUsername(const QString& username);
+ /*********
+ * Email QSL
+ ********/
+ static QString getEmailQSLSmtpHost();
+ static void setEmailQSLSmtpHost(const QString &host);
+ static int getEmailQSLSmtpPort();
+ static void setEmailQSLSmtpPort(int port);
+ static int getEmailQSLSmtpEncryption();
+ static void setEmailQSLSmtpEncryption(int enc);
+ static QString getEmailQSLSmtpUsername();
+ static void setEmailQSLSmtpUsername(const QString &username);
+ static QString getEmailQSLFromAddress();
+ static void setEmailQSLFromAddress(const QString &addr);
+ static QString getEmailQSLFromName();
+ static void setEmailQSLFromName(const QString &name);
+ static QString getEmailQSLSubjectTemplate(const QString &defaultValue);
+ static void setEmailQSLSubjectTemplate(const QString &tmpl);
+ static QString getEmailQSLBodyTemplate(const QString &defaultValue);
+ static void setEmailQSLBodyTemplate(const QString &tmpl);
+ static QString getEmailQSLCardImagePath();
+ static void setEmailQSLCardImagePath(const QString &path);
+ static QByteArray getEmailQSLCardOverlays();
+ static void setEmailQSLCardOverlays(const QByteArray &json);
+
/*********
* LoTW
********/
diff --git a/core/Migration.cpp b/core/Migration.cpp
index 0c2efc64..93bc9339 100644
--- a/core/Migration.cpp
+++ b/core/Migration.cpp
@@ -329,6 +329,9 @@ bool DBSchemaMigration::functionMigration(int version)
case 35:
ret = removeSettings2DB();
break;
+ case 40:
+ ret = emailQSLSettings2DB();
+ break;
default:
ret = true;
}
@@ -907,6 +910,37 @@ bool DBSchemaMigration::removeSettings2DB()
return true;
}
+bool DBSchemaMigration::emailQSLSettings2DB()
+{
+ FCT_IDENTIFICATION;
+
+ QSettings settings;
+
+ if (settings.contains("emailqsl/smtpHost"))
+ LogParam::setEmailQSLSmtpHost(settings.value("emailqsl/smtpHost").toString());
+ if (settings.contains("emailqsl/smtpPort"))
+ LogParam::setEmailQSLSmtpPort(settings.value("emailqsl/smtpPort").toInt());
+ if (settings.contains("emailqsl/smtpEncryption"))
+ LogParam::setEmailQSLSmtpEncryption(settings.value("emailqsl/smtpEncryption").toInt());
+ if (settings.contains("emailqsl/smtpUsername"))
+ LogParam::setEmailQSLSmtpUsername(settings.value("emailqsl/smtpUsername").toString());
+ if (settings.contains("emailqsl/fromAddress"))
+ LogParam::setEmailQSLFromAddress(settings.value("emailqsl/fromAddress").toString());
+ if (settings.contains("emailqsl/fromName"))
+ LogParam::setEmailQSLFromName(settings.value("emailqsl/fromName").toString());
+ if (settings.contains("emailqsl/subjectTemplate"))
+ LogParam::setEmailQSLSubjectTemplate(settings.value("emailqsl/subjectTemplate").toString());
+ if (settings.contains("emailqsl/bodyTemplate"))
+ LogParam::setEmailQSLBodyTemplate(settings.value("emailqsl/bodyTemplate").toString());
+ if (settings.contains("emailqsl/cardImagePath"))
+ LogParam::setEmailQSLCardImagePath(settings.value("emailqsl/cardImagePath").toString());
+ if (settings.contains("emailqsl/cardOverlays"))
+ LogParam::setEmailQSLCardOverlays(settings.value("emailqsl/cardOverlays").toByteArray());
+
+ settings.remove("emailqsl");
+ return true;
+}
+
bool DBSchemaMigration::settings2DB()
{
FCT_IDENTIFICATION;
diff --git a/core/Migration.h b/core/Migration.h
index d4b4ec72..92f787ca 100644
--- a/core/Migration.h
+++ b/core/Migration.h
@@ -39,6 +39,7 @@ class DBSchemaMigration : public QObject
bool profiles2DB();
bool settings2DB();
bool removeSettings2DB();
+ bool emailQSLSettings2DB();
bool setSelectedProfile(const QString &tablename, const QString &profileName);
QString fixIntlField(const QSqlQuery &query, const QString &columName, const QString &columnNameIntl);
bool refreshUploadStatusTrigger();
diff --git a/res/macos/Info.plist b/res/macos/Info.plist
new file mode 100644
index 00000000..31b854a6
--- /dev/null
+++ b/res/macos/Info.plist
@@ -0,0 +1,50 @@
+
+
+
+
+ CFBundleAllowMixedLocalizations
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ @EXECUTABLE@
+ CFBundleIconFile
+ @ICON@
+ CFBundleIdentifier
+ io.github.foldynl.qlog
+ CFBundleName
+ QLog
+ CFBundleDisplayName
+ QLog
+ CFBundlePackageType
+ APPL
+ CFBundleSignature
+ ????
+ CFBundleShortVersionString
+ @SHORT_VERSION@
+ CFBundleVersion
+ @FULL_VERSION@
+ LSMinimumSystemVersion
+ 14.0
+ LSApplicationCategoryType
+ public.app-category.utilities
+ NSHumanReadableCopyright
+ Copyright © OK1MLG. Licensed under the GNU GPL v3.
+ NSPrincipalClass
+ NSApplication
+ NSSupportsAutomaticGraphicsSwitching
+
+ NSHighResolutionCapable
+
+
+
+ NSLocalNetworkUsageDescription
+ QLog needs local network access to communicate with WSJT-X, network-attached rigs, rotators, and other amateur-radio software running on devices on your network.
+
+
diff --git a/service/emailqsl/EmailQSLService.cpp b/service/emailqsl/EmailQSLService.cpp
new file mode 100644
index 00000000..7a80effe
--- /dev/null
+++ b/service/emailqsl/EmailQSLService.cpp
@@ -0,0 +1,824 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "EmailQSLService.h"
+#include "core/debug.h"
+#include "core/LogParam.h"
+
+MODULE_IDENTIFICATION("qlog.service.emailqsl");
+
+// ---------------------------------------------------------------------------
+// EmailQSLFieldOverlay
+// ---------------------------------------------------------------------------
+
+QJsonObject EmailQSLFieldOverlay::toJson() const
+{
+ QJsonObject o;
+ o["type"] = type;
+ o["fieldName"] = fieldName;
+ o["x"] = x;
+ o["y"] = y;
+ o["fontFamily"] = fontFamily;
+ o["fontSize"] = fontSize;
+ o["color"] = color;
+ o["bold"] = bold;
+ o["italic"] = italic;
+ o["width"] = width;
+ o["height"] = height;
+ o["fillColor"] = fillColor;
+ o["opacity"] = opacity;
+ o["cornerRadius"] = cornerRadius;
+ return o;
+}
+
+EmailQSLFieldOverlay EmailQSLFieldOverlay::fromJson(const QJsonObject &obj)
+{
+ EmailQSLFieldOverlay f;
+ f.type = obj.value("type").toString(QStringLiteral("TEXT"));
+ f.fieldName = obj.value("fieldName").toString();
+ f.x = obj.value("x").toInt();
+ f.y = obj.value("y").toInt();
+ f.fontFamily = obj.value("fontFamily").toString(QStringLiteral("Arial"));
+ f.fontSize = obj.value("fontSize").toInt(14);
+ f.color = obj.value("color").toString(QStringLiteral("#000000"));
+ f.bold = obj.value("bold").toBool();
+ f.italic = obj.value("italic").toBool();
+ f.width = obj.value("width").toInt(120);
+ f.height = obj.value("height").toInt(60);
+ f.fillColor = obj.value("fillColor").toString(QStringLiteral("#FFFF99"));
+ f.opacity = obj.value("opacity").toInt(80);
+ f.cornerRadius = obj.value("cornerRadius").toInt(8);
+ return f;
+}
+
+// ---------------------------------------------------------------------------
+// EmailQSLBase — credential registration
+// ---------------------------------------------------------------------------
+
+const QString EmailQSLBase::SECURE_STORAGE_KEY = QStringLiteral("EmailQSL");
+REGISTRATION_SECURE_SERVICE(EmailQSLBase);
+
+void EmailQSLBase::registerCredentials()
+{
+ CredentialRegistry::instance().add(SECURE_STORAGE_KEY, []()
+ {
+ return QList
+ {
+ { SECURE_STORAGE_KEY, []() { return getSmtpUsername(); } }
+ };
+ });
+}
+
+// ---------------------------------------------------------------------------
+// EmailQSLBase — settings persisted via LogParam (log_param DB table)
+// ---------------------------------------------------------------------------
+
+QString EmailQSLBase::getSmtpHost()
+{
+ return LogParam::getEmailQSLSmtpHost();
+}
+
+void EmailQSLBase::setSmtpHost(const QString &host)
+{
+ LogParam::setEmailQSLSmtpHost(host);
+}
+
+int EmailQSLBase::getSmtpPort()
+{
+ return LogParam::getEmailQSLSmtpPort();
+}
+
+void EmailQSLBase::setSmtpPort(int port)
+{
+ LogParam::setEmailQSLSmtpPort(port);
+}
+
+int EmailQSLBase::getSmtpEncryption()
+{
+ return LogParam::getEmailQSLSmtpEncryption();
+}
+
+void EmailQSLBase::setSmtpEncryption(int enc)
+{
+ LogParam::setEmailQSLSmtpEncryption(enc);
+}
+
+QString EmailQSLBase::getSmtpUsername()
+{
+ return LogParam::getEmailQSLSmtpUsername();
+}
+
+void EmailQSLBase::setSmtpUsername(const QString &username)
+{
+ LogParam::setEmailQSLSmtpUsername(username);
+}
+
+QString EmailQSLBase::getSmtpPassword()
+{
+ return getPassword(SECURE_STORAGE_KEY, getSmtpUsername());
+}
+
+void EmailQSLBase::saveSmtpCredentials(const QString &username, const QString &password)
+{
+ const QString oldUsername = getSmtpUsername();
+ if (oldUsername != username && !oldUsername.isEmpty())
+ deletePassword(SECURE_STORAGE_KEY, oldUsername);
+ setSmtpUsername(username);
+ savePassword(SECURE_STORAGE_KEY, username, password);
+}
+
+QString EmailQSLBase::getFromAddress()
+{
+ return LogParam::getEmailQSLFromAddress();
+}
+
+void EmailQSLBase::setFromAddress(const QString &addr)
+{
+ LogParam::setEmailQSLFromAddress(addr);
+}
+
+QString EmailQSLBase::getFromName()
+{
+ return LogParam::getEmailQSLFromName();
+}
+
+void EmailQSLBase::setFromName(const QString &name)
+{
+ LogParam::setEmailQSLFromName(name);
+}
+
+QString EmailQSLBase::getSubjectTemplate()
+{
+ return LogParam::getEmailQSLSubjectTemplate(
+ QStringLiteral("QSL Card from {MY_CALLSIGN} for our QSO on {QSO_DATE}"));
+}
+
+void EmailQSLBase::setSubjectTemplate(const QString &tmpl)
+{
+ LogParam::setEmailQSLSubjectTemplate(tmpl);
+}
+
+QString EmailQSLBase::getBodyTemplate()
+{
+ return LogParam::getEmailQSLBodyTemplate(
+ QStringLiteral("Dear {NAME},\n\nPlease find my QSL card attached confirming our contact.\n\n"
+ "Callsign: {MY_CALLSIGN}\n"
+ "Date: {QSO_DATE}\nTime: {TIME_ON} UTC\n"
+ "Band: {BAND}\nMode: {MODE}\n"
+ "RST Sent: {RST_SENT}\nRST Rcvd: {RST_RCVD}\n\n"
+ "73,\n{MY_CALLSIGN}"));
+}
+
+void EmailQSLBase::setBodyTemplate(const QString &tmpl)
+{
+ LogParam::setEmailQSLBodyTemplate(tmpl);
+}
+
+QString EmailQSLBase::getCardImagePath()
+{
+ return LogParam::getEmailQSLCardImagePath();
+}
+
+void EmailQSLBase::setCardImagePath(const QString &path)
+{
+ LogParam::setEmailQSLCardImagePath(path);
+}
+
+QList EmailQSLBase::getCardFieldOverlays()
+{
+ QList result;
+ const QJsonArray arr = QJsonDocument::fromJson(LogParam::getEmailQSLCardOverlays()).array();
+ for (const QJsonValue &v : arr)
+ result.append(EmailQSLFieldOverlay::fromJson(v.toObject()));
+ return result;
+}
+
+void EmailQSLBase::setCardFieldOverlays(const QList &overlays)
+{
+ QJsonArray arr;
+ for (const EmailQSLFieldOverlay &o : overlays)
+ arr.append(o.toJson());
+ LogParam::setEmailQSLCardOverlays(QJsonDocument(arr).toJson(QJsonDocument::Compact));
+}
+
+// ---------------------------------------------------------------------------
+// EmailQSLBase — sent-tracking via contacts.fields JSON
+// ---------------------------------------------------------------------------
+
+static const QString EMAIL_QSL_SENT_KEY = QStringLiteral("email_qsl_sent_dt");
+
+QDateTime EmailQSLBase::getEmailSentDateTime(const QSqlRecord &record)
+{
+ const QByteArray raw = record.value(QStringLiteral("fields")).toByteArray();
+ const QJsonObject fields = QJsonDocument::fromJson(raw).object();
+ const QString dtStr = fields.value(EMAIL_QSL_SENT_KEY).toString();
+ return dtStr.isEmpty() ? QDateTime() : QDateTime::fromString(dtStr, Qt::ISODate);
+}
+
+bool EmailQSLBase::hasEmailBeenSentToCallsign(const QString &callsign, int excludeId)
+{
+ QSqlQuery q;
+ q.prepare(QStringLiteral("SELECT fields FROM contacts WHERE callsign = :cs AND id != :ex"));
+ q.bindValue(":cs", callsign.toUpper());
+ q.bindValue(":ex", excludeId);
+ if (!q.exec())
+ return false;
+
+ while (q.next())
+ {
+ const QByteArray raw = q.value(0).toByteArray();
+ const QJsonObject fields = QJsonDocument::fromJson(raw).object();
+ if (fields.contains(EMAIL_QSL_SENT_KEY))
+ return true;
+ }
+ return false;
+}
+
+void EmailQSLBase::recordEmailSent(int contactId, const QSqlRecord ¤tRecord)
+{
+ FCT_IDENTIFICATION;
+
+ const QByteArray raw = currentRecord.value(QStringLiteral("fields")).toByteArray();
+ QJsonObject fields = QJsonDocument::fromJson(raw).object();
+ fields[EMAIL_QSL_SENT_KEY] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
+
+ QSqlQuery q;
+ q.prepare(QStringLiteral("UPDATE contacts SET fields = :f WHERE id = :id"));
+ q.bindValue(":f", QJsonDocument(fields).toJson(QJsonDocument::Compact));
+ q.bindValue(":id", contactId);
+ if (!q.exec())
+ qCWarning(runtime) << "recordEmailSent update failed:" << q.lastError().text();
+}
+
+// ---------------------------------------------------------------------------
+// EmailQSLBase — merge fields
+// ---------------------------------------------------------------------------
+
+QString EmailQSLBase::fieldValue(const QString &key, const QSqlRecord &record)
+{
+ if (key == QLatin1String("CALLSIGN"))
+ return record.value(QStringLiteral("callsign")).toString().toUpper();
+
+ if (key == QLatin1String("QSO_DATE"))
+ {
+ const QDateTime dt = record.value(QStringLiteral("start_time")).toDateTime();
+ return dt.isValid() ? dt.toUTC().toString(QStringLiteral("dd-MMM-yyyy")).toUpper() : QString();
+ }
+ if (key == QLatin1String("QSO_DATE_ISO"))
+ {
+ const QDateTime dt = record.value(QStringLiteral("start_time")).toDateTime();
+ return dt.isValid() ? dt.toUTC().toString(QStringLiteral("yyyyMMdd")) : QString();
+ }
+ if (key == QLatin1String("TIME_ON"))
+ {
+ const QDateTime dt = record.value(QStringLiteral("start_time")).toDateTime();
+ return dt.isValid() ? dt.toUTC().toString(QStringLiteral("HHmm")) : QString();
+ }
+ if (key == QLatin1String("FREQ"))
+ {
+ bool ok;
+ double mhz = record.value(QStringLiteral("freq")).toDouble(&ok);
+ return ok ? QString::number(mhz, 'f', 3) : QString();
+ }
+ if (key == QLatin1String("BAND")) return record.value(QStringLiteral("band")).toString().toUpper();
+ if (key == QLatin1String("MODE")) return record.value(QStringLiteral("mode")).toString().toUpper();
+ if (key == QLatin1String("SUBMODE")) return record.value(QStringLiteral("submode")).toString().toUpper();
+ if (key == QLatin1String("RST_SENT")) return record.value(QStringLiteral("rst_sent")).toString();
+ if (key == QLatin1String("RST_RCVD")) return record.value(QStringLiteral("rst_rcvd")).toString();
+ if (key == QLatin1String("NAME"))
+ {
+ const QString n = record.value(QStringLiteral("name_intl")).toString();
+ return n.isEmpty() ? record.value(QStringLiteral("name")).toString() : n;
+ }
+ if (key == QLatin1String("QTH"))
+ {
+ const QString q = record.value(QStringLiteral("qth_intl")).toString();
+ return q.isEmpty() ? record.value(QStringLiteral("qth")).toString() : q;
+ }
+ if (key == QLatin1String("COUNTRY"))
+ {
+ const QString c = record.value(QStringLiteral("country_intl")).toString();
+ return c.isEmpty() ? record.value(QStringLiteral("country")).toString() : c;
+ }
+ if (key == QLatin1String("GRIDSQUARE")) return record.value(QStringLiteral("gridsquare")).toString().toUpper();
+ if (key == QLatin1String("DXCC")) return record.value(QStringLiteral("dxcc")).toString();
+ if (key == QLatin1String("CQZ")) return record.value(QStringLiteral("cqz")).toString();
+ if (key == QLatin1String("ITUZ")) return record.value(QStringLiteral("ituz")).toString();
+ if (key == QLatin1String("TX_PWR")) return record.value(QStringLiteral("tx_pwr")).toString();
+ if (key == QLatin1String("EMAIL")) return record.value(QStringLiteral("email")).toString();
+ if (key == QLatin1String("MY_CALLSIGN")) return record.value(QStringLiteral("station_callsign")).toString().toUpper();
+ if (key == QLatin1String("MY_GRIDSQUARE")) return record.value(QStringLiteral("my_gridsquare")).toString().toUpper();
+ if (key == QLatin1String("OPERATOR")) return record.value(QStringLiteral("operator")).toString().toUpper();
+ if (key == QLatin1String("COMMENT"))
+ {
+ const QString c = record.value(QStringLiteral("comment_intl")).toString();
+ return c.isEmpty() ? record.value(QStringLiteral("comment")).toString() : c;
+ }
+ if (key == QLatin1String("SOTA_REF")) return record.value(QStringLiteral("sota_ref")).toString();
+ if (key == QLatin1String("POTA_REF")) return record.value(QStringLiteral("pota_ref")).toString();
+ if (key == QLatin1String("WWFF_REF")) return record.value(QStringLiteral("wwff_ref")).toString();
+ if (key == QLatin1String("IOTA")) return record.value(QStringLiteral("iota")).toString();
+ if (key == QLatin1String("SIG")) return record.value(QStringLiteral("sig_intl")).toString();
+ if (key == QLatin1String("CONTEST_ID")) return record.value(QStringLiteral("contest_id")).toString();
+
+ // Fallback: try direct lowercase column name
+ const QString col = key.toLower();
+ if (record.indexOf(col) >= 0)
+ return record.value(col).toString();
+
+ return QString();
+}
+
+QString EmailQSLBase::applyMergeFields(const QString &tmpl, const QSqlRecord &record)
+{
+ QString result = tmpl;
+ static const QRegularExpression rx(QStringLiteral("\\{([A-Z0-9_]+)\\}"));
+ QRegularExpressionMatchIterator it = rx.globalMatch(tmpl);
+ while (it.hasNext())
+ {
+ const QRegularExpressionMatch m = it.next();
+ const QString key = m.captured(1);
+ const QString value = fieldValue(key, record);
+ result.replace(QLatin1Char('{') + key + QLatin1Char('}'), value);
+ }
+ return result;
+}
+
+QList EmailQSLBase::availableMergeFields()
+{
+ return {
+ { "CALLSIGN", QObject::tr("Contact callsign") },
+ { "QSO_DATE", QObject::tr("QSO date (dd-MMM-yyyy)") },
+ { "QSO_DATE_ISO", QObject::tr("QSO date (YYYYMMDD)") },
+ { "TIME_ON", QObject::tr("QSO start time UTC (HHmm)") },
+ { "FREQ", QObject::tr("Frequency (MHz)") },
+ { "BAND", QObject::tr("Band") },
+ { "MODE", QObject::tr("Mode") },
+ { "SUBMODE", QObject::tr("Sub-mode") },
+ { "RST_SENT", QObject::tr("RST sent") },
+ { "RST_RCVD", QObject::tr("RST received") },
+ { "NAME", QObject::tr("Contact name") },
+ { "QTH", QObject::tr("Contact QTH") },
+ { "COUNTRY", QObject::tr("Country") },
+ { "GRIDSQUARE", QObject::tr("Grid square") },
+ { "DXCC", QObject::tr("DXCC entity number") },
+ { "CQZ", QObject::tr("CQ zone") },
+ { "ITUZ", QObject::tr("ITU zone") },
+ { "TX_PWR", QObject::tr("TX power") },
+ { "EMAIL", QObject::tr("Contact email address") },
+ { "MY_CALLSIGN", QObject::tr("My callsign") },
+ { "MY_GRIDSQUARE", QObject::tr("My grid square") },
+ { "OPERATOR", QObject::tr("Operator callsign") },
+ { "COMMENT", QObject::tr("Comment") },
+ { "SOTA_REF", QObject::tr("SOTA reference") },
+ { "POTA_REF", QObject::tr("POTA reference") },
+ { "WWFF_REF", QObject::tr("WWFF reference") },
+ { "IOTA", QObject::tr("IOTA reference") },
+ { "SIG", QObject::tr("Special interest group") },
+ { "CONTEST_ID", QObject::tr("Contest ID") },
+ };
+}
+
+// ---------------------------------------------------------------------------
+// EmailQSLBase — card rendering
+// ---------------------------------------------------------------------------
+
+QPixmap EmailQSLBase::renderCard(const QSqlRecord &record)
+{
+ FCT_IDENTIFICATION;
+ return renderCard(getCardImagePath(), record, getCardFieldOverlays());
+}
+
+QPixmap EmailQSLBase::renderCard(const QString &imagePath,
+ const QSqlRecord &record,
+ const QList &overlays)
+{
+ FCT_IDENTIFICATION;
+
+ QPixmap pixmap(imagePath);
+ if (pixmap.isNull())
+ {
+ qCWarning(runtime) << "renderCard: could not load image:" << imagePath;
+ return QPixmap();
+ }
+
+ // Overlay x, y, fontSize and BOX width/height are all stored as absolute
+ // pixel values scaled to the source image dimensions. No extra scaling is
+ // applied here — the values are used as-is. addDefaultOverlays() and
+ // onCardImageChanged() are responsible for keeping them proportional whenever
+ // the background image changes.
+ QPainter painter(&pixmap);
+ painter.setRenderHint(QPainter::Antialiasing);
+ painter.setRenderHint(QPainter::TextAntialiasing);
+
+ for (const EmailQSLFieldOverlay &ov : overlays)
+ {
+ if (ov.type == QLatin1String("BOX"))
+ {
+ QColor fill(ov.fillColor);
+ fill.setAlphaF(ov.opacity / 100.0);
+ painter.setPen(QPen(QColor(ov.color), 1.5));
+ painter.setBrush(fill);
+ painter.drawRoundedRect(ov.x, ov.y, ov.width, ov.height,
+ ov.cornerRadius, ov.cornerRadius);
+
+ if (!ov.fieldName.isEmpty())
+ {
+ QFont font(ov.fontFamily, ov.fontSize > 0 ? ov.fontSize : 11);
+ font.setBold(ov.bold);
+ painter.setFont(font);
+ painter.setPen(QColor(ov.color));
+ painter.setBrush(Qt::NoBrush);
+ QFontMetrics fm(font);
+ painter.drawText(ov.x, ov.y - fm.descent() - 2, ov.fieldName);
+ }
+ }
+ else if (ov.type == QLatin1String("LABEL"))
+ {
+ if (ov.fieldName.isEmpty())
+ continue;
+
+ QFont font(ov.fontFamily, ov.fontSize);
+ font.setBold(ov.bold);
+ font.setItalic(ov.italic);
+ painter.setFont(font);
+ painter.setPen(QColor(ov.color));
+ painter.setBrush(Qt::NoBrush);
+ painter.drawText(ov.x, ov.y, ov.fieldName);
+ }
+ else // TEXT — merge-field substitution
+ {
+ const QString value = fieldValue(ov.fieldName, record);
+ if (value.isEmpty())
+ continue;
+
+ QFont font(ov.fontFamily, ov.fontSize);
+ font.setBold(ov.bold);
+ font.setItalic(ov.italic);
+ painter.setFont(font);
+ painter.setPen(QColor(ov.color));
+ painter.setBrush(Qt::NoBrush);
+ painter.drawText(ov.x, ov.y, value);
+ }
+ }
+ painter.end();
+ return pixmap;
+}
+
+// ---------------------------------------------------------------------------
+// SmtpWorker
+// ---------------------------------------------------------------------------
+
+SmtpWorker::SmtpWorker(const QString &host, int port, int encryption,
+ const QString &username, const QString &password,
+ const QString &fromAddress, const QString &fromName,
+ const QString &toAddress,
+ const QString &subject, const QString &body,
+ const QByteArray &imageData, const QString &imageName,
+ QObject *parent)
+ : QObject(parent),
+ m_host(host), m_port(port), m_encryption(encryption),
+ m_username(username), m_password(password),
+ m_fromAddress(fromAddress), m_fromName(fromName),
+ m_toAddress(toAddress),
+ m_subject(subject), m_body(body),
+ m_imageData(imageData), m_imageName(imageName)
+{
+}
+
+bool SmtpWorker::waitForResponse(QSslSocket *socket, QByteArray &out, int timeoutMs)
+{
+ out.clear();
+ // Multi-line responses end with "NNN " (space after code); single with "NNN "
+ // Keep reading until we get a line without a dash after the code.
+ do {
+ if (!socket->waitForReadyRead(timeoutMs))
+ return false;
+ out += socket->readAll();
+ } while (out.size() > 3 && out[out.size()-2] != '\r' &&
+ (out.size() < 4 || out[3] == '-'));
+ // More robust: check if last complete line starts with "NNN " (no dash)
+ // Parse the last CRLF-terminated line
+ const QList lines = out.split('\n');
+ for (const QByteArray &line : lines)
+ {
+ if (line.length() >= 4 && line[3] == ' ')
+ return true; // found a final response line
+ if (line.length() >= 4 && line[3] == '-')
+ continue; // continuation line
+ }
+ return !out.isEmpty();
+}
+
+int SmtpWorker::responseCode(const QByteArray &response)
+{
+ // Find the last "NNN " line
+ const QList lines = response.split('\n');
+ for (int i = lines.size() - 1; i >= 0; --i)
+ {
+ const QByteArray &line = lines.at(i).trimmed();
+ if (line.length() >= 3)
+ {
+ bool ok;
+ int code = line.left(3).toInt(&ok);
+ if (ok)
+ return code;
+ }
+ }
+ return -1;
+}
+
+bool SmtpWorker::sendCommand(QSslSocket *socket, const QByteArray &cmd,
+ int expectedCode, QByteArray &response)
+{
+ socket->write(cmd);
+ if (!socket->waitForBytesWritten(10000))
+ return false;
+ if (!waitForResponse(socket, response))
+ return false;
+ return responseCode(response) == expectedCode;
+}
+
+QByteArray SmtpWorker::buildMimeMessage()
+{
+ const QString boundary = QStringLiteral("----QLogEmailQSL_%1")
+ .arg(QDateTime::currentMSecsSinceEpoch());
+
+ QByteArray msg;
+
+ // Encode From display name safely
+ const QByteArray fromDisplay = ("\"" + m_fromName + "\" <" + m_fromAddress + ">").toUtf8();
+ msg += "MIME-Version: 1.0\r\n";
+ msg += "From: " + fromDisplay + "\r\n";
+ msg += "To: <" + m_toAddress.toUtf8() + ">\r\n";
+ // RFC 2047 encoded subject
+ msg += "Subject: =?UTF-8?B?" + m_subject.toUtf8().toBase64() + "?=\r\n";
+ msg += "Content-Type: multipart/mixed; boundary=\"" + boundary.toUtf8() + "\"\r\n";
+ msg += "\r\n";
+
+ // --- Text part ---
+ msg += "--" + boundary.toUtf8() + "\r\n";
+ msg += "Content-Type: text/plain; charset=UTF-8\r\n";
+ msg += "Content-Transfer-Encoding: base64\r\n";
+ msg += "\r\n";
+ const QByteArray bodyB64 = m_body.toUtf8().toBase64();
+ for (int i = 0; i < bodyB64.size(); i += 76)
+ msg += bodyB64.mid(i, 76) + "\r\n";
+
+ // --- Image attachment (if present) ---
+ if (!m_imageData.isEmpty())
+ {
+ msg += "\r\n--" + boundary.toUtf8() + "\r\n";
+ const QString mimeType = m_imageName.endsWith(QLatin1String(".png"), Qt::CaseInsensitive)
+ ? QStringLiteral("image/png")
+ : QStringLiteral("image/jpeg");
+ msg += "Content-Type: " + mimeType.toUtf8()
+ + "; name=\"" + m_imageName.toUtf8() + "\"\r\n";
+ msg += "Content-Transfer-Encoding: base64\r\n";
+ msg += "Content-Disposition: attachment; filename=\""
+ + m_imageName.toUtf8() + "\"\r\n";
+ msg += "\r\n";
+ const QByteArray imgB64 = m_imageData.toBase64();
+ for (int i = 0; i < imgB64.size(); i += 76)
+ msg += imgB64.mid(i, 76) + "\r\n";
+ }
+
+ msg += "\r\n--" + boundary.toUtf8() + "--\r\n";
+ return msg;
+}
+
+void SmtpWorker::run()
+{
+ FCT_IDENTIFICATION;
+
+ QSslSocket socket;
+ socket.setProtocol(QSsl::AnyProtocol);
+ socket.setPeerVerifyMode(QSslSocket::VerifyNone); // tolerate self-signed certs
+
+ // ---- Connect ----
+ if (m_encryption == EmailQSLBase::ENCRYPTION_SSL_TLS)
+ {
+ socket.connectToHostEncrypted(m_host, static_cast(m_port));
+ if (!socket.waitForEncrypted(15000))
+ {
+ emit finished(false, tr("SSL/TLS connection failed: %1").arg(socket.errorString()));
+ return;
+ }
+ }
+ else
+ {
+ socket.connectToHost(m_host, static_cast(m_port));
+ if (!socket.waitForConnected(15000))
+ {
+ emit finished(false, tr("Could not connect to %1:%2 — %3")
+ .arg(m_host).arg(m_port).arg(socket.errorString()));
+ return;
+ }
+ }
+
+ // ---- Read greeting (220) ----
+ QByteArray resp;
+ if (!waitForResponse(&socket, resp) || responseCode(resp) != 220)
+ {
+ emit finished(false, tr("Server did not send a valid greeting (expected 220)."));
+ return;
+ }
+
+ // ---- EHLO ----
+ const QByteArray localHost = QHostInfo::localHostName().toUtf8();
+ if (!sendCommand(&socket, "EHLO " + localHost + "\r\n", 250, resp))
+ {
+ // Try HELO as fallback
+ if (!sendCommand(&socket, "HELO " + localHost + "\r\n", 250, resp))
+ {
+ emit finished(false, tr("EHLO/HELO rejected by server."));
+ return;
+ }
+ }
+
+ // ---- STARTTLS upgrade ----
+ if (m_encryption == EmailQSLBase::ENCRYPTION_STARTTLS)
+ {
+ if (!sendCommand(&socket, "STARTTLS\r\n", 220, resp))
+ {
+ emit finished(false, tr("STARTTLS not accepted by server."));
+ return;
+ }
+ socket.startClientEncryption();
+ if (!socket.waitForEncrypted(15000))
+ {
+ emit finished(false, tr("TLS handshake failed: %1").arg(socket.errorString()));
+ return;
+ }
+ // Re-EHLO after TLS negotiation
+ if (!sendCommand(&socket, "EHLO " + localHost + "\r\n", 250, resp))
+ {
+ emit finished(false, tr("EHLO after STARTTLS rejected."));
+ return;
+ }
+ }
+
+ // ---- AUTH LOGIN ----
+ if (!m_username.isEmpty())
+ {
+ if (!sendCommand(&socket, "AUTH LOGIN\r\n", 334, resp))
+ {
+ emit finished(false, tr("AUTH LOGIN not supported by server."));
+ return;
+ }
+ if (!sendCommand(&socket, m_username.toUtf8().toBase64() + "\r\n", 334, resp))
+ {
+ emit finished(false, tr("Username rejected by server."));
+ return;
+ }
+ if (!sendCommand(&socket, m_password.toUtf8().toBase64() + "\r\n", 235, resp))
+ {
+ emit finished(false, tr("Authentication failed — check your username and password."));
+ return;
+ }
+ }
+
+ // ---- MAIL FROM ----
+ if (!sendCommand(&socket, "MAIL FROM:<" + m_fromAddress.toUtf8() + ">\r\n", 250, resp))
+ {
+ emit finished(false, tr("MAIL FROM rejected: %1").arg(QString::fromUtf8(resp)));
+ return;
+ }
+
+ // ---- RCPT TO ----
+ if (!sendCommand(&socket, "RCPT TO:<" + m_toAddress.toUtf8() + ">\r\n", 250, resp))
+ {
+ emit finished(false, tr("Recipient address not accepted: %1").arg(QString::fromUtf8(resp)));
+ return;
+ }
+
+ // ---- DATA ----
+ if (!sendCommand(&socket, "DATA\r\n", 354, resp))
+ {
+ emit finished(false, tr("DATA command rejected."));
+ return;
+ }
+
+ // ---- Send message body ----
+ QByteArray mime = buildMimeMessage();
+ mime += "\r\n.\r\n";
+ socket.write(mime);
+ socket.flush();
+
+ if (!waitForResponse(&socket, resp) || responseCode(resp) != 250)
+ {
+ emit finished(false, tr("Message rejected by server: %1").arg(QString::fromUtf8(resp)));
+ return;
+ }
+
+ // ---- QUIT ----
+ socket.write("QUIT\r\n");
+ socket.waitForBytesWritten(5000);
+ socket.disconnectFromHost();
+ socket.waitForDisconnected(5000);
+
+ emit finished(true, QString());
+}
+
+// ---------------------------------------------------------------------------
+// EmailQSLService
+// ---------------------------------------------------------------------------
+
+EmailQSLService::EmailQSLService(QObject *parent)
+ : QObject(parent)
+{
+}
+
+void EmailQSLService::sendEmailQSL(const QSqlRecord &record)
+{
+ FCT_IDENTIFICATION;
+
+ // Render card at full source resolution (fonts scale with image size)
+ const QPixmap cardPixmap = EmailQSLBase::renderCard(record);
+ QByteArray imageData;
+ QString imageName;
+ if (!cardPixmap.isNull())
+ {
+ // Scale down to EMAIL_WIDTH for the attachment — keeps file size reasonable
+ // while still looking sharp on any screen. The full-res pixmap is only
+ // used when the user explicitly saves via "Save Card…".
+ static constexpr int EMAIL_WIDTH = 1280;
+ const QPixmap emailPixmap = (cardPixmap.width() > EMAIL_WIDTH)
+ ? cardPixmap.scaledToWidth(EMAIL_WIDTH, Qt::SmoothTransformation)
+ : cardPixmap;
+
+ QBuffer buf(&imageData);
+ buf.open(QIODevice::WriteOnly);
+ emailPixmap.save(&buf, "JPEG", 90);
+ imageName = QStringLiteral("qsl_card.jpg");
+ }
+
+ // Merge email fields
+ const QString subject = EmailQSLBase::applyMergeFields(
+ EmailQSLBase::getSubjectTemplate(), record);
+ const QString body = EmailQSLBase::applyMergeFields(
+ EmailQSLBase::getBodyTemplate(), record);
+
+ const QString toAddress = record.value(QStringLiteral("email")).toString().trimmed();
+
+ QThread *thread = new QThread(this);
+ SmtpWorker *worker = new SmtpWorker(
+ EmailQSLBase::getSmtpHost(),
+ EmailQSLBase::getSmtpPort(),
+ EmailQSLBase::getSmtpEncryption(),
+ EmailQSLBase::getSmtpUsername(),
+ EmailQSLBase::getSmtpPassword(),
+ EmailQSLBase::getFromAddress(),
+ EmailQSLBase::getFromName(),
+ toAddress,
+ subject, body,
+ imageData, imageName);
+
+ worker->moveToThread(thread);
+
+ connect(thread, &QThread::started, worker, &SmtpWorker::run);
+ connect(worker, &SmtpWorker::finished, this,
+ [this](bool ok, const QString &msg) { emit sendFinished(ok, msg); });
+ connect(worker, &SmtpWorker::finished, worker, &QObject::deleteLater);
+ connect(worker, &SmtpWorker::finished, thread, &QThread::quit);
+ connect(thread, &QThread::finished, thread, &QObject::deleteLater);
+
+ thread->start();
+}
+
+void EmailQSLService::testConnection(const QString &host, int port, int encryption,
+ const QString &username, const QString &password)
+{
+ FCT_IDENTIFICATION;
+
+ // Send a dummy message to a no-op address just to verify auth works —
+ // actually we just connect + EHLO + AUTH and then QUIT.
+ QThread *thread = new QThread(this);
+ SmtpWorker *worker = new SmtpWorker(
+ host, port, encryption, username, password,
+ username, QStringLiteral("Test"),
+ username, // to = from (won't actually be sent)
+ QStringLiteral("QLog connection test"),
+ QStringLiteral("Connection test only — no message will be sent."),
+ QByteArray(), QString());
+
+ worker->moveToThread(thread);
+
+ connect(thread, &QThread::started, worker, &SmtpWorker::run);
+ connect(worker, &SmtpWorker::finished, this,
+ [this](bool ok, const QString &msg) { emit testFinished(ok, msg); });
+ connect(worker, &SmtpWorker::finished, worker, &QObject::deleteLater);
+ connect(worker, &SmtpWorker::finished, thread, &QThread::quit);
+ connect(thread, &QThread::finished, thread, &QObject::deleteLater);
+
+ thread->start();
+}
diff --git a/service/emailqsl/EmailQSLService.h b/service/emailqsl/EmailQSLService.h
new file mode 100644
index 00000000..73a6f213
--- /dev/null
+++ b/service/emailqsl/EmailQSLService.h
@@ -0,0 +1,169 @@
+#ifndef QLOG_SERVICE_EMAILQSLSERVICE_H
+#define QLOG_SERVICE_EMAILQSLSERVICE_H
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "core/CredentialStore.h"
+
+// Describes a single overlay drawn on the QSL card image.
+// type == "TEXT" → renders a merge-field value as text
+// type == "BOX" → renders a filled rounded rectangle (label is optional caption)
+struct EmailQSLFieldOverlay
+{
+ // Common
+ QString type = QStringLiteral("TEXT"); // "TEXT" or "BOX"
+ QString fieldName; // TEXT: merge key; BOX: optional caption label
+ int x = 0;
+ int y = 0;
+
+ // TEXT-only
+ QString fontFamily = QStringLiteral("Arial");
+ int fontSize = 14;
+ QString color = QStringLiteral("#000000"); // text color / BOX border color
+ bool bold = false;
+ bool italic = false;
+
+ // BOX-only
+ int width = 120;
+ int height = 60;
+ QString fillColor = QStringLiteral("#FFFF99"); // box fill (no alpha — use opacity)
+ int opacity = 80; // fill opacity 0–100
+ int cornerRadius = 8;
+
+ QJsonObject toJson() const;
+ static EmailQSLFieldOverlay fromJson(const QJsonObject &obj);
+};
+
+// Static helpers for reading/writing all Email QSL settings.
+// The SMTP password is kept in the secure credential store;
+// everything else is persisted via LogParam (log_param DB table).
+class EmailQSLBase : public SecureServiceBase
+{
+public:
+ DECLARE_SECURE_SERVICE(EmailQSLBase)
+ static const QString SECURE_STORAGE_KEY;
+
+ enum EncryptionType
+ {
+ ENCRYPTION_NONE = 0,
+ ENCRYPTION_SSL_TLS = 1,
+ ENCRYPTION_STARTTLS = 2
+ };
+
+ // --- SMTP connection ---
+ static QString getSmtpHost();
+ static void setSmtpHost(const QString &host);
+ static int getSmtpPort();
+ static void setSmtpPort(int port);
+ static int getSmtpEncryption();
+ static void setSmtpEncryption(int enc);
+ static QString getSmtpUsername();
+ static void setSmtpUsername(const QString &username);
+ static QString getSmtpPassword();
+ static void saveSmtpCredentials(const QString &username, const QString &password);
+
+ // --- Envelope / headers ---
+ static QString getFromAddress();
+ static void setFromAddress(const QString &addr);
+ static QString getFromName();
+ static void setFromName(const QString &name);
+ static QString getSubjectTemplate();
+ static void setSubjectTemplate(const QString &tmpl);
+ static QString getBodyTemplate();
+ static void setBodyTemplate(const QString &tmpl);
+
+ // --- QSL card image & overlays ---
+ static QString getCardImagePath();
+ static void setCardImagePath(const QString &path);
+ static QList getCardFieldOverlays();
+ static void setCardFieldOverlays(const QList &overlays);
+
+ // --- Sent-tracking (stored in contacts.fields JSON) ---
+ static QDateTime getEmailSentDateTime(const QSqlRecord &record);
+ static bool hasEmailBeenSentToCallsign(const QString &callsign, int excludeId = -1);
+ static void recordEmailSent(int contactId, const QSqlRecord ¤tRecord);
+
+ // --- Rendering helpers ---
+ // Full render using saved settings from LogParam (used when sending).
+ static QPixmap renderCard(const QSqlRecord &record);
+ // Full render using an explicit image path and overlay list
+ // (used by settings preview so unsaved changes are shown).
+ static QPixmap renderCard(const QString &imagePath,
+ const QSqlRecord &record,
+ const QList &overlays);
+ static QString applyMergeFields(const QString &tmpl, const QSqlRecord &record);
+
+ // Available merge keys (for display in settings UI)
+ struct MergeField { QString key; QString description; };
+ static QList availableMergeFields();
+
+private:
+ static QString fieldValue(const QString &key, const QSqlRecord &record);
+};
+
+// Worker object that runs the SMTP protocol on a background thread.
+class SmtpWorker : public QObject
+{
+ Q_OBJECT
+public:
+ explicit SmtpWorker(const QString &host, int port, int encryption,
+ const QString &username, const QString &password,
+ const QString &fromAddress, const QString &fromName,
+ const QString &toAddress,
+ const QString &subject, const QString &body,
+ const QByteArray &imageData, const QString &imageName,
+ QObject *parent = nullptr);
+
+public slots:
+ void run();
+
+signals:
+ void finished(bool success, const QString &errorMessage);
+
+private:
+ bool waitForResponse(QSslSocket *socket, QByteArray &out, int timeoutMs = 15000);
+ int responseCode(const QByteArray &response);
+ bool sendCommand(QSslSocket *socket, const QByteArray &cmd, int expectedCode,
+ QByteArray &response);
+ QByteArray buildMimeMessage();
+
+ QString m_host;
+ int m_port;
+ int m_encryption;
+ QString m_username;
+ QString m_password;
+ QString m_fromAddress;
+ QString m_fromName;
+ QString m_toAddress;
+ QString m_subject;
+ QString m_body;
+ QByteArray m_imageData;
+ QString m_imageName;
+};
+
+// High-level service used by the UI. Call sendEmailQSL() and connect to
+// sendFinished() for result notification.
+class EmailQSLService : public QObject
+{
+ Q_OBJECT
+public:
+ explicit EmailQSLService(QObject *parent = nullptr);
+
+ void sendEmailQSL(const QSqlRecord &record);
+ void testConnection(const QString &host, int port, int encryption,
+ const QString &username, const QString &password);
+
+signals:
+ void sendFinished(bool success, const QString &message);
+ void testFinished(bool success, const QString &message);
+};
+
+#endif // QLOG_SERVICE_EMAILQSLSERVICE_H
diff --git a/ui/EmailQSLDialog.cpp b/ui/EmailQSLDialog.cpp
new file mode 100644
index 00000000..3eb4fe98
--- /dev/null
+++ b/ui/EmailQSLDialog.cpp
@@ -0,0 +1,234 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "EmailQSLDialog.h"
+#include "ui_EmailQSLDialog.h"
+#include "core/debug.h"
+
+MODULE_IDENTIFICATION("qlog.ui.emailqsldialog");
+
+EmailQSLDialog::EmailQSLDialog(const QSqlRecord &record, QWidget *parent)
+ : QDialog(parent),
+ ui(new Ui::EmailQSLDialog),
+ m_record(record),
+ m_service(new EmailQSLService(this))
+{
+ FCT_IDENTIFICATION;
+
+ ui->setupUi(this);
+
+ // Add action buttons next to Cancel
+ QPushButton *previewBtn = ui->buttonBox->addButton(tr("Preview Card…"), QDialogButtonBox::ActionRole);
+ connect(previewBtn, &QPushButton::clicked, this, &EmailQSLDialog::previewAndPrintCard);
+
+ QPushButton *sendBtn = ui->buttonBox->addButton(tr("Send"), QDialogButtonBox::AcceptRole);
+ connect(sendBtn, &QPushButton::clicked, this, &EmailQSLDialog::sendClicked);
+ connect(m_service, &EmailQSLService::sendFinished,
+ this, &EmailQSLDialog::onSendFinished);
+
+ populateInfo();
+ buildWarnings();
+}
+
+EmailQSLDialog::~EmailQSLDialog()
+{
+ delete ui;
+}
+
+void EmailQSLDialog::populateInfo()
+{
+ FCT_IDENTIFICATION;
+
+ const QString callsign = m_record.value(QStringLiteral("callsign")).toString().toUpper();
+ const QString email = m_record.value(QStringLiteral("email")).toString();
+ const QDateTime dt = m_record.value(QStringLiteral("start_time")).toDateTime().toUTC();
+ const QString band = m_record.value(QStringLiteral("band")).toString().toUpper();
+ const QString mode = m_record.value(QStringLiteral("mode")).toString().toUpper();
+
+ ui->callValueLabel->setText(callsign.isEmpty() ? tr("(unknown)") : callsign);
+ ui->emailValueLabel->setText(email.isEmpty()
+ ? tr("No email address on record")
+ : email);
+ ui->dateValueLabel->setText(dt.isValid() ? dt.toString(Qt::RFC2822Date) : tr("(unknown)"));
+ ui->bandModeValueLabel->setText(
+ QString("%1 / %2").arg(band.isEmpty() ? QStringLiteral("?") : band,
+ mode.isEmpty() ? QStringLiteral("?") : mode));
+
+ // Subject / body previews
+ ui->subjectPreviewLabel->setText(
+ EmailQSLBase::applyMergeFields(EmailQSLBase::getSubjectTemplate(), m_record));
+ ui->bodyPreviewEdit->setPlainText(
+ EmailQSLBase::applyMergeFields(EmailQSLBase::getBodyTemplate(), m_record));
+
+ // Card thumbnail
+ const QPixmap card = EmailQSLBase::renderCard(m_record);
+ if (!card.isNull())
+ ui->cardPreviewLabel->setPixmap(card.scaled(
+ ui->cardPreviewLabel->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation));
+ else
+ ui->cardPreviewLabel->setText(tr("No card image"));
+}
+
+void EmailQSLDialog::buildWarnings()
+{
+ FCT_IDENTIFICATION;
+
+ QStringList warnings;
+
+ // 1) No email address
+ const QString emailAddr = m_record.value(QStringLiteral("email")).toString().trimmed();
+ if (emailAddr.isEmpty())
+ {
+ warnings << tr("This contact has no email address — the message cannot be sent.");
+ // Disable the Send button
+ for (QAbstractButton *btn : ui->buttonBox->buttons())
+ if (ui->buttonBox->buttonRole(btn) == QDialogButtonBox::AcceptRole)
+ btn->setEnabled(false);
+ }
+
+ // 2) Already sent for this specific QSO
+ const QDateTime prevSent = EmailQSLBase::getEmailSentDateTime(m_record);
+ if (prevSent.isValid())
+ {
+ warnings << tr("An Email QSL was already sent for this QSO on %1 UTC.")
+ .arg(prevSent.toString(Qt::RFC2822Date));
+ }
+
+ // 3) Previously sent to the same callsign (different QSO)
+ const QString callsign = m_record.value(QStringLiteral("callsign")).toString().toUpper();
+ const int id = m_record.value(QStringLiteral("id")).toInt();
+ if (!callsign.isEmpty() && EmailQSLBase::hasEmailBeenSentToCallsign(callsign, id))
+ {
+ warnings << tr("Note: you have previously sent an Email QSL to %1 for a different QSO.")
+ .arg(callsign);
+ }
+
+ // 4) No SMTP host configured
+ if (EmailQSLBase::getSmtpHost().isEmpty())
+ warnings << tr("SMTP server is not configured. Go to Settings → Email QSL.");
+
+ if (!warnings.isEmpty())
+ ui->warningLabel->setText(warnings.join(QStringLiteral("\n")));
+}
+
+void EmailQSLDialog::sendClicked()
+{
+ FCT_IDENTIFICATION;
+
+ // Disable buttons while sending
+ for (QAbstractButton *btn : ui->buttonBox->buttons())
+ btn->setEnabled(false);
+
+ ui->statusLabel->setText(tr("Sending…"));
+
+ m_service->sendEmailQSL(m_record);
+}
+
+void EmailQSLDialog::onSendFinished(bool success, const QString &message)
+{
+ FCT_IDENTIFICATION;
+
+ if (success)
+ {
+ // Record the sent timestamp in contacts.fields
+ const int id = m_record.value(QStringLiteral("id")).toInt();
+ EmailQSLBase::recordEmailSent(id, m_record);
+
+ // Auto-close — the log table refresh is handled by the finished() signal
+ accept();
+ }
+ else
+ {
+ ui->statusLabel->setStyleSheet(QStringLiteral("color: red; font-weight: bold;"));
+ ui->statusLabel->setText(tr("Send failed: %1").arg(message));
+
+ // Rename Cancel → Close and disable Send so the user can only dismiss
+ for (QAbstractButton *btn : ui->buttonBox->buttons())
+ {
+ const QDialogButtonBox::ButtonRole role = ui->buttonBox->buttonRole(btn);
+ if (role == QDialogButtonBox::RejectRole)
+ {
+ btn->setText(tr("Close"));
+ btn->setEnabled(true);
+ }
+ else
+ {
+ btn->setEnabled(false); // keep Send / Preview disabled
+ }
+ }
+ }
+}
+
+void EmailQSLDialog::previewAndPrintCard()
+{
+ FCT_IDENTIFICATION;
+
+ const QPixmap card = EmailQSLBase::renderCard(m_record);
+ if (card.isNull())
+ {
+ QMessageBox::warning(this, tr("Preview"),
+ tr("Could not render the card image.\n"
+ "Please check Settings → Email QSL and make sure a card image is selected."));
+ return;
+ }
+
+ QDialog *dlg = new QDialog(this);
+ dlg->setAttribute(Qt::WA_DeleteOnClose);
+ dlg->setWindowTitle(tr("Card Preview — %1")
+ .arg(m_record.value(QStringLiteral("callsign")).toString().toUpper()));
+ QVBoxLayout *lay = new QVBoxLayout(dlg);
+
+ QScrollArea *scroll = new QScrollArea(dlg);
+ QLabel *lbl = new QLabel(scroll);
+ const QPixmap scaled = card.scaled(QSize(800, 600), Qt::KeepAspectRatio, Qt::SmoothTransformation);
+ lbl->setPixmap(scaled);
+ lbl->adjustSize();
+ scroll->setWidget(lbl);
+ scroll->setMinimumSize(qMin(scaled.width() + 20, 820),
+ qMin(scaled.height() + 20, 620));
+ lay->addWidget(scroll);
+
+ QDialogButtonBox *bb = new QDialogButtonBox(QDialogButtonBox::Close, dlg);
+ QPushButton *saveBtn = bb->addButton(tr("Save Card…"), QDialogButtonBox::ActionRole);
+
+ connect(saveBtn, &QPushButton::clicked, dlg, [card, this]()
+ {
+ const QString callsign = m_record.value(QStringLiteral("callsign")).toString().toUpper();
+ const QString rawDate = m_record.value(QStringLiteral("start_time")).toString();
+ const QDateTime dt = QDateTime::fromString(rawDate, Qt::ISODate);
+ const QString date = dt.isValid() ? dt.toUTC().toString(QStringLiteral("yyyyMMdd")) : QStringLiteral("date");
+ const QString time = dt.isValid() ? dt.toUTC().toString(QStringLiteral("HHmm")) : QStringLiteral("time");
+ const QString band = m_record.value(QStringLiteral("band")).toString().toUpper().replace(QLatin1Char(' '), QLatin1Char('_'));
+ const QString mode = m_record.value(QStringLiteral("mode")).toString().toUpper();
+ const QString defaultName = QStringLiteral("QSL_%1_%2_%3_%4_%5.png")
+ .arg(callsign, date, time, band, mode);
+ const QString defaultDir = QStandardPaths::writableLocation(QStandardPaths::PicturesLocation);
+ const QString path = QFileDialog::getSaveFileName(
+ this,
+ tr("Save QSL Card"),
+ defaultDir + QStringLiteral("/") + defaultName,
+ tr("PNG Image (*.png);;JPEG Image (*.jpg *.jpeg)"));
+
+ if (path.isEmpty())
+ return;
+
+ if (!card.save(path))
+ {
+ QMessageBox::warning(this, tr("Save Failed"),
+ tr("Could not save the card image to:\n%1").arg(path));
+ }
+ });
+
+ connect(bb, &QDialogButtonBox::rejected, dlg, &QDialog::close);
+ lay->addWidget(bb);
+ dlg->show(); // non-modal
+}
diff --git a/ui/EmailQSLDialog.h b/ui/EmailQSLDialog.h
new file mode 100644
index 00000000..c2a609bb
--- /dev/null
+++ b/ui/EmailQSLDialog.h
@@ -0,0 +1,37 @@
+#ifndef QLOG_UI_EMAILQSLDIALOG_H
+#define QLOG_UI_EMAILQSLDIALOG_H
+
+#include
+#include
+
+#include "service/emailqsl/EmailQSLService.h"
+
+namespace Ui {
+class EmailQSLDialog;
+}
+
+// Shown before sending an Email QSL. Displays contact details, the rendered
+// card thumbnail, warnings about duplicate sends, and lets the user confirm.
+class EmailQSLDialog : public QDialog
+{
+ Q_OBJECT
+
+public:
+ explicit EmailQSLDialog(const QSqlRecord &record, QWidget *parent = nullptr);
+ ~EmailQSLDialog();
+
+private slots:
+ void sendClicked();
+ void onSendFinished(bool success, const QString &message);
+ void previewAndPrintCard();
+
+private:
+ void populateInfo();
+ void buildWarnings();
+
+ Ui::EmailQSLDialog *ui;
+ QSqlRecord m_record;
+ EmailQSLService *m_service;
+};
+
+#endif // QLOG_UI_EMAILQSLDIALOG_H
diff --git a/ui/EmailQSLDialog.ui b/ui/EmailQSLDialog.ui
new file mode 100644
index 00000000..1489fbae
--- /dev/null
+++ b/ui/EmailQSLDialog.ui
@@ -0,0 +1,135 @@
+
+
+ EmailQSLDialog
+
+
+ 00520500
+
+
+ Send Email QSL Card
+
+
+
+
+ -
+
+
-
+
+ 200130
+ 260170
+ QFrame::Box
+ Qt::AlignCenter
+ No card image
+ true
+
+
+ -
+
+
+ QSO Details
+
+
-
+ Callsign:
+
+ -
+
+
+ -
+ To address:
+
+ -
+
+
+ true
+
+
+ -
+ Date / Time:
+
+ -
+
+
+ -
+ Band / Mode:
+
+ -
+
+
+
+
+
+
+
+
+
+ -
+
+
+ true
+
+ QLabel { color: #cc6600; font-weight: bold; }
+
+
+
+
+
+ -
+
+ Subject
+
+
-
+
+
+ true
+
+
+
+
+
+
+
+ -
+
+ Email Body (preview)
+
+
-
+
+ true
+ 0100
+
+
+
+
+
+
+
+ -
+
+
+ Qt::AlignCenter
+
+
+
+
+ -
+
+
+ QDialogButtonBox::Cancel
+
+
+
+
+
+
+
+
+
+ buttonBoxrejected()
+ EmailQSLDialogreject()
+
+ 260480
+ 260240
+
+
+
+
diff --git a/ui/EmailQSLSettingsWidget.cpp b/ui/EmailQSLSettingsWidget.cpp
new file mode 100644
index 00000000..0163da9a
--- /dev/null
+++ b/ui/EmailQSLSettingsWidget.cpp
@@ -0,0 +1,1051 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "EmailQSLSettingsWidget.h"
+#include "ui_EmailQSLSettingsWidget.h"
+#include "service/emailqsl/EmailQSLService.h"
+#include "ui/component/CardEditorWidget.h"
+#include "core/debug.h"
+
+MODULE_IDENTIFICATION("qlog.ui.emailqslsettingswidget");
+
+// ---------------------------------------------------------------------------
+// Column indices for the overlay table
+// TEXT columns: TYPE FIELD X Y FONT SIZE COLOR BOLD ITALIC (BOX cols greyed)
+// BOX columns: TYPE FIELD X Y W H FILL OPACITY RADIUS BORDER
+// We use one wide model and show/grey non-applicable cells per row.
+// ---------------------------------------------------------------------------
+enum OverlayCol
+{
+ OC_TYPE = 0,
+ OC_FIELD = 1, // TEXT: merge key; BOX: optional caption
+ OC_X = 2,
+ OC_Y = 3,
+ OC_FONT = 4, // TEXT only
+ OC_SIZE = 5, // TEXT only (also used for BOX caption font size)
+ OC_COLOR = 6, // TEXT: text color; BOX: border color
+ OC_BOLD = 7, // TEXT only
+ OC_ITALIC = 8, // TEXT only
+ OC_W = 9, // BOX only
+ OC_H = 10, // BOX only
+ OC_FILL = 11, // BOX only
+ OC_OPACITY = 12, // BOX only
+ OC_RADIUS = 13, // BOX only
+ OC_COUNT = 14
+};
+
+// ---------------------------------------------------------------------------
+// Local delegate: merge-field combobox for the Field column
+// ---------------------------------------------------------------------------
+// ---------------------------------------------------------------------------
+// Delegate: Type combo (TEXT / BOX)
+// ---------------------------------------------------------------------------
+class TypeDelegate : public QStyledItemDelegate
+{
+public:
+ using QStyledItemDelegate::QStyledItemDelegate;
+ QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &,
+ const QModelIndex &) const override
+ {
+ QComboBox *cb = new QComboBox(parent);
+ cb->addItem(QStringLiteral("TEXT"));
+ cb->addItem(QStringLiteral("BOX"));
+ cb->addItem(QStringLiteral("LABEL"));
+ return cb;
+ }
+ void setEditorData(QWidget *editor, const QModelIndex &index) const override
+ {
+ QComboBox *cb = static_cast(editor);
+ cb->setCurrentIndex(cb->findText(index.data(Qt::DisplayRole).toString()));
+ }
+ void setModelData(QWidget *editor, QAbstractItemModel *model,
+ const QModelIndex &index) const override
+ {
+ model->setData(index, static_cast(editor)->currentText(), Qt::EditRole);
+ }
+ void updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option,
+ const QModelIndex &) const override
+ { editor->setGeometry(option.rect); }
+};
+
+// ---------------------------------------------------------------------------
+// Delegate: merge-field combobox (TEXT Field column)
+// ---------------------------------------------------------------------------
+class MergeFieldDelegate : public QStyledItemDelegate
+{
+public:
+ using QStyledItemDelegate::QStyledItemDelegate;
+ QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &,
+ const QModelIndex &) const override
+ {
+ QComboBox *cb = new QComboBox(parent);
+ cb->setEditable(true); // also allow free text (for BOX captions)
+ for (const EmailQSLBase::MergeField &f : EmailQSLBase::availableMergeFields())
+ cb->addItem(f.key);
+ return cb;
+ }
+ void setEditorData(QWidget *editor, const QModelIndex &index) const override
+ {
+ QComboBox *cb = static_cast(editor);
+ const QString v = index.data(Qt::DisplayRole).toString();
+ const int idx = cb->findText(v);
+ if (idx >= 0) cb->setCurrentIndex(idx);
+ else cb->setEditText(v);
+ }
+ void setModelData(QWidget *editor, QAbstractItemModel *model,
+ const QModelIndex &index) const override
+ {
+ model->setData(index, static_cast(editor)->currentText(), Qt::EditRole);
+ }
+ void updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option,
+ const QModelIndex &) const override
+ { editor->setGeometry(option.rect); }
+};
+
+// ---------------------------------------------------------------------------
+// Delegate: QFontComboBox for the Font column
+// ---------------------------------------------------------------------------
+class FontFamilyDelegate : public QStyledItemDelegate
+{
+public:
+ using QStyledItemDelegate::QStyledItemDelegate;
+ QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &,
+ const QModelIndex &) const override
+ { return new QFontComboBox(parent); }
+ void setEditorData(QWidget *editor, const QModelIndex &index) const override
+ {
+ static_cast(editor)->setCurrentFont(
+ QFont(index.data(Qt::DisplayRole).toString()));
+ }
+ void setModelData(QWidget *editor, QAbstractItemModel *model,
+ const QModelIndex &index) const override
+ {
+ model->setData(index,
+ static_cast(editor)->currentFont().family(), Qt::EditRole);
+ }
+ void updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option,
+ const QModelIndex &) const override
+ { editor->setGeometry(option.rect); }
+};
+
+// ---------------------------------------------------------------------------
+// Delegate: colored swatch — double-click opens QColorDialog
+// ---------------------------------------------------------------------------
+class ColorSwatchDelegate : public QStyledItemDelegate
+{
+public:
+ using QStyledItemDelegate::QStyledItemDelegate;
+
+ void paint(QPainter *painter, const QStyleOptionViewItem &option,
+ const QModelIndex &index) const override
+ {
+ const QString hex = index.data(Qt::DisplayRole).toString();
+ const QColor c(hex.isEmpty() ? QStringLiteral("#000000") : hex);
+ const QRect r = option.rect.adjusted(2, 2, -2, -2);
+ painter->fillRect(r, c);
+ painter->setPen(QPen(Qt::gray, 1));
+ painter->drawRect(r);
+ painter->setPen(c.lightness() > 128 ? Qt::black : Qt::white);
+ QFont f = painter->font(); f.setPointSize(8); painter->setFont(f);
+ painter->drawText(option.rect, Qt::AlignCenter, hex);
+ }
+
+ QWidget *createEditor(QWidget *, const QStyleOptionViewItem &,
+ const QModelIndex &) const override { return nullptr; }
+
+ bool editorEvent(QEvent *event, QAbstractItemModel *model,
+ const QStyleOptionViewItem &, const QModelIndex &index) override
+ {
+ if (event->type() != QEvent::MouseButtonDblClick) return false;
+ const QString cur = index.data(Qt::DisplayRole).toString();
+ const QColor chosen = QColorDialog::getColor(
+ QColor(cur.isEmpty() ? QStringLiteral("#000000") : cur),
+ nullptr, tr("Choose Color"));
+ if (chosen.isValid())
+ model->setData(index, chosen.name(), Qt::EditRole);
+ return true;
+ }
+};
+
+// ---------------------------------------------------------------------------
+// Delegate: compact QSpinBox — editor width constrained to cell
+// ---------------------------------------------------------------------------
+class SpinBoxDelegate : public QStyledItemDelegate
+{
+ int m_min, m_max;
+public:
+ SpinBoxDelegate(int min, int max, QObject *parent)
+ : QStyledItemDelegate(parent), m_min(min), m_max(max) {}
+
+ QWidget *createEditor(QWidget *parent, const QStyleOptionViewItem &option,
+ const QModelIndex &) const override
+ {
+ QSpinBox *sb = new QSpinBox(parent);
+ sb->setRange(m_min, m_max);
+ // Constrain width so the spinbox doesn't expand beyond the cell
+ sb->setFixedWidth(option.rect.width());
+ sb->setButtonSymbols(QAbstractSpinBox::UpDownArrows);
+ return sb;
+ }
+ void setEditorData(QWidget *editor, const QModelIndex &index) const override
+ {
+ static_cast(editor)->setValue(index.data(Qt::DisplayRole).toInt());
+ }
+ void setModelData(QWidget *editor, QAbstractItemModel *model,
+ const QModelIndex &index) const override
+ {
+ model->setData(index, static_cast(editor)->value(), Qt::EditRole);
+ }
+ void updateEditorGeometry(QWidget *editor, const QStyleOptionViewItem &option,
+ const QModelIndex &) const override
+ { editor->setGeometry(option.rect); }
+};
+
+// ---------------------------------------------------------------------------
+// SMTP presets
+// ---------------------------------------------------------------------------
+struct SmtpPreset
+{
+ QString label;
+ QString host;
+ int port;
+ int encryption;
+ QString hint;
+};
+
+static QList smtpPresets()
+{
+ return {
+ { QObject::tr("-- Select preset --"), {}, 587, 2, {} },
+ { QStringLiteral("Gmail"),
+ QStringLiteral("smtp.gmail.com"), 587, 2,
+ QObject::tr("Gmail: go to myaccount.google.com → Security → 2-Step Verification → App Passwords.\n"
+ "Create an App Password for 'Mail'. Use the 16-character code as your password here.") },
+ { QStringLiteral("Gmail (SSL/TLS port 465)"),
+ QStringLiteral("smtp.gmail.com"), 465, 1,
+ QObject::tr("Gmail SSL variant (port 465). Use your Gmail App Password.") },
+ { QStringLiteral("Outlook / Hotmail / Microsoft 365"),
+ QStringLiteral("smtp-mail.outlook.com"), 587, 2,
+ QObject::tr("Outlook: use your full Microsoft email and password.\n"
+ "If MFA is on, generate an App Password at account.microsoft.com → Security.") },
+ { QStringLiteral("Yahoo Mail"),
+ QStringLiteral("smtp.mail.yahoo.com"), 587, 2,
+ QObject::tr("Yahoo: go to Account Security and click 'Generate app password'.") },
+ { QStringLiteral("iCloud Mail"),
+ QStringLiteral("smtp.mail.me.com"), 587, 2,
+ QObject::tr("iCloud: generate an App-Specific Password at appleid.apple.com → Sign-In and Security.") },
+ { QStringLiteral("Zoho Mail"),
+ QStringLiteral("smtp.zoho.com"), 587, 2,
+ QObject::tr("Zoho: enable SMTP in Zoho Mail Settings → Mail Accounts → IMAP/POP/SMTP.") },
+ { QStringLiteral("Custom"), {}, 587, 2, {} },
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Helper — build a dummy QSqlRecord for previewing merge fields
+// ---------------------------------------------------------------------------
+static QSqlRecord buildDummyRecord()
+{
+ static const QStringList cols = {
+ "callsign","start_time","freq","band","mode","submode",
+ "rst_sent","rst_rcvd","name_intl","name","qth_intl","qth",
+ "country_intl","country","gridsquare","dxcc","cqz","ituz",
+ "tx_pwr","email","station_callsign","my_gridsquare","operator",
+ "comment_intl","comment","sota_ref","pota_ref","wwff_ref",
+ "iota","sig_intl","contest_id"
+ };
+ QSqlRecord r;
+ for (const QString &c : cols)
+ {
+ QSqlField f(c, QVariant::String);
+ r.append(f);
+ }
+ r.setValue("callsign", QStringLiteral("W1AW"));
+ r.setValue("start_time", QDateTime::currentDateTimeUtc().toString(Qt::ISODate));
+ r.setValue("freq", QStringLiteral("14.225"));
+ r.setValue("band", QStringLiteral("20M"));
+ r.setValue("mode", QStringLiteral("SSB"));
+ r.setValue("rst_sent", QStringLiteral("59"));
+ r.setValue("rst_rcvd", QStringLiteral("59"));
+ r.setValue("name_intl", QStringLiteral("ARRL HQ"));
+ r.setValue("station_callsign", QStringLiteral("AA5SH"));
+ r.setValue("my_gridsquare", QStringLiteral("EM20"));
+ r.setValue("gridsquare", QStringLiteral("FN31"));
+ r.setValue("country_intl", QStringLiteral("United States"));
+ return r;
+}
+
+// ---------------------------------------------------------------------------
+// Helper — build a default overlay
+// ---------------------------------------------------------------------------
+static EmailQSLFieldOverlay makeOverlay(const QString &field,
+ int x, int y,
+ int fontSize,
+ bool bold,
+ const QString &color = QStringLiteral("#000000"),
+ const QString &font = QStringLiteral("Arial"))
+{
+ EmailQSLFieldOverlay ov;
+ ov.fieldName = field;
+ ov.x = x;
+ ov.y = y;
+ ov.fontSize = fontSize;
+ ov.bold = bold;
+ ov.fontFamily = font;
+ ov.color = color;
+ return ov;
+}
+
+// ---------------------------------------------------------------------------
+// EmailQSLSettingsWidget
+// ---------------------------------------------------------------------------
+
+EmailQSLSettingsWidget::EmailQSLSettingsWidget(QWidget *parent)
+ : QWidget(parent),
+ ui(new Ui::EmailQSLSettingsWidget),
+ m_overlayModel(new QStandardItemModel(0, OC_COUNT, this)),
+ m_testService(new EmailQSLService(this)),
+ m_syncing(false)
+{
+ FCT_IDENTIFICATION;
+
+ ui->setupUi(this);
+
+ // ---- Overlay table ----
+ m_overlayModel->setHorizontalHeaderLabels({
+ tr("Type"), tr("Field / Caption"),
+ tr("X"), tr("Y"),
+ tr("Font"), tr("Pt"),
+ tr("Color"), tr("B"), tr("I"),
+ tr("W"), tr("H"), tr("Fill"), tr("Opacity%"), tr("Radius")
+ });
+ ui->overlayTableView->setModel(m_overlayModel);
+
+ // Delegates
+ auto *spinCoord = new SpinBoxDelegate(0, 99999, this);
+ auto *spinSz = new SpinBoxDelegate(4, 300, this);
+ auto *spinDim = new SpinBoxDelegate(1, 99999, this);
+ auto *spinOpac = new SpinBoxDelegate(0, 100, this);
+ auto *spinRadius = new SpinBoxDelegate(0, 500, this);
+
+ ui->overlayTableView->setItemDelegateForColumn(OC_TYPE, new TypeDelegate(this));
+ ui->overlayTableView->setItemDelegateForColumn(OC_FIELD, new MergeFieldDelegate(this));
+ ui->overlayTableView->setItemDelegateForColumn(OC_X, spinCoord);
+ ui->overlayTableView->setItemDelegateForColumn(OC_Y, spinCoord);
+ ui->overlayTableView->setItemDelegateForColumn(OC_FONT, new FontFamilyDelegate(this));
+ ui->overlayTableView->setItemDelegateForColumn(OC_SIZE, spinSz);
+ ui->overlayTableView->setItemDelegateForColumn(OC_COLOR, new ColorSwatchDelegate(this));
+ ui->overlayTableView->setItemDelegateForColumn(OC_W, spinDim);
+ ui->overlayTableView->setItemDelegateForColumn(OC_H, spinDim);
+ ui->overlayTableView->setItemDelegateForColumn(OC_FILL, new ColorSwatchDelegate(this));
+ ui->overlayTableView->setItemDelegateForColumn(OC_OPACITY, spinOpac);
+ ui->overlayTableView->setItemDelegateForColumn(OC_RADIUS, spinRadius);
+
+ // Column widths — fixed-width numeric columns stay compact
+ auto *hdr = ui->overlayTableView->horizontalHeader();
+ hdr->setSectionResizeMode(OC_TYPE, QHeaderView::ResizeToContents);
+ hdr->setSectionResizeMode(OC_FIELD, QHeaderView::Stretch);
+ hdr->setSectionResizeMode(OC_X, QHeaderView::Fixed);
+ hdr->setSectionResizeMode(OC_Y, QHeaderView::Fixed);
+ hdr->setSectionResizeMode(OC_FONT, QHeaderView::Stretch);
+ hdr->setSectionResizeMode(OC_SIZE, QHeaderView::Fixed);
+ hdr->setSectionResizeMode(OC_COLOR, QHeaderView::Fixed);
+ hdr->setSectionResizeMode(OC_BOLD, QHeaderView::Fixed);
+ hdr->setSectionResizeMode(OC_ITALIC, QHeaderView::Fixed);
+ hdr->setSectionResizeMode(OC_W, QHeaderView::Fixed);
+ hdr->setSectionResizeMode(OC_H, QHeaderView::Fixed);
+ hdr->setSectionResizeMode(OC_FILL, QHeaderView::Fixed);
+ hdr->setSectionResizeMode(OC_OPACITY, QHeaderView::Fixed);
+ hdr->setSectionResizeMode(OC_RADIUS, QHeaderView::Fixed);
+ // Set pixel widths for fixed columns
+ ui->overlayTableView->setColumnWidth(OC_X, 55);
+ ui->overlayTableView->setColumnWidth(OC_Y, 55);
+ ui->overlayTableView->setColumnWidth(OC_SIZE, 40);
+ ui->overlayTableView->setColumnWidth(OC_COLOR, 60);
+ ui->overlayTableView->setColumnWidth(OC_BOLD, 24);
+ ui->overlayTableView->setColumnWidth(OC_ITALIC, 24);
+ ui->overlayTableView->setColumnWidth(OC_W, 55);
+ ui->overlayTableView->setColumnWidth(OC_H, 55);
+ ui->overlayTableView->setColumnWidth(OC_FILL, 60);
+ ui->overlayTableView->setColumnWidth(OC_OPACITY, 55);
+ ui->overlayTableView->setColumnWidth(OC_RADIUS, 50);
+
+ // ---- Merge field reference table ----
+ const auto fields = EmailQSLBase::availableMergeFields();
+ ui->mergeFieldRefTable->setRowCount(fields.size());
+ for (int i = 0; i < fields.size(); ++i)
+ {
+ ui->mergeFieldRefTable->setItem(
+ i, 0, new QTableWidgetItem(QLatin1Char('{') + fields[i].key + QLatin1Char('}')));
+ ui->mergeFieldRefTable->setItem(
+ i, 1, new QTableWidgetItem(fields[i].description));
+ }
+ ui->mergeFieldRefTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
+ ui->mergeFieldRefTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch);
+
+ // ---- Merge field combos (subject/body insert) ----
+ for (const EmailQSLBase::MergeField &f : fields)
+ {
+ const QString display = QLatin1Char('{') + f.key + QLatin1Char('}');
+ ui->bodyFieldCombo->addItem(display);
+ ui->subjectFieldCombo->addItem(display);
+ }
+
+ // ---- SMTP presets ----
+ ui->smtpPresetCombo->blockSignals(true);
+ for (const SmtpPreset &p : smtpPresets())
+ ui->smtpPresetCombo->addItem(p.label);
+ ui->smtpPresetCombo->blockSignals(false);
+
+ // ---- Connections ----
+ connect(ui->browseCardImageButton, &QPushButton::clicked,
+ this, &EmailQSLSettingsWidget::browseCardImage);
+ connect(ui->cardImagePathEdit, &QLineEdit::textChanged,
+ this, &EmailQSLSettingsWidget::onCardImageChanged);
+
+ connect(ui->smtpPresetCombo, QOverload::of(&QComboBox::currentIndexChanged),
+ this, &EmailQSLSettingsWidget::onSmtpPresetChanged);
+ connect(ui->testConnectionButton, &QPushButton::clicked,
+ this, &EmailQSLSettingsWidget::testConnection);
+ connect(m_testService, &EmailQSLService::testFinished,
+ this, &EmailQSLSettingsWidget::onTestFinished);
+
+ connect(ui->addOverlayButton, &QPushButton::clicked,
+ this, &EmailQSLSettingsWidget::addOverlay);
+ connect(ui->addBoxButton, &QPushButton::clicked,
+ this, &EmailQSLSettingsWidget::addBox);
+ connect(ui->addLabelButton, &QPushButton::clicked,
+ this, &EmailQSLSettingsWidget::addLabel);
+ connect(ui->addDefaultFieldsButton, &QPushButton::clicked,
+ this, &EmailQSLSettingsWidget::addDefaultOverlays);
+ connect(ui->removeOverlayButton, &QPushButton::clicked,
+ this, &EmailQSLSettingsWidget::removeOverlay);
+ connect(ui->previewCardButton, &QPushButton::clicked,
+ this, &EmailQSLSettingsWidget::previewCard);
+
+ connect(ui->insertBodyFieldButton, &QPushButton::clicked,
+ this, &EmailQSLSettingsWidget::insertMergeFieldBody);
+ connect(ui->insertSubjectFieldButton, &QPushButton::clicked,
+ this, &EmailQSLSettingsWidget::insertMergeFieldSubject);
+
+ // Table ↔ editor bidirectional sync
+ connect(m_overlayModel, &QStandardItemModel::dataChanged,
+ this, &EmailQSLSettingsWidget::onTableDataChanged);
+ connect(m_overlayModel, &QStandardItemModel::rowsRemoved,
+ this, &EmailQSLSettingsWidget::onTableDataChanged);
+
+ connect(ui->cardEditorWidget, &CardEditorWidget::overlayPositionChanged,
+ this, &EmailQSLSettingsWidget::onEditorPositionChanged);
+ connect(ui->cardEditorWidget, &CardEditorWidget::overlaySizeChanged,
+ this, &EmailQSLSettingsWidget::onEditorSizeChanged);
+ connect(ui->cardEditorWidget, &CardEditorWidget::overlaySelected,
+ this, &EmailQSLSettingsWidget::onEditorOverlaySelected);
+
+ connect(ui->overlayTableView->selectionModel(),
+ &QItemSelectionModel::currentRowChanged,
+ this, [this](const QModelIndex ¤t, const QModelIndex &) {
+ ui->cardEditorWidget->setSelectedIndex(
+ current.isValid() ? current.row() : -1);
+ ui->removeOverlayButton->setEnabled(current.isValid());
+ });
+}
+
+EmailQSLSettingsWidget::~EmailQSLSettingsWidget()
+{
+ delete ui;
+}
+
+// ---------------------------------------------------------------------------
+// readSettings / writeSettings
+// ---------------------------------------------------------------------------
+
+void EmailQSLSettingsWidget::readSettings()
+{
+ FCT_IDENTIFICATION;
+
+ ui->smtpHostEdit->setText(EmailQSLBase::getSmtpHost());
+ ui->smtpPortSpin->setValue(EmailQSLBase::getSmtpPort());
+ ui->encryptionCombo->setCurrentIndex(EmailQSLBase::getSmtpEncryption());
+ ui->smtpUsernameEdit->setText(EmailQSLBase::getSmtpUsername());
+ ui->smtpPasswordEdit->setText(EmailQSLBase::getSmtpPassword());
+ ui->fromAddressEdit->setText(EmailQSLBase::getFromAddress());
+ ui->fromNameEdit->setText(EmailQSLBase::getFromName());
+ ui->subjectTemplateEdit->setText(EmailQSLBase::getSubjectTemplate());
+ ui->bodyTemplateEdit->setPlainText(EmailQSLBase::getBodyTemplate());
+ ui->cardImagePathEdit->setText(EmailQSLBase::getCardImagePath());
+
+ m_overlays = EmailQSLBase::getCardFieldOverlays();
+ listToOverlayTable();
+
+ // Trigger image load into the editor (textChanged may not fire if value unchanged)
+ onCardImageChanged(ui->cardImagePathEdit->text());
+}
+
+void EmailQSLSettingsWidget::writeSettings()
+{
+ FCT_IDENTIFICATION;
+
+ EmailQSLBase::setSmtpHost(ui->smtpHostEdit->text().trimmed());
+ EmailQSLBase::setSmtpPort(ui->smtpPortSpin->value());
+ EmailQSLBase::setSmtpEncryption(ui->encryptionCombo->currentIndex());
+ EmailQSLBase::saveSmtpCredentials(ui->smtpUsernameEdit->text().trimmed(),
+ ui->smtpPasswordEdit->text());
+ EmailQSLBase::setFromAddress(ui->fromAddressEdit->text().trimmed());
+ EmailQSLBase::setFromName(ui->fromNameEdit->text().trimmed());
+ EmailQSLBase::setSubjectTemplate(ui->subjectTemplateEdit->text());
+ EmailQSLBase::setBodyTemplate(ui->bodyTemplateEdit->toPlainText());
+ EmailQSLBase::setCardImagePath(ui->cardImagePathEdit->text().trimmed());
+
+ overlayTableToList();
+ EmailQSLBase::setCardFieldOverlays(m_overlays);
+}
+
+// ---------------------------------------------------------------------------
+// Overlay table ↔ m_overlays ↔ card editor
+// ---------------------------------------------------------------------------
+
+// Helper — make a greyed-out non-editable placeholder for N/A cells
+static QStandardItem *naItem()
+{
+ QStandardItem *si = new QStandardItem(QStringLiteral("—"));
+ si->setFlags(si->flags() & ~Qt::ItemIsEditable);
+ si->setForeground(QColor(0xbb, 0xbb, 0xbb));
+ si->setTextAlignment(Qt::AlignCenter);
+ return si;
+}
+
+void EmailQSLSettingsWidget::listToOverlayTable()
+{
+ // Guard against re-entrant onTableDataChanged() firing during setRowCount(0)
+ // which would wipe m_overlays before we finish rebuilding the table.
+ m_syncing = true;
+ m_overlayModel->setRowCount(0);
+ for (const EmailQSLFieldOverlay &ov : m_overlays)
+ {
+ const bool isBox = (ov.type == QLatin1String("BOX"));
+
+ auto item = [](const QString &text) { return new QStandardItem(text); };
+ auto checkItem = [](bool checked) {
+ QStandardItem *si = new QStandardItem();
+ si->setCheckable(true);
+ si->setCheckState(checked ? Qt::Checked : Qt::Unchecked);
+ si->setTextAlignment(Qt::AlignCenter);
+ return si;
+ };
+
+ QList row;
+ row << item(ov.type); // OC_TYPE
+ row << item(ov.fieldName); // OC_FIELD
+ row << item(QString::number(ov.x)); // OC_X
+ row << item(QString::number(ov.y)); // OC_Y
+ // TEXT-only
+ row << (isBox ? naItem() : item(ov.fontFamily)); // OC_FONT
+ row << (isBox && ov.fieldName.isEmpty()
+ ? naItem() : item(QString::number(ov.fontSize))); // OC_SIZE
+ row << item(ov.color); // OC_COLOR (border/text)
+ row << (isBox ? naItem() : checkItem(ov.bold)); // OC_BOLD
+ row << (isBox ? naItem() : checkItem(ov.italic)); // OC_ITALIC
+ // BOX-only
+ row << (isBox ? item(QString::number(ov.width)) : naItem()); // OC_W
+ row << (isBox ? item(QString::number(ov.height)) : naItem()); // OC_H
+ row << (isBox ? item(ov.fillColor) : naItem()); // OC_FILL
+ row << (isBox ? item(QString::number(ov.opacity)): naItem()); // OC_OPACITY
+ row << (isBox ? item(QString::number(ov.cornerRadius)) : naItem()); // OC_RADIUS
+
+ m_overlayModel->appendRow(row);
+ }
+ m_syncing = false;
+ ui->cardEditorWidget->setOverlays(m_overlays);
+}
+
+void EmailQSLSettingsWidget::overlayTableToList()
+{
+ m_overlays.clear();
+ for (int r = 0; r < m_overlayModel->rowCount(); ++r)
+ {
+ EmailQSLFieldOverlay ov;
+ auto txt = [&](int col) { return m_overlayModel->item(r, col)->text(); };
+
+ ov.type = txt(OC_TYPE);
+ ov.fieldName = txt(OC_FIELD);
+ ov.x = txt(OC_X).toInt();
+ ov.y = txt(OC_Y).toInt();
+ ov.color = txt(OC_COLOR);
+
+ if (ov.type == QLatin1String("BOX"))
+ {
+ ov.fontFamily = QStringLiteral("Arial");
+ ov.fontSize = txt(OC_SIZE) == QStringLiteral("—") ? 11 : txt(OC_SIZE).toInt();
+ ov.width = txt(OC_W).toInt();
+ ov.height = txt(OC_H).toInt();
+ ov.fillColor = txt(OC_FILL);
+ ov.opacity = txt(OC_OPACITY).toInt();
+ ov.cornerRadius = txt(OC_RADIUS).toInt();
+ }
+ else
+ {
+ ov.fontFamily = txt(OC_FONT);
+ ov.fontSize = txt(OC_SIZE).toInt();
+ ov.bold = m_overlayModel->item(r, OC_BOLD)->checkState() == Qt::Checked;
+ ov.italic = m_overlayModel->item(r, OC_ITALIC)->checkState() == Qt::Checked;
+ }
+ m_overlays.append(ov);
+ }
+}
+
+void EmailQSLSettingsWidget::onTableDataChanged()
+{
+ FCT_IDENTIFICATION;
+
+ if (m_syncing) return;
+ m_syncing = true;
+ overlayTableToList();
+ ui->cardEditorWidget->setOverlays(m_overlays);
+ m_syncing = false;
+}
+
+void EmailQSLSettingsWidget::onEditorPositionChanged(int index, int x, int y)
+{
+ FCT_IDENTIFICATION;
+
+ if (m_syncing || index < 0 || index >= m_overlayModel->rowCount()) return;
+ m_syncing = true;
+ m_overlayModel->item(index, OC_X)->setText(QString::number(x));
+ m_overlayModel->item(index, OC_Y)->setText(QString::number(y));
+ if (index < m_overlays.size())
+ { m_overlays[index].x = x; m_overlays[index].y = y; }
+ m_syncing = false;
+}
+
+void EmailQSLSettingsWidget::onEditorSizeChanged(int index, int w, int h)
+{
+ FCT_IDENTIFICATION;
+
+ if (m_syncing || index < 0 || index >= m_overlayModel->rowCount()) return;
+ m_syncing = true;
+ auto *wi = m_overlayModel->item(index, OC_W);
+ auto *hi = m_overlayModel->item(index, OC_H);
+ if (wi && wi->text() != QStringLiteral("—")) wi->setText(QString::number(w));
+ if (hi && hi->text() != QStringLiteral("—")) hi->setText(QString::number(h));
+ if (index < m_overlays.size())
+ { m_overlays[index].width = w; m_overlays[index].height = h; }
+ m_syncing = false;
+}
+
+void EmailQSLSettingsWidget::onEditorOverlaySelected(int index)
+{
+ if (index >= 0)
+ ui->overlayTableView->selectRow(index);
+ else
+ ui->overlayTableView->clearSelection();
+ ui->removeOverlayButton->setEnabled(index >= 0);
+}
+
+// ---------------------------------------------------------------------------
+// Slots: image
+// ---------------------------------------------------------------------------
+
+void EmailQSLSettingsWidget::browseCardImage()
+{
+ FCT_IDENTIFICATION;
+
+ const QString path = QFileDialog::getOpenFileName(
+ this, tr("Select QSL Card Image"),
+ ui->cardImagePathEdit->text(),
+ tr("Images (*.jpg *.jpeg *.png *.bmp);;All files (*)"));
+ if (!path.isEmpty())
+ ui->cardImagePathEdit->setText(path);
+}
+
+void EmailQSLSettingsWidget::onCardImageChanged(const QString &path)
+{
+ FCT_IDENTIFICATION;
+
+ QPixmap pm(path);
+
+ // If there are existing overlays and a previous image, rescale all stored
+ // pixel values (positions, font sizes, box dimensions) proportionally so
+ // the layout stays in the same visual position on the new image.
+ const QPixmap &prev = ui->cardEditorWidget->image();
+ if (!prev.isNull() && !pm.isNull()
+ && prev.size() != pm.size())
+ {
+ overlayTableToList(); // make sure m_overlays is current
+
+ if (!m_overlays.isEmpty())
+ {
+ const double sx = static_cast(pm.width()) / prev.width();
+ const double sy = static_cast(pm.height()) / prev.height();
+
+ for (EmailQSLFieldOverlay &ov : m_overlays)
+ {
+ ov.x = qRound(ov.x * sx);
+ ov.y = qRound(ov.y * sy);
+ // Font size tracks horizontal scale (same axis as card width)
+ ov.fontSize = qMax(1, qRound(ov.fontSize * sx));
+ if (ov.type == QLatin1String("BOX"))
+ {
+ ov.width = qMax(20, qRound(ov.width * sx));
+ ov.height = qMax(10, qRound(ov.height * sy));
+ }
+ }
+
+ listToOverlayTable();
+ }
+ }
+
+ ui->cardEditorWidget->setImage(pm);
+}
+
+// ---------------------------------------------------------------------------
+// Slots: overlays
+// ---------------------------------------------------------------------------
+
+void EmailQSLSettingsWidget::addOverlay()
+{
+ FCT_IDENTIFICATION;
+
+ // Default position: top-left area of image (or 50,50 if no image)
+ const QPixmap &img = ui->cardEditorWidget->image();
+ const int cx = img.isNull() ? 50 : img.width() / 2;
+ const int cy = img.isNull() ? 50 : img.height() / 4;
+
+ overlayTableToList();
+ m_overlays.append(makeOverlay(QStringLiteral("CALLSIGN"), cx, cy, 14, false));
+ listToOverlayTable();
+
+ const int newRow = m_overlayModel->rowCount() - 1;
+ ui->overlayTableView->setCurrentIndex(m_overlayModel->index(newRow, 0));
+ ui->overlayTableView->scrollToBottom();
+ ui->cardEditorWidget->setSelectedIndex(newRow);
+}
+
+void EmailQSLSettingsWidget::addBox()
+{
+ FCT_IDENTIFICATION;
+
+ const QPixmap &img = ui->cardEditorWidget->image();
+ const int W = img.isNull() ? 1000 : img.width();
+ const int H = img.isNull() ? 700 : img.height();
+
+ EmailQSLFieldOverlay ov;
+ ov.type = QStringLiteral("BOX");
+ ov.fieldName = QString(); // no caption by default
+ ov.x = qRound(W * 0.05);
+ ov.y = qRound(H * 0.60);
+ ov.width = qRound(W * 0.25);
+ ov.height = qRound(H * 0.22);
+ ov.fillColor = QStringLiteral("#FFFF99");
+ ov.color = QStringLiteral("#888800");
+ ov.opacity = 80;
+ ov.cornerRadius = qMax(4, qRound(H * 0.02));
+ ov.fontSize = 0; // no caption
+
+ overlayTableToList();
+ m_overlays.append(ov);
+ listToOverlayTable();
+
+ const int newRow = m_overlayModel->rowCount() - 1;
+ ui->overlayTableView->setCurrentIndex(m_overlayModel->index(newRow, 0));
+ ui->overlayTableView->scrollToBottom();
+ ui->cardEditorWidget->setSelectedIndex(newRow);
+}
+
+void EmailQSLSettingsWidget::addLabel()
+{
+ FCT_IDENTIFICATION;
+
+ const QPixmap &img = ui->cardEditorWidget->image();
+ const int cx = img.isNull() ? 50 : img.width() / 2;
+ const int cy = img.isNull() ? 50 : img.height() / 4;
+
+ EmailQSLFieldOverlay ov;
+ ov.type = QStringLiteral("LABEL");
+ ov.fieldName = tr("Label Text");
+ ov.x = cx;
+ ov.y = cy;
+ ov.fontFamily = QStringLiteral("Arial");
+ ov.fontSize = 14;
+ ov.color = QStringLiteral("#000000");
+ ov.bold = false;
+ ov.italic = false;
+
+ overlayTableToList();
+ m_overlays.append(ov);
+ listToOverlayTable();
+
+ const int newRow = m_overlayModel->rowCount() - 1;
+ ui->overlayTableView->setCurrentIndex(m_overlayModel->index(newRow, 0));
+ ui->overlayTableView->scrollToBottom();
+ ui->cardEditorWidget->setSelectedIndex(newRow);
+}
+
+void EmailQSLSettingsWidget::addDefaultOverlays()
+{
+ FCT_IDENTIFICATION;
+
+ const QPixmap &img = ui->cardEditorWidget->image();
+ const int W = img.isNull() ? 1280 : img.width();
+ const int H = img.isNull() ? 896 : img.height();
+
+ // Font sizes are designed for a 1280-px-wide reference card and scaled
+ // linearly to the actual image width so they always look proportional.
+ // The same scale is applied to horizontal positions; vertical positions
+ // use the image height fraction directly.
+ static constexpr double REF_W = 1280.0;
+ const double fs = W / REF_W; // font scale (also used for x/horizontal)
+
+ auto scalePt = [&](int refPt) { return qMax(1, qRound(refPt * fs)); };
+
+ // Helper lambda to make a LABEL (static text) overlay
+ auto makeLabel = [&](const QString &text, int x, int y, int refFontPt, bool bold,
+ const QString &color = QStringLiteral("#333333")) -> EmailQSLFieldOverlay
+ {
+ EmailQSLFieldOverlay ov;
+ ov.type = QStringLiteral("LABEL");
+ ov.fieldName = text;
+ ov.x = x;
+ ov.y = y;
+ ov.fontSize = scalePt(refFontPt);
+ ov.bold = bold;
+ ov.fontFamily = QStringLiteral("Arial");
+ ov.color = color;
+ return ov;
+ };
+
+ // Reference font sizes at 1280 px width
+ const int refCallsignPt = 80; // MY_CALLSIGN prominent header
+ const int refDxCallPt = 40; // DX station callsign
+ const int refDataPt = 20; // QSO data values
+ const int refLabelPt = 12; // small caption labels above each field
+
+ // Positions as fractions of card dimensions — same as before
+ const int labelOffY = qRound(H * 0.045);
+
+ const int rowName = qRound(H * 0.60);
+ const int rowCall = qRound(H * 0.72);
+ const int rowData = qRound(H * 0.84);
+
+ const int col1 = qRound(W * 0.08);
+ const int col2 = qRound(W * 0.35);
+ const int col3 = qRound(W * 0.55);
+ const int col4 = qRound(W * 0.68);
+ const int col5 = qRound(W * 0.80);
+ const int col6 = qRound(W * 0.90);
+ const int colGrid = qRound(W * 0.40);
+
+ const QList defaults = {
+ // My callsign centred near top — no label needed
+ makeOverlay("MY_CALLSIGN", qRound(W * 0.50), qRound(H * 0.18), scalePt(refCallsignPt), true),
+
+ // Name row
+ makeLabel(tr("Name:"), col1, rowName - labelOffY, refLabelPt, false),
+ makeOverlay("NAME", col1, rowName, scalePt(refDataPt), false),
+
+ // Callsign + grid row
+ makeLabel(tr("Callsign:"), col1, rowCall - labelOffY, refLabelPt, false),
+ makeOverlay("CALLSIGN", col1, rowCall, scalePt(refDxCallPt), true),
+ makeLabel(tr("Grid:"), colGrid, rowCall - labelOffY, refLabelPt, false),
+ makeOverlay("GRIDSQUARE", colGrid, rowCall, scalePt(refDataPt), false),
+
+ // QSO detail row
+ makeLabel(tr("Date:"), col1, rowData - labelOffY, refLabelPt, false),
+ makeOverlay("QSO_DATE", col1, rowData, scalePt(refDataPt), false),
+ makeLabel(tr("Time (UTC):"), col2, rowData - labelOffY, refLabelPt, false),
+ makeOverlay("TIME_ON", col2, rowData, scalePt(refDataPt), false),
+ makeLabel(tr("Band:"), col3, rowData - labelOffY, refLabelPt, false),
+ makeOverlay("BAND", col3, rowData, scalePt(refDataPt), false),
+ makeLabel(tr("Mode:"), col4, rowData - labelOffY, refLabelPt, false),
+ makeOverlay("MODE", col4, rowData, scalePt(refDataPt), false),
+ makeLabel(tr("RST Snt:"), col5, rowData - labelOffY, refLabelPt, false),
+ makeOverlay("RST_SENT", col5, rowData, scalePt(refDataPt), false),
+ makeLabel(tr("RST Rcvd:"), col6, rowData - labelOffY, refLabelPt, false),
+ makeOverlay("RST_RCVD", col6, rowData, scalePt(refDataPt), false),
+ };
+
+ overlayTableToList();
+ for (const EmailQSLFieldOverlay &ov : defaults)
+ m_overlays.append(ov);
+ listToOverlayTable();
+ ui->overlayTableView->scrollToBottom();
+}
+
+void EmailQSLSettingsWidget::removeOverlay()
+{
+ FCT_IDENTIFICATION;
+
+ const QModelIndexList sel = ui->overlayTableView->selectionModel()->selectedRows();
+ if (sel.isEmpty())
+ return;
+
+ overlayTableToList();
+ m_overlays.removeAt(sel.first().row());
+ listToOverlayTable();
+}
+
+// ---------------------------------------------------------------------------
+// Full-size preview — renders using current UI state (not saved LogParam values)
+// ---------------------------------------------------------------------------
+
+QPixmap EmailQSLSettingsWidget::renderPreviewPixmap(const QSqlRecord &record)
+{
+ FCT_IDENTIFICATION;
+
+ overlayTableToList();
+ return EmailQSLBase::renderCard(ui->cardImagePathEdit->text().trimmed(),
+ record, m_overlays);
+}
+
+void EmailQSLSettingsWidget::previewCard()
+{
+ FCT_IDENTIFICATION;
+
+ const QSqlRecord dummy = buildDummyRecord();
+ const QPixmap preview = renderPreviewPixmap(dummy);
+
+ if (preview.isNull())
+ {
+ QMessageBox::warning(this, tr("Preview"),
+ tr("Could not load the card image.\n"
+ "Please check the path and make sure the file exists."));
+ return;
+ }
+
+ QDialog *dlg = new QDialog(this);
+ dlg->setAttribute(Qt::WA_DeleteOnClose);
+ dlg->setWindowTitle(tr("Card Preview (test data)"));
+ QVBoxLayout *lay = new QVBoxLayout(dlg);
+
+ QScrollArea *scroll = new QScrollArea(dlg);
+ QLabel *lbl = new QLabel(scroll);
+ const QPixmap scaled = preview.scaled(
+ QSize(800, 600), Qt::KeepAspectRatio, Qt::SmoothTransformation);
+ lbl->setPixmap(scaled);
+ lbl->adjustSize();
+ scroll->setWidget(lbl);
+ scroll->setMinimumSize(qMin(scaled.width() + 20, 820),
+ qMin(scaled.height() + 20, 620));
+ lay->addWidget(scroll);
+
+ QDialogButtonBox *bb = new QDialogButtonBox(QDialogButtonBox::Close, dlg);
+ QPushButton *saveBtn = bb->addButton(tr("Save Card…"), QDialogButtonBox::ActionRole);
+
+ connect(saveBtn, &QPushButton::clicked, this, [preview, this]()
+ {
+ const QString defaultDir = QStandardPaths::writableLocation(QStandardPaths::PicturesLocation);
+ const QString path = QFileDialog::getSaveFileName(
+ this,
+ tr("Save QSL Card"),
+ defaultDir + QStringLiteral("/QSL_preview.png"),
+ tr("PNG Image (*.png);;JPEG Image (*.jpg *.jpeg)"));
+
+ if (path.isEmpty())
+ return;
+
+ if (!preview.save(path))
+ {
+ QMessageBox::warning(this, tr("Save Failed"),
+ tr("Could not save the card image to:\n%1").arg(path));
+ }
+ });
+
+ connect(bb, &QDialogButtonBox::rejected, dlg, &QDialog::close);
+ lay->addWidget(bb);
+ dlg->show(); // non-modal
+}
+
+// ---------------------------------------------------------------------------
+// SMTP
+// ---------------------------------------------------------------------------
+
+void EmailQSLSettingsWidget::onSmtpPresetChanged(int index)
+{
+ FCT_IDENTIFICATION;
+
+ const QList presets = smtpPresets();
+ if (index <= 0 || index >= presets.size())
+ return;
+
+ const SmtpPreset &p = presets.at(index);
+ if (!p.host.isEmpty())
+ {
+ ui->smtpHostEdit->setText(p.host);
+ ui->smtpPortSpin->setValue(p.port);
+ ui->encryptionCombo->setCurrentIndex(p.encryption);
+ }
+ if (!p.hint.isEmpty())
+ QMessageBox::information(this, tr("Provider Notes"), p.hint);
+}
+
+void EmailQSLSettingsWidget::testConnection()
+{
+ FCT_IDENTIFICATION;
+
+ ui->testResultLabel->setText(tr("Testing…"));
+ ui->testConnectionButton->setEnabled(false);
+
+ m_testService->testConnection(
+ ui->smtpHostEdit->text().trimmed(),
+ ui->smtpPortSpin->value(),
+ ui->encryptionCombo->currentIndex(),
+ ui->smtpUsernameEdit->text().trimmed(),
+ ui->smtpPasswordEdit->text());
+}
+
+void EmailQSLSettingsWidget::onTestFinished(bool success, const QString &message)
+{
+ FCT_IDENTIFICATION;
+
+ ui->testConnectionButton->setEnabled(true);
+ if (success)
+ {
+ ui->testResultLabel->setStyleSheet(QStringLiteral("color: green; font-weight: bold;"));
+ ui->testResultLabel->setText(tr("Connection OK"));
+ }
+ else
+ {
+ ui->testResultLabel->setStyleSheet(QStringLiteral("color: red;"));
+ ui->testResultLabel->setText(tr("Failed: %1").arg(message));
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Template insertion
+// ---------------------------------------------------------------------------
+
+void EmailQSLSettingsWidget::insertMergeFieldBody()
+{
+ FCT_IDENTIFICATION;
+
+ const QString text = ui->bodyFieldCombo->currentText();
+ if (!text.isEmpty())
+ ui->bodyTemplateEdit->insertPlainText(text);
+}
+
+void EmailQSLSettingsWidget::insertMergeFieldSubject()
+{
+ FCT_IDENTIFICATION;
+
+ const QString text = ui->subjectFieldCombo->currentText();
+ if (!text.isEmpty())
+ ui->subjectTemplateEdit->insert(text);
+}
diff --git a/ui/EmailQSLSettingsWidget.h b/ui/EmailQSLSettingsWidget.h
new file mode 100644
index 00000000..a987e0f0
--- /dev/null
+++ b/ui/EmailQSLSettingsWidget.h
@@ -0,0 +1,63 @@
+#ifndef QLOG_UI_EMAILQSLSETTINGSWIDGET_H
+#define QLOG_UI_EMAILQSLSETTINGSWIDGET_H
+
+#include
+#include
+#include
+#include
+#include
+
+#include "service/emailqsl/EmailQSLService.h"
+
+namespace Ui {
+class EmailQSLSettingsWidget;
+}
+
+class EmailQSLSettingsWidget : public QWidget
+{
+ Q_OBJECT
+
+public:
+ explicit EmailQSLSettingsWidget(QWidget *parent = nullptr);
+ ~EmailQSLSettingsWidget();
+
+ void readSettings();
+ void writeSettings();
+
+ // Render card using the current UI state (not saved LogParam values).
+ // Used by the preview dialog so the user can see results before saving.
+ QPixmap renderPreviewPixmap(const QSqlRecord &record);
+
+public slots:
+ void browseCardImage();
+ void onCardImageChanged(const QString &path);
+ void onSmtpPresetChanged(int index);
+ void testConnection();
+ void onTestFinished(bool success, const QString &message);
+ void addOverlay();
+ void addBox();
+ void addLabel();
+ void addDefaultOverlays();
+ void removeOverlay();
+ void insertMergeFieldBody();
+ void insertMergeFieldSubject();
+ void previewCard();
+
+ // Bidirectional sync between the overlay table and the card editor widget
+ void onTableDataChanged();
+ void onEditorPositionChanged(int index, int newX, int newY);
+ void onEditorSizeChanged(int index, int newW, int newH);
+ void onEditorOverlaySelected(int index);
+
+private:
+ void overlayTableToList();
+ void listToOverlayTable();
+
+ Ui::EmailQSLSettingsWidget *ui;
+ QList m_overlays;
+ QStandardItemModel *m_overlayModel;
+ EmailQSLService *m_testService;
+ bool m_syncing;
+};
+
+#endif // QLOG_UI_EMAILQSLSETTINGSWIDGET_H
diff --git a/ui/EmailQSLSettingsWidget.ui b/ui/EmailQSLSettingsWidget.ui
new file mode 100644
index 00000000..ae3d019c
--- /dev/null
+++ b/ui/EmailQSLSettingsWidget.ui
@@ -0,0 +1,384 @@
+
+
+ EmailQSLSettingsWidget
+
+
+ 00820720
+
+ Email QSL Settings
+
+
+ -
+
+
+ Configure Email QSL card sending. Use {FIELD} placeholders in subject, body and card overlays.
+
+ true
+
+
+
+ -
+
+
+
+
+
+
+ SMTP Server
+
+
+
-
+
+ Quick Presets
+
+
-
+
+ Provider preset:
+
+
+ -
+
+
+ Select a preset to auto-fill host, port and encryption.
+
+Gmail: enable 2-FA, then create an App Password at myaccount.google.com → Security → App Passwords. Use the 16-character app password (not your Google password).
+Outlook / Hotmail / Microsoft 365: use your Microsoft account email and password. If MFA is enabled generate an App Password at account.microsoft.com → Security.
+Yahoo Mail: go to Account Security and generate an App Password.
+iCloud Mail: generate an App-Specific Password at appleid.apple.com → Sign-In and Security.
+
+
+
+ -
+
+ Qt::Horizontal
+
+
+
+
+
+
+ -
+
+ Server
+
+
-
+ SMTP host:
+
+ -
+
+ e.g. smtp.gmail.com
+
+
+ -
+ Port:
+
+ -
+
+ 1
+ 65535
+ 587
+
+
+ -
+ Encryption:
+
+ -
+
+
- None
+ - SSL/TLS (implicit, port 465)
+ - STARTTLS (explicit, port 587)
+
+
+
+
+
+
+ -
+
+ Authentication
+
+
-
+ Username:
+
+ -
+
+ your.address@gmail.com
+
+
+ -
+ Password / App password:
+
+ -
+
+ QLineEdit::Password
+ App password recommended (16 chars for Gmail)
+
+
+ -
+ From address:
+
+ -
+
+ your.address@gmail.com
+
+
+ -
+ From display name:
+
+ -
+
+ AA5SH Amateur Radio
+
+
+
+
+
+
+ -
+
+
-
+
+ Test Connection
+
+
+ -
+
+
+
+
+ -
+ Qt::Horizontal
+
+
+
+
+ -
+ Qt::Vertical
+
+
+
+
+
+
+
+
+
+ Card Image & Overlays
+
+
+
+ -
+
+
-
+ Card image:
+
+ -
+
+ Path to base QSL card image (JPEG or PNG)
+
+
+ -
+
+ Browse…
+
+
+
+
+
+
+ -
+
+
+ 300220
+
+
+ Click an overlay to select it. Drag to reposition it on the card.
+
+
+
+
+ -
+
+
+ Click to select · Drag to move · BOX: drag bottom-right handle to resize.
+
+ QLabel { color: #555; font-style: italic; }
+
+
+
+
+ -
+
+ Overlays
+
+
-
+
+ 16777215170
+ QAbstractItemView::SingleSelection
+ QAbstractItemView::SelectRows
+
+ QAbstractItemView::DoubleClicked|QAbstractItemView::SelectedClicked|QAbstractItemView::EditKeyPressed
+
+ true
+
+
+ -
+
+
-
+
+ Add Text
+ Add a merge-field text overlay
+
+
+ -
+
+ Add Box
+ Add a filled rounded rectangle (like the yellow boxes on traditional QSL cards)
+
+
+ -
+
+ Add Static Text
+ Add a free-form text label (literal text, not a merge field)
+
+
+ -
+
+ Default QSL Fields
+
+ Insert a pre-built set of text overlays (callsign, date, time, band, mode, RST, name, grid) positioned across the lower area of the card.
+
+
+
+ -
+
+ Remove
+ false
+
+
+ -
+ Qt::Horizontal
+
+ -
+
+ Full-Size Preview…
+
+
+
+
+ -
+
+
+ TEXT: Field=merge key. BOX: Field=optional caption, drag corner to resize. LABEL: Field=literal text printed as-is. Double-click Color/Fill to pick color.
+
+ true
+ QLabel { color: #555; }
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Email Template
+
+
+ -
+
+ Subject Line
+
+
-
+
+ QSL Card from {MY_CALLSIGN} for our QSO on {QSO_DATE}
+
+
+ -
+
+ Choose a merge field to insert
+
+
+ -
+
+ Insert
+
+
+
+
+
+
+ -
+
+ Email Body
+
+
-
+
+
-
+ Insert field:
+
+ -
+
+ Choose a merge field to insert at the cursor
+
+
+ -
+
+ Insert
+
+
+ -
+ Qt::Horizontal
+
+
+
+ -
+
+ 0180
+ Enter email body here. Use {FIELD} placeholders.
+
+
+
+
+
+
+ -
+
+ Available Merge Fields
+
+
-
+
+ 16777215150
+ 2
+ QAbstractItemView::NoEditTriggers
+ QAbstractItemView::NoSelection
+ Placeholder
+ Description
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ CardEditorWidget
+ QWidget
+ ui/component/CardEditorWidget.h
+
+
+
+
+
diff --git a/ui/LogbookWidget.cpp b/ui/LogbookWidget.cpp
index 2e11b35c..56c4ffbc 100644
--- a/ui/LogbookWidget.cpp
+++ b/ui/LogbookWidget.cpp
@@ -1,6 +1,8 @@
#include
#include
#include
+#include
+#include
#include
#include
#include
@@ -33,6 +35,7 @@
#include "service/GenericCallbook.h"
#include "core/QSOFilterManager.h"
#include "core/LogParam.h"
+#include "ui/EmailQSLDialog.h"
MODULE_IDENTIFICATION("qlog.ui.logbookwidget");
@@ -133,6 +136,7 @@ LogbookWidget::LogbookWidget(QWidget *parent) :
ui->contactTable->addAction(ui->actionFilter);
ui->contactTable->addAction(ui->actionLookup);
ui->contactTable->addAction(ui->actionSendDXCSpot);
+ ui->contactTable->addAction(ui->actionSendEmailQSL);
ui->contactTable->addAction(separator);
ui->contactTable->addAction(ui->actionExportAs);
ui->contactTable->addAction(ui->actionCallbookLookup);
@@ -1256,6 +1260,131 @@ void LogbookWidget::sendDXCSpot()
emit sendDXSpotContactReq(model->record(selectedIndexes.at(0).row()));
}
+void LogbookWidget::sendEmailQSL()
+{
+ FCT_IDENTIFICATION;
+
+ const QModelIndexList selectedIndexes = ui->contactTable->selectionModel()->selectedRows();
+ if (selectedIndexes.isEmpty())
+ return;
+
+ // Collect records, skip those without an email address silently (report at end)
+ QList toSend;
+ int skippedNoEmail = 0;
+
+ // Single resend decision covers both "already sent for this QSO" and
+ // "already sent to this callsign for a different QSO" — one prompt per
+ // batch regardless of the mix of duplicate types.
+ // -1 = undecided, 0 = skip all duplicates, 1 = resend all duplicates
+ int resendDecision = -1;
+
+ const bool batchMode = (selectedIndexes.count() > 1);
+
+ for (const QModelIndex &idx : selectedIndexes)
+ {
+ const QSqlRecord rec = model->record(idx.row());
+ const QString email = rec.value(QStringLiteral("email")).toString().trimmed();
+
+ if (email.isEmpty())
+ {
+ ++skippedNoEmail;
+ continue;
+ }
+
+ const QString callsign = rec.value(QStringLiteral("callsign")).toString().toUpper();
+ const int id = rec.value(QStringLiteral("id")).toInt();
+ const QDateTime prevSent = EmailQSLBase::getEmailSentDateTime(rec);
+ const bool sentQso = prevSent.isValid();
+ const bool sentCall = !sentQso && EmailQSLBase::hasEmailBeenSentToCallsign(callsign, id);
+
+ if (sentQso || sentCall)
+ {
+ if (resendDecision == 0)
+ {
+ continue; // "No to All" already chosen
+ }
+ else if (resendDecision == -1)
+ {
+ QString detail = sentQso
+ ? tr("An Email QSL was already sent for %1 on %2 UTC.")
+ .arg(callsign, prevSent.toString(Qt::RFC2822Date))
+ : tr("An Email QSL was previously sent to %1 for a different QSO.")
+ .arg(callsign);
+
+ QString msg = detail + QStringLiteral("
") + tr("Do you want to send again?");
+ if (batchMode)
+ msg += QStringLiteral("
") +
+ tr("\"Yes to All\" / \"No to All\" applies to every duplicate in this batch.") +
+ QStringLiteral("");
+
+ QMessageBox mb(this);
+ mb.setWindowTitle(tr("Already Sent — Send Again?"));
+ mb.setTextFormat(Qt::RichText);
+ mb.setText(msg);
+ mb.setIcon(QMessageBox::Question);
+
+ QPushButton *yesBtn = mb.addButton(tr("Yes"), QMessageBox::YesRole);
+ QPushButton *yesAllBtn = batchMode ? mb.addButton(tr("Yes to All"), QMessageBox::YesRole) : nullptr;
+ QPushButton *noBtn = mb.addButton(tr("No"), QMessageBox::NoRole);
+ QPushButton *noAllBtn = batchMode ? mb.addButton(tr("No to All"), QMessageBox::NoRole) : nullptr;
+ Q_UNUSED(yesBtn)
+
+ mb.exec();
+ QAbstractButton *clicked = mb.clickedButton();
+
+ if (clicked == noAllBtn) { resendDecision = 0; continue; }
+ else if (clicked == noBtn) { continue; }
+ else if (clicked == yesAllBtn) { resendDecision = 1; }
+ // else "Yes" — include this one, leave decision open for next
+ }
+ // resendDecision == 1 → fall through and add
+ }
+
+ toSend.append(rec);
+ }
+
+ if (toSend.isEmpty())
+ {
+ if (skippedNoEmail > 0)
+ QMessageBox::information(this, tr("Email QSL"),
+ tr("%n contact(s) skipped — no email address on file.", "", skippedNoEmail));
+ return;
+ }
+
+ // Show dialogs one at a time so they never pile up behind the main window
+ showNextEmailQSLDialog(new QList(toSend));
+
+ if (skippedNoEmail > 0)
+ QMessageBox::information(this, tr("Email QSL"),
+ tr("%n contact(s) skipped — no email address on file.", "", skippedNoEmail));
+}
+
+void LogbookWidget::showNextEmailQSLDialog(QList *queue)
+{
+ FCT_IDENTIFICATION;
+
+ if (queue->isEmpty())
+ {
+ delete queue;
+ model->select(); // refresh the log table once the whole batch is done
+ return;
+ }
+
+ const QSqlRecord rec = queue->takeFirst();
+ EmailQSLDialog *dialog = new EmailQSLDialog(rec, this);
+ dialog->setAttribute(Qt::WA_DeleteOnClose);
+
+ // When this dialog closes (for any reason), open the next one
+ connect(dialog, &QDialog::finished, this, [this, queue]()
+ {
+ showNextEmailQSLDialog(queue);
+ });
+
+ dialog->show();
+ dialog->raise();
+ dialog->activateWindow();
+}
+
void LogbookWidget::setDefaultSort()
{
FCT_IDENTIFICATION;
diff --git a/ui/LogbookWidget.h b/ui/LogbookWidget.h
index 304562e0..3f8bce2f 100644
--- a/ui/LogbookWidget.h
+++ b/ui/LogbookWidget.h
@@ -87,6 +87,7 @@ public slots:
void focusSearchCallsign();
void reloadSetting();
void sendDXCSpot();
+ void sendEmailQSL();
void setDefaultSort();
void actionCallbookLookup();
void callsignFound(const CallbookResponseData &data);
@@ -129,6 +130,7 @@ public slots:
void updateQSORecordFromCallbook(const CallbookResponseData &data);
void queryNextQSOLookupBatch();
void finishQSOLookupBatch();
+ void showNextEmailQSLDialog(QList *queue);
void clearSearchText();
void setupSearchMenu();
void setContactTableColumnVisible(int columnIndex, bool visible);
diff --git a/ui/LogbookWidget.ui b/ui/LogbookWidget.ui
index cca6bd5f..1ab80107 100644
--- a/ui/LogbookWidget.ui
+++ b/ui/LogbookWidget.ui
@@ -333,6 +333,14 @@
Logbook - Send DX Spot
+
+
+ Send Email QSL Card
+
+
+ Send an Email QSL card to this contact
+
+
true
@@ -567,6 +575,16 @@
+
+ actionSendEmailQSL
+ triggered()
+ LogbookWidget
+ sendEmailQSL()
+
+ -1-1
+ 404168
+
+
actionSendDXCSpot
triggered()
@@ -715,6 +733,7 @@
exportContact()
clubFilterChanged()
sendDXCSpot()
+ sendEmailQSL()
setCallsignSearch()
setGridsquareSearch()
setPotaSearch()
diff --git a/ui/SettingsDialog.cpp b/ui/SettingsDialog.cpp
index 9d2cf0b2..339c49d5 100644
--- a/ui/SettingsDialog.cpp
+++ b/ui/SettingsDialog.cpp
@@ -54,6 +54,7 @@
#include "ui/BandmapWidget.h"
#include "ui/RigctldAdvancedDialog.h"
#include "cwkey/drivers/CWWinKey.h"
+#include "ui/EmailQSLSettingsWidget.h"
MODULE_IDENTIFICATION("qlog.ui.settingsdialog");
@@ -2779,6 +2780,11 @@ void SettingsDialog::readSettings()
ui->unitFormatImperialRadioButton->setChecked(!unitFormatMetric);
loadQsoStatusColors();
+ /***************/
+ /* Email QSL */
+ /***************/
+ ui->emailQSLSettingsWidget->readSettings();
+
/******************/
/* END OF Reading */
/******************/
@@ -2912,6 +2918,11 @@ void SettingsDialog::writeSettings()
locale.setSettingUseMetric(ui->unitFormatMetricRadioButton->isChecked());
saveQsoStatusColors();
Data::reloadQsoStatusColors();
+
+ /***************/
+ /* Email QSL */
+ /***************/
+ ui->emailQSLSettingsWidget->writeSettings();
}
void SettingsDialog::setupAdifRecoveryTab()
diff --git a/ui/SettingsDialog.ui b/ui/SettingsDialog.ui
index a5486d68..ccb7c4a9 100644
--- a/ui/SettingsDialog.ui
+++ b/ui/SettingsDialog.ui
@@ -5224,6 +5224,16 @@
+
+
+ Email QSL
+
+
+ -
+
+
+
+
@@ -5239,6 +5249,11 @@
QLineEdit
+
+ EmailQSLSettingsWidget
+ QWidget
+ ui/EmailQSLSettingsWidget.h
+
stationProfileNameEdit
diff --git a/ui/component/CardEditorWidget.cpp b/ui/component/CardEditorWidget.cpp
new file mode 100644
index 00000000..c6c04b53
--- /dev/null
+++ b/ui/component/CardEditorWidget.cpp
@@ -0,0 +1,380 @@
+#include
+#include
+#include
+#include
+#include
+
+#include "CardEditorWidget.h"
+
+CardEditorWidget::CardEditorWidget(QWidget *parent)
+ : QWidget(parent)
+{
+ setMouseTracking(true);
+ setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
+ setMinimumSize(300, 200);
+}
+
+// ---------------------------------------------------------------------------
+// Public interface
+// ---------------------------------------------------------------------------
+
+void CardEditorWidget::setImage(const QPixmap &pm)
+{
+ m_image = pm;
+ update();
+}
+
+void CardEditorWidget::setOverlays(const QList &overlays)
+{
+ m_overlays = overlays;
+ if (m_selectedIndex >= m_overlays.size())
+ m_selectedIndex = -1;
+ update();
+}
+
+void CardEditorWidget::updateOverlay(int index, const EmailQSLFieldOverlay &ov)
+{
+ if (index < 0 || index >= m_overlays.size())
+ return;
+ m_overlays[index] = ov;
+ update();
+}
+
+void CardEditorWidget::setSelectedIndex(int index)
+{
+ if (m_selectedIndex == index)
+ return;
+ m_selectedIndex = index;
+ update();
+}
+
+QSize CardEditorWidget::sizeHint() const
+{
+ if (m_image.isNull())
+ return QSize(500, 320);
+ return m_image.size().scaled(640, 420, Qt::KeepAspectRatio);
+}
+
+QSize CardEditorWidget::minimumSizeHint() const { return QSize(300, 200); }
+
+// ---------------------------------------------------------------------------
+// Coordinate helpers
+// ---------------------------------------------------------------------------
+
+QRectF CardEditorWidget::imageRect() const
+{
+ if (m_image.isNull())
+ return QRectF(rect());
+ QSizeF sz = m_image.size();
+ sz.scale(size(), Qt::KeepAspectRatio);
+ return QRectF(QPointF((width() - sz.width()) / 2.0,
+ (height() - sz.height()) / 2.0), sz);
+}
+
+double CardEditorWidget::displayScale() const
+{
+ if (m_image.isNull() || m_image.width() == 0) return 1.0;
+ return imageRect().width() / m_image.width();
+}
+
+QPointF CardEditorWidget::imageToWidget(const QPointF &ip) const
+{
+ const QRectF r = imageRect();
+ const double sx = r.width() / (m_image.isNull() ? 1 : m_image.width());
+ const double sy = r.height() / (m_image.isNull() ? 1 : m_image.height());
+ return QPointF(r.left() + ip.x() * sx, r.top() + ip.y() * sy);
+}
+
+QPointF CardEditorWidget::widgetToImage(const QPointF &wp) const
+{
+ const QRectF r = imageRect();
+ if (r.width() == 0 || r.height() == 0) return wp;
+ const double sx = (m_image.isNull() ? 1 : m_image.width()) / r.width();
+ const double sy = (m_image.isNull() ? 1 : m_image.height()) / r.height();
+ return QPointF((wp.x() - r.left()) * sx, (wp.y() - r.top()) * sy);
+}
+
+QRectF CardEditorWidget::overlayWidgetRect(int idx) const
+{
+ if (idx < 0 || idx >= m_overlays.size()) return {};
+ const EmailQSLFieldOverlay &ov = m_overlays.at(idx);
+ const QPointF tl = imageToWidget(QPointF(ov.x, ov.y));
+ const double sc = displayScale();
+
+ if (ov.type == QLatin1String("BOX"))
+ {
+ return QRectF(tl, QSizeF(ov.width * sc, ov.height * sc));
+ }
+
+ // TEXT / LABEL: bounding box of the displayed string
+ QFont f(ov.fontFamily, qMax(8, qRound(ov.fontSize * sc)));
+ f.setBold(ov.bold); f.setItalic(ov.italic);
+ const QFontMetrics fm(f);
+ const QString lbl = (ov.type == QLatin1String("LABEL"))
+ ? ov.fieldName
+ : (QLatin1Char('{') + ov.fieldName + QLatin1Char('}'));
+ return QRectF(tl.x(), tl.y() - fm.ascent(), fm.horizontalAdvance(lbl), fm.height());
+}
+
+QRectF CardEditorWidget::resizeHandleRect(int idx) const
+{
+ const QRectF r = overlayWidgetRect(idx);
+ if (r.isNull()) return {};
+ const double h = RESIZE_HANDLE_PX;
+ return QRectF(r.right() - h, r.bottom() - h, h * 2, h * 2);
+}
+
+bool CardEditorWidget::isOnResizeHandle(int idx, const QPointF &wPt) const
+{
+ if (idx < 0 || idx >= m_overlays.size()) return false;
+ if (m_overlays.at(idx).type != QLatin1String("BOX")) return false;
+ return resizeHandleRect(idx).contains(wPt);
+}
+
+int CardEditorWidget::overlayAt(const QPointF &wPt) const
+{
+ // Iterate in reverse so top-drawn items (higher index) are hit first
+ for (int i = m_overlays.size() - 1; i >= 0; --i)
+ {
+ const QRectF r = overlayWidgetRect(i).adjusted(-4, -4, 4, 4);
+ if (r.contains(wPt)) return i;
+ }
+ return -1;
+}
+
+// ---------------------------------------------------------------------------
+// Paint
+// ---------------------------------------------------------------------------
+
+void CardEditorWidget::paintEvent(QPaintEvent *)
+{
+ QPainter p(this);
+ p.setRenderHint(QPainter::SmoothPixmapTransform);
+ p.setRenderHint(QPainter::Antialiasing);
+ p.setRenderHint(QPainter::TextAntialiasing);
+
+ // Background
+ p.fillRect(rect(), palette().window());
+
+ if (m_image.isNull())
+ {
+ p.setPen(QColor(0x88, 0x88, 0x88));
+ p.drawText(rect(), Qt::AlignCenter,
+ tr("No card image selected.\nClick \"Browse…\" to choose a QSL card image."));
+ p.setPen(QPen(QColor(0x88, 0x88, 0x88), 1, Qt::DashLine));
+ p.drawRect(rect().adjusted(0, 0, -1, -1));
+ return;
+ }
+
+ // Card image
+ p.drawPixmap(imageRect().toRect(), m_image);
+
+ const double sc = displayScale();
+
+ for (int i = 0; i < m_overlays.size(); ++i)
+ {
+ const EmailQSLFieldOverlay &ov = m_overlays.at(i);
+ const bool sel = (i == m_selectedIndex);
+ const QRectF wr = overlayWidgetRect(i);
+
+ if (ov.type == QLatin1String("BOX"))
+ {
+ // --- Fill ---
+ QColor fill(ov.fillColor);
+ fill.setAlphaF(ov.opacity / 100.0);
+ const double cr = ov.cornerRadius * sc;
+
+ p.setPen(QPen(QColor(ov.color), sel ? 2.0 : 1.5));
+ p.setBrush(fill);
+ p.drawRoundedRect(wr, cr, cr);
+
+ // --- Caption above box ---
+ if (!ov.fieldName.isEmpty())
+ {
+ const int dispSz = qMax(8, qRound((ov.fontSize > 0 ? ov.fontSize : 11) * sc));
+ QFont f(ov.fontFamily, dispSz);
+ f.setBold(ov.bold);
+ p.setFont(f);
+ p.setPen(QColor(ov.color));
+ p.setBrush(Qt::NoBrush);
+ QFontMetrics fm(f);
+ p.drawText(QPointF(wr.left(), wr.top() - fm.descent() - 2), ov.fieldName);
+ }
+
+ // --- Selection decoration ---
+ if (sel)
+ {
+ p.save();
+ p.setPen(QPen(QColor(0, 120, 215), 1.5, Qt::DashLine));
+ p.setBrush(Qt::NoBrush);
+ p.drawRoundedRect(wr.adjusted(-3, -3, 3, 3), cr + 3, cr + 3);
+
+ // Resize handle (bottom-right corner)
+ const QRectF rh = resizeHandleRect(i);
+ p.setPen(Qt::NoPen);
+ p.setBrush(QColor(0, 120, 215));
+ p.drawEllipse(rh.center(), RESIZE_HANDLE_PX * 0.5, RESIZE_HANDLE_PX * 0.5);
+
+ // Size info label
+ p.setPen(QColor(0, 80, 180));
+ p.setFont(QFont(QStringLiteral("Arial"), 8));
+ p.drawText(QPointF(wr.left() + 3, wr.bottom() - 3),
+ QString("%1×%2").arg(ov.width).arg(ov.height));
+ p.restore();
+ }
+ }
+ else // TEXT or LABEL
+ {
+ const int dispSz = qMax(8, qRound(ov.fontSize * sc));
+ QFont f(ov.fontFamily, dispSz);
+ f.setBold(ov.bold); f.setItalic(ov.italic);
+ p.setFont(f);
+
+ // LABEL shows literal text; TEXT shows {FIELDNAME} placeholder
+ const QString lbl = (ov.type == QLatin1String("LABEL"))
+ ? ov.fieldName
+ : (QLatin1Char('{') + ov.fieldName + QLatin1Char('}'));
+ const QFontMetrics fm(f);
+ const QPointF anchor(wr.left(), wr.top() + fm.ascent());
+
+ // Selection box
+ if (sel)
+ {
+ p.save();
+ p.setPen(QPen(QColor(0, 120, 215), 1.5, Qt::DashLine));
+ p.setBrush(QColor(0, 120, 215, 25));
+ p.drawRect(wr.adjusted(-3, -3, 3, 3));
+ // Drag-handle dot
+ p.setPen(Qt::NoPen);
+ p.setBrush(QColor(0, 120, 215));
+ p.drawEllipse(QPointF(wr.left() - 7, wr.center().y()), 4, 4);
+ p.restore();
+ }
+ else
+ {
+ p.save();
+ p.setPen(QPen(QColor(0, 0, 0, 35), 1, Qt::DotLine));
+ p.setBrush(Qt::NoBrush);
+ p.drawRect(wr.adjusted(-2, -2, 2, 2));
+ p.restore();
+ }
+
+ // Shadow
+ {
+ QColor shadow = QColor(ov.color).lightness() > 128
+ ? QColor(0, 0, 0, 90) : QColor(255, 255, 255, 90);
+ p.save();
+ p.setPen(shadow);
+ p.drawText(anchor + QPointF(1, 1), lbl);
+ p.restore();
+ }
+
+ p.setPen(QColor(ov.color));
+ p.drawText(anchor, lbl);
+ }
+ }
+
+ // Border
+ p.setPen(QPen(QColor(0x88, 0x88, 0x88), 1));
+ p.setBrush(Qt::NoBrush);
+ p.drawRect(rect().adjusted(0, 0, -1, -1));
+}
+
+// ---------------------------------------------------------------------------
+// Mouse events
+// ---------------------------------------------------------------------------
+
+void CardEditorWidget::mousePressEvent(QMouseEvent *event)
+{
+ if (event->button() != Qt::LeftButton) { QWidget::mousePressEvent(event); return; }
+
+ const QPointF wPos = event->pos();
+ const int idx = overlayAt(wPos);
+
+ m_selectedIndex = idx;
+ m_dragIndex = -1;
+ m_resizing = false;
+
+ if (idx >= 0)
+ {
+ m_dragIndex = idx;
+ m_dragStartImg = widgetToImage(wPos);
+ m_dragAnchorImg = QPointF(m_overlays.at(idx).x, m_overlays.at(idx).y);
+
+ if (isOnResizeHandle(idx, wPos))
+ {
+ m_resizing = true;
+ m_dragSizeAtPress = QSizeF(m_overlays.at(idx).width, m_overlays.at(idx).height);
+ setCursor(Qt::SizeFDiagCursor);
+ }
+ else
+ {
+ setCursor(Qt::ClosedHandCursor);
+ }
+ }
+
+ emit overlaySelected(m_selectedIndex);
+ update();
+}
+
+void CardEditorWidget::mouseMoveEvent(QMouseEvent *event)
+{
+ const QPointF wPos = event->pos();
+
+ if (m_dragIndex >= 0 && (event->buttons() & Qt::LeftButton))
+ {
+ const QPointF curImg = widgetToImage(wPos);
+ const QPointF delta = curImg - m_dragStartImg;
+
+ EmailQSLFieldOverlay &ov = m_overlays[m_dragIndex];
+ const int imgW = m_image.isNull() ? 99999 : m_image.width();
+ const int imgH = m_image.isNull() ? 99999 : m_image.height();
+
+ if (m_resizing)
+ {
+ const int newW = qMax(20, qRound(m_dragSizeAtPress.width() + delta.x()));
+ const int newH = qMax(10, qRound(m_dragSizeAtPress.height() + delta.y()));
+ ov.width = qMin(newW, imgW - ov.x);
+ ov.height = qMin(newH, imgH - ov.y);
+ }
+ else
+ {
+ ov.x = qBound(0, qRound(m_dragAnchorImg.x() + delta.x()), imgW - 1);
+ ov.y = qBound(0, qRound(m_dragAnchorImg.y() + delta.y()), imgH - 1);
+ }
+ update();
+ }
+ else
+ {
+ // Cursor feedback
+ const int hov = overlayAt(wPos);
+ if (hov >= 0)
+ {
+ if (isOnResizeHandle(hov, wPos))
+ setCursor(Qt::SizeFDiagCursor);
+ else
+ setCursor(Qt::OpenHandCursor);
+ }
+ else
+ {
+ setCursor(Qt::ArrowCursor);
+ }
+ }
+}
+
+void CardEditorWidget::mouseReleaseEvent(QMouseEvent *event)
+{
+ if (event->button() == Qt::LeftButton && m_dragIndex >= 0)
+ {
+ const EmailQSLFieldOverlay &ov = m_overlays.at(m_dragIndex);
+ if (m_resizing)
+ emit overlaySizeChanged(m_dragIndex, ov.width, ov.height);
+ else
+ emit overlayPositionChanged(m_dragIndex, ov.x, ov.y);
+
+ m_dragIndex = -1;
+ m_resizing = false;
+ setCursor(Qt::ArrowCursor);
+ update();
+ }
+}
diff --git a/ui/component/CardEditorWidget.h b/ui/component/CardEditorWidget.h
new file mode 100644
index 00000000..c55d16d1
--- /dev/null
+++ b/ui/component/CardEditorWidget.h
@@ -0,0 +1,68 @@
+#ifndef QLOG_UI_COMPONENT_CARDEDITORWIDGET_H
+#define QLOG_UI_COMPONENT_CARDEDITORWIDGET_H
+
+#include
+#include
+
+#include "service/emailqsl/EmailQSLService.h"
+
+// Interactive QSL card editor widget.
+// Supports two overlay types:
+// TEXT — displays a {FIELD} placeholder; drag to reposition.
+// BOX — filled rounded rectangle; drag to move, drag bottom-right handle to resize.
+class CardEditorWidget : public QWidget
+{
+ Q_OBJECT
+
+public:
+ explicit CardEditorWidget(QWidget *parent = nullptr);
+
+ void setImage(const QPixmap &pm);
+ const QPixmap &image() const { return m_image; }
+
+ void setOverlays(const QList &overlays);
+ const QList &overlays() const { return m_overlays; }
+
+ void updateOverlay(int index, const EmailQSLFieldOverlay &ov);
+ void setSelectedIndex(int index);
+ int selectedIndex() const { return m_selectedIndex; }
+
+ QSize sizeHint() const override;
+ QSize minimumSizeHint() const override;
+
+signals:
+ void overlayPositionChanged(int index, int newX, int newY);
+ void overlaySizeChanged(int index, int newW, int newH); // BOX resize
+ void overlaySelected(int index);
+
+protected:
+ void paintEvent(QPaintEvent *) override;
+ void mousePressEvent(QMouseEvent *) override;
+ void mouseMoveEvent(QMouseEvent *) override;
+ void mouseReleaseEvent(QMouseEvent *) override;
+
+private:
+ static constexpr int RESIZE_HANDLE_PX = 10; // half-size of resize grip in widget px
+
+ QRectF imageRect() const;
+ double displayScale() const;
+ QPointF imageToWidget(const QPointF &imgPt) const;
+ QPointF widgetToImage(const QPointF &wPt) const;
+ QRectF overlayWidgetRect(int index) const; // widget-coords bounding rect
+ QRectF resizeHandleRect(int index) const; // widget-coords resize grip
+ int overlayAt(const QPointF &wPt) const; // -1 = none
+ bool isOnResizeHandle(int index, const QPointF &wPt) const;
+
+ QPixmap m_image;
+ QList m_overlays;
+ int m_selectedIndex = -1;
+
+ // Drag state
+ int m_dragIndex = -1;
+ bool m_resizing = false;
+ QPointF m_dragStartImg; // image-coord of press point
+ QPointF m_dragAnchorImg; // image-coord anchor (overlay x,y at press)
+ QSizeF m_dragSizeAtPress; // overlay w,h at press (resize only)
+};
+
+#endif // QLOG_UI_COMPONENT_CARDEDITORWIDGET_H