diff --git a/configuration/findUsersWithSessionInMonths.sh b/configuration/findUsersWithSessionInMonths.sh new file mode 100755 index 00000000000..e98ecc71611 --- /dev/null +++ b/configuration/findUsersWithSessionInMonths.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Usage: ${0} {MONGO-URI} {MONTHS} +# Example: findUsersWithSessionInMonths.sh "mongodb://localhost/winddb?replicaSet=rs0" 12 +echo ' +var cutoff = new Date(); +cutoff.setMonth(cutoff.getMonth() - '${2}'); + +var activeUsers = db.SESSIONS.distinct( + "SESSION_ATTRIBUTES.SESSION_ATTRIBUTE_VALUE.SESSION_PRINCIPAL_REALM_VALUE", + { SESSION_START_TIMESTAMP: { $gte: cutoff } } +).flat(); + +db.USERS.find( + { + EMAIL_VALIDATED: true, + EMAIL: { $exists: true, $ne: "" }, + NAME: { $in: activeUsers }, + $or: [ + { DID_OPT_OUT_OF_FEATURE_AND_COMMUNITY_EMAILS: { $exists: false } }, + { DID_OPT_OUT_OF_FEATURE_AND_COMMUNITY_EMAILS: false } + ] + }, + { EMAIL: 1, FULLNAME: 1, NAME: 1, _id: 0 } +).forEach(function(u) { + var name = (u.FULLNAME && u.FULLNAME.trim()) || u.NAME || "user"; + print(u.EMAIL + "," + name); +})' | mongosh "${1}" diff --git a/configuration/sendEmails.md b/configuration/sendEmails.md new file mode 100644 index 00000000000..e248cd3c625 --- /dev/null +++ b/configuration/sendEmails.md @@ -0,0 +1,89 @@ +# sendEmails.sh — Personalized Email Sender + +Bash script for sending personalized HTML emails via SMTP (AWS WorkMail). + +## Prerequisites + +- `curl` with SMTP/TLS support +- `base64` (coreutils) +- SMTP credentials for the WorkMail account (`support@sapsailing.com`) + +## Files + +| File | Purpose | +|---|---| +| `sendEmails.sh` | The sending script | +| `emailTemplate.html` | HTML email template with `{{NAME}}` placeholder | +| `recipients.csv` | CSV file with recipient list | + +## CSV Format + +```csv +email,name +alice@example.com,Alice +bob@example.com,Bob +sailor42@example.com,sailor42 +``` + +- **email** — recipient address +- **name** — inserted into the salutation ("Dear {{NAME}},"). Use the user's first name if known, otherwise their username/nickname. +- The header row is auto-detected and skipped if the first field starts with `email` (case-insensitive). + +## Usage + +```bash +./sendEmails.sh [--dry-run] [template.html] +``` + +| Argument | Required | Default | Description | +|---|---|---|---| +| `--dry-run` | no | — | Preview personalized emails without sending | +| `recipients.csv` | yes | — | Path to the CSV file | +| `template.html` | no | `/tmp/emailTemplate.html` | Path to the HTML template | + +## Environment Variables + +| Variable | Required | Description | +|---|---|---| +| `SMTP_USER` | yes (unless `--dry-run`) | SMTP username for WorkMail | +| `SMTP_PASS` | yes (unless `--dry-run`) | SMTP password for WorkMail | + +## Examples + +Preview all emails without sending: + +```bash +./sendEmails.sh --dry-run recipients.csv +``` + +Send using a custom template: + +```bash +export SMTP_USER="your-smtp-username" +export SMTP_PASS="your-smtp-password" +./sendEmails.sh recipients.csv myTemplate.html +``` + +## Configuration + +These values are set at the top of the script and can be adjusted: + +| Variable | Default | Description | +|---|---|---| +| `FROM_ADDR` | `SAP Sailing Analytics ` | Sender display name and address | +| `SUBJECT` | `Your SAP Sailing Analytics Account — Open Source Transition Update` | Email subject line | +| `SMTP_URL` | `smtps://smtp.mail.eu-west-1.awsapps.com:465` | WorkMail SMTP endpoint | + +## How It Works + +1. Reads the CSV line by line, skipping the header row. +2. For each recipient, replaces `{{NAME}}` in the HTML template with the name from the CSV. +3. Constructs a MIME message with UTF-8 base64-encoded subject and body. +4. Sends via `curl` over SMTPS to the WorkMail endpoint. +5. Waits 1 second between sends to respect SES rate limits. + +## Recommended Workflow + +1. **Preview** — Run with `--dry-run` and review the output. +2. **Test** — Send to your own address first (`echo "email,name" > test.csv && echo "you@example.com,YourName" >> test.csv`). +3. **Send** — Run against the full recipient list. diff --git a/configuration/sendEmails.sh b/configuration/sendEmails.sh new file mode 100755 index 00000000000..ef3d6532253 --- /dev/null +++ b/configuration/sendEmails.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +set -euo pipefail + +FROM_ADDR="SAP Sailing Analytics " +SUBJECT="SAP Sailing Analytics — Open Source Transition Update" +SMTP_URL="smtps://smtp.mail.eu-west-1.awsapps.com:465" + +usage() { + cat <<'EOF' +Usage: sendEmails.sh [--dry-run] [template.html] + + recipients.csv CSV file with columns: email,name + template.html HTML template with {{NAME}} placeholder + (default: /tmp/emailTemplate.html) + --dry-run Print personalized emails to stdout without sending + +Environment variables: + SMTP_USER SMTP username (required unless --dry-run) + SMTP_PASS SMTP password (required unless --dry-run) + +CSV format example: + email,name + alice@example.com,Alice + bob@example.com,Bob +EOF + exit 1 +} + +DRY_RUN=false +if [[ "${1:-}" == "--dry-run" ]]; then + DRY_RUN=true + shift +fi + +CSV="${1:-}" +TEMPLATE="${2:-/tmp/emailTemplate.html}" + +[[ -z "$CSV" ]] && usage +[[ ! -f "$CSV" ]] && { echo "Error: CSV file not found: $CSV"; exit 1; } +[[ ! -f "$TEMPLATE" ]] && { echo "Error: Template file not found: $TEMPLATE"; exit 1; } + +if [[ "$DRY_RUN" == false ]]; then + [[ -z "${SMTP_USER:-}" ]] && { echo "Error: SMTP_USER not set"; exit 1; } + [[ -z "${SMTP_PASS:-}" ]] && { echo "Error: SMTP_PASS not set"; exit 1; } +fi + +TEMPLATE_BODY=$(cat "$TEMPLATE") + +SENT=0 +FAILED=0 +SKIPPED_HEADER=false + +while IFS= read -r line || [[ -n "$line" ]]; do + # Skip empty lines + [[ -z "${line// }" ]] && continue + + # Skip header row + if [[ "$SKIPPED_HEADER" == false ]]; then + SKIPPED_HEADER=true + if echo "$line" | grep -qi "^email"; then + continue + fi + fi + + EMAIL=$(echo "$line" | cut -d',' -f1 | xargs) + NAME=$(echo "$line" | cut -d',' -f2- | xargs) + + [[ -z "$EMAIL" ]] && continue + + BODY="${TEMPLATE_BODY//\{\{NAME\}\}/$NAME}" + + if [[ "$DRY_RUN" == true ]]; then + echo "========================================" + echo "To: $EMAIL" + echo "From: $FROM_ADDR" + echo "Subject: $SUBJECT" + echo "----------------------------------------" + echo "$BODY" + echo "" + SENT=$((SENT + 1)) + continue + fi + + MIME_MSG=$(cat </tmp/curl_err.txt; then + echo "Sent to $EMAIL ($NAME)" + SENT=$((SENT + 1)) + SEND_OK=true + break + else + if grep -q "421" /tmp/curl_err.txt 2>/dev/null; then + RETRIES=$((RETRIES + 1)) + WAIT=$((RETRIES * 5)) + echo " Rate limited, retrying in ${WAIT}s... (attempt $((RETRIES))/$MAX_RETRIES)" >&2 + sleep "$WAIT" + else + break + fi + fi + done + if [[ "$SEND_OK" == false ]]; then + echo "FAILED: $EMAIL ($NAME)" >&2 + FAILED=$((FAILED + 1)) + fi + + sleep 3 +done < "$CSV" + +echo "" +if [[ "$DRY_RUN" == true ]]; then + echo "Dry run complete. $SENT emails previewed." +else + echo "Done. Sent: $SENT, Failed: $FAILED" +fi diff --git a/java/com.sap.sailing.aiagent/src/com/sap/sailing/aiagent/impl/AIAgentImpl.java b/java/com.sap.sailing.aiagent/src/com/sap/sailing/aiagent/impl/AIAgentImpl.java index a54c1723c1a..6844f0fb3a0 100644 --- a/java/com.sap.sailing.aiagent/src/com/sap/sailing/aiagent/impl/AIAgentImpl.java +++ b/java/com.sap.sailing.aiagent/src/com/sap/sailing/aiagent/impl/AIAgentImpl.java @@ -15,6 +15,7 @@ import java.util.logging.Logger; import org.apache.http.client.ClientProtocolException; import org.apache.shiro.SecurityUtils; +import org.apache.shiro.UnavailableSecurityManagerException; import org.apache.shiro.authz.AuthorizationException; import org.json.simple.parser.ParseException; import org.osgi.util.tracker.ServiceTracker; @@ -312,7 +313,13 @@ public class AIAgentImpl implements AIAgent { for (final Leaderboard leaderboard : event.getLeaderboards()) { addNewRaceColumnListenerToLeaderboard(leaderboard); } - logger.info("User "+SecurityUtils.getSubject().getPrincipal()+" activated AI comments for event "+event.getName()+" with ID "+event.getId()); + Object principal; + try { + principal = SecurityUtils.getSubject().getPrincipal(); + } catch (UnavailableSecurityManagerException e) { + principal = "null"; + } + logger.info("User "+principal+" activated AI comments for event "+event.getName()+" with ID "+event.getId()); listeners.forEach(l->l.startedCommentingOnEvent(event)); } diff --git a/java/com.sap.sailing.gwt.ui/src/main/java/com/sap/sailing/gwt/ui/adminconsole/ResultImportUrlsListComposite.java b/java/com.sap.sailing.gwt.ui/src/main/java/com/sap/sailing/gwt/ui/adminconsole/ResultImportUrlsListComposite.java index 098b6d68dd4..c15ebdffc83 100644 --- a/java/com.sap.sailing.gwt.ui/src/main/java/com/sap/sailing/gwt/ui/adminconsole/ResultImportUrlsListComposite.java +++ b/java/com.sap.sailing.gwt.ui/src/main/java/com/sap/sailing/gwt/ui/adminconsole/ResultImportUrlsListComposite.java @@ -23,6 +23,7 @@ import com.sap.sse.common.Util.Pair; import com.sap.sse.gwt.client.ErrorReporter; import com.sap.sse.gwt.client.Notification; import com.sap.sse.gwt.client.Notification.NotificationType; +import com.sap.sse.gwt.client.async.MarkedAsyncCallback; import com.sap.sse.gwt.client.celltable.RefreshableMultiSelectionModel; import com.sap.sse.gwt.client.dialog.DataEntryDialog; import com.sap.sse.security.ui.client.component.AccessControlledButtonPanel; @@ -112,7 +113,8 @@ public class ResultImportUrlsListComposite extends Composite { new DataEntryDialog.DialogCallback() { @Override public void ok(UrlDTO url) { - sailingServiceWrite.addResultImportUrl(getSelectedProviderName(), url, new AsyncCallback() { + sailingServiceWrite.addResultImportUrl(getSelectedProviderName(), url, + new MarkedAsyncCallback<>(new AsyncCallback() { @Override public void onSuccess(Void result) { Notification.notify(stringMessages.successfullyUpdatedResultImportUrls(), @@ -124,7 +126,7 @@ public class ResultImportUrlsListComposite extends Composite { errorReporter .reportError(stringMessages.errorAddingResultImportUrl(caught.getMessage())); } - }); + })); } @Override public void cancel() { diff --git a/java/com.sap.sse.replication.interfaces/src/com/sap/sse/replication/FullyInitializedReplicableTracker.java b/java/com.sap.sse.replication.interfaces/src/com/sap/sse/replication/FullyInitializedReplicableTracker.java index 4613f575c43..b4ac81af719 100755 --- a/java/com.sap.sse.replication.interfaces/src/com/sap/sse/replication/FullyInitializedReplicableTracker.java +++ b/java/com.sap.sse.replication.interfaces/src/com/sap/sse/replication/FullyInitializedReplicableTracker.java @@ -130,7 +130,8 @@ public class FullyInitializedReplicableTracker> exten * {@link #waitForService(long)}). If no such service object can be found before timing out, {@code null} * is returned. Once a service object has been retrieved and a non-{@code null} {@link #replicationServiceTracker} * has been provided at construction time, the {@link ReplicationService} is obtained from that tracker by - * waiting for it at least {@code timeoutInMillis} milliseconds and then + * waiting for it at least {@code timeoutInMillis} milliseconds and then is asked to wait for the replication + * to be fully initialized, so in particular having received and incorporated the initial load. * * @param timeoutInMillis * 0 means indefinite wait time diff --git a/java/com.sap.sse.security/src/com/sap/sse/security/SecurityInitializationCustomizer.java b/java/com.sap.sse.security/src/com/sap/sse/security/SecurityInitializationCustomizer.java index 473ccf8c38a..a847f09d329 100644 --- a/java/com.sap.sse.security/src/com/sap/sse/security/SecurityInitializationCustomizer.java +++ b/java/com.sap.sse.security/src/com/sap/sse/security/SecurityInitializationCustomizer.java @@ -1,6 +1,6 @@ package com.sap.sse.security; +@FunctionalInterface public interface SecurityInitializationCustomizer { - void customizeSecurityService(SecurityService securityService); } diff --git a/java/com.sap.sse.security/src/com/sap/sse/security/impl/Activator.java b/java/com.sap.sse.security/src/com/sap/sse/security/impl/Activator.java index fd1bc5d31f7..931517e5e2c 100644 --- a/java/com.sap.sse.security/src/com/sap/sse/security/impl/Activator.java +++ b/java/com.sap.sse.security/src/com/sap/sse/security/impl/Activator.java @@ -19,7 +19,6 @@ import com.sap.sse.ServerInfo; import com.sap.sse.classloading.ServiceTrackerCustomizerForClassLoaderSupplierRegistrations; import com.sap.sse.mail.MailService; import com.sap.sse.replication.Replicable; -import com.sap.sse.replication.ReplicationMasterDescriptor; import com.sap.sse.replication.ReplicationService; import com.sap.sse.rest.CORSFilterConfiguration; import com.sap.sse.security.SecurityInitializationCustomizer; @@ -289,9 +288,12 @@ public class Activator implements BundleActivator { // create security service, it will also create a default admin user if no users exist createAndRegisterSecurityService(bundleContext, userStore, accessControlStore, subscriptionPlanProvider); applyCustomizations(); - migrate(userStore, securityService.get()); - final ReplicationMasterDescriptor masterDescriptor = securityService.get().getMasterDescriptor(); - if (masterDescriptor == null) { + final ReplicationService replicationService = ServiceTrackerFactory.createAndOpen(context, ReplicationService.class).waitForService(0); + // See also bug 6244: if the SecurityService will become a replica, don't worry about subscriptions + // and migrations as that is relevant only on the primary, and we don't want to establish or even replicate + // effects of temporary locally-created SERVER objects, ownerships, and permissions. + if (!replicationService.isReplicationStarting() && securityService.get().getMasterDescriptor() == null) { + migrate(userStore, securityService.get()); startSubscriptionDataUpdateTask(bundleContext); startSubscriptionPlanUpdateTask(bundleContext); }