Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions docs/openapi/llmo-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1602,6 +1602,14 @@ llmo-strategy:
This operation completely overwrites the existing strategy with the provided payload.
The payload can be any valid JSON object - no schema validation is performed.
The strategy is stored in S3 at `workspace/llmo/{siteId}/strategy.json`.

When status changes are detected (either at the strategy level or at the
opportunity level), email notifications are sent to the relevant recipients.
Initial strategy creation does not trigger strategy status emails. Assignment
notifications go to the assignee only (not the strategy owner). Notification
delivery is awaited, and a summary of sent/failed/skipped counts is included
in the response. Notification failures do not cause the endpoint to return
an error.
operationId: saveStrategy
requestBody:
required: true
Expand All @@ -1623,8 +1631,25 @@ llmo-strategy:
type: string
description: The S3 object version ID of the saved strategy
example: "abc123def456"
notifications:
type: object
description: Summary of notification delivery
properties:
sent:
type: integer
description: Number of emails sent successfully
failed:
type: integer
description: Number of emails that failed to send
skipped:
type: integer
description: Number of changes skipped (e.g. no valid recipients)
changes:
type: integer
description: Number of status changes detected
required:
- version
- notifications
'400':
$ref: './responses.yaml#/400'
'401':
Expand Down
49 changes: 46 additions & 3 deletions src/controllers/llmo/llmo.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
import { queryLlmoFiles } from './llmo-query-handler.js';
import { updateModifiedByDetails } from './llmo-config-metadata.js';
import { handleLlmoRationale } from './llmo-rationale.js';
import { notifyStrategyChanges } from '../../support/opportunity-workspace-notifications.js';

const { readConfig, writeConfig } = llmo;
const { readStrategy, writeStrategy } = llmoStrategy;
Expand Down Expand Up @@ -1172,12 +1173,15 @@ function LlmoController(ctx) {

/**
* PUT /sites/{siteId}/llmo/strategy
* Saves LLMO strategy data to S3
* Saves LLMO strategy data to S3.
* Status changes trigger email notifications (when enabled).
* @param {object} context - Request context
* @returns {Promise<Response>} Version of the saved strategy
*/
const saveStrategy = async (context) => {
const { log, s3, data } = context;
const {
log, s3, data, dataAccess,
} = context;
const { siteId } = context.params;

try {
Expand All @@ -1193,13 +1197,52 @@ function LlmoController(ctx) {
return badRequest('LLMO strategy storage is not configured for this environment');
}

// Read previous strategy for diff (best-effort, null if not found)
let prevData = null;
let skipNotifications = false;
try {
const prev = await readStrategy(siteId, s3.s3Client, { s3Bucket: s3.s3Bucket });
if (prev.exists) {
prevData = prev.data;
}
} catch (readError) {
skipNotifications = true;
log.warn(`Could not read previous strategy for site ${siteId} (notifications will be skipped): ${readError.message}`);
}

log.info(`Writing LLMO strategy to S3 for siteId: ${siteId}`);
const { version } = await writeStrategy(siteId, data, s3.s3Client, {
s3Bucket: s3.s3Bucket,
});

log.info(`Successfully saved LLMO strategy for siteId: ${siteId}, version: ${version}`);
return ok({ version });

// Await notifications and include summary in response for debugging
let siteBaseUrl = '';
if (dataAccess?.Site) {
const site = await dataAccess.Site.findById(siteId);
siteBaseUrl = site?.getBaseURL?.() || '';
}
let notificationSummary = {
sent: 0, failed: 0, skipped: 0, changes: 0,
};
if (!skipNotifications) {
try {
notificationSummary = await notifyStrategyChanges(context, {
prevData,
nextData: data,
siteId,
siteBaseUrl,
});
if (notificationSummary.changes > 0) {
log.info(`Strategy notification summary for site ${siteId}: ${JSON.stringify(notificationSummary)}`);
}
} catch (err) {
log.error(`Strategy notification error for site ${siteId}: ${err.message}`);
}
}

return ok({ version, notifications: notificationSummary });
} catch (error) {
log.error(`Error saving llmo strategy for siteId: ${siteId}, error: ${error.message}`);
return badRequest(error.message);
Expand Down
117 changes: 117 additions & 0 deletions src/support/email-service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import { ImsClient } from '@adobe/spacecat-shared-ims-client';

/**
* Acquires an IMS service access token using email-specific credentials.
* Does NOT mutate context.env.
* @param {Object} context - The request context with env and log.
* @returns {Promise<string>} The access token string.
*/
export async function getEmailServiceToken(context) {
const { env } = context;

const emailEnv = {
...env,
IMS_CLIENT_ID: env.LLMO_EMAIL_IMS_CLIENT_ID,
IMS_CLIENT_SECRET: env.LLMO_EMAIL_IMS_CLIENT_SECRET,
IMS_CLIENT_CODE: env.LLMO_EMAIL_IMS_CLIENT_CODE,
IMS_SCOPE: env.LLMO_EMAIL_IMS_SCOPE,
};

const imsClient = ImsClient.createFrom({ ...context, env: emailEnv });

try {
const tokenPayload = await imsClient.getServiceAccessToken();
return tokenPayload.access_token;
} catch (error) {
context.log.error('[email-service] Failed to acquire IMS token', { error: error.message });
throw error;
}
}

/**
* Sends a templated email via Adobe Post Office.
*
* @param {Object} context - The request context (must include env and log).
* @param {Object} options
* @param {string[]} options.recipients - Array of email addresses.
* @param {string} options.templateName - Post Office template name.
* @param {Object<string,string>} [options.templateData] - Template variable key/value pairs.
* @param {string} [options.locale='en_US'] - Locale for the email.
* @param {string} [options.accessToken] - when provided, skips token acquisition.
* @returns {Promise<{success: boolean, statusCode: number, error?: string, templateUsed: string}>}
* Result object. Never throws by default.
*/
export async function sendEmail(context, {
recipients,
templateName,
templateData,
locale = 'en_US',
accessToken: providedToken,
}) {
const { env, log } = context;
const result = { success: false, statusCode: 0, templateUsed: templateName };

try {
if (!recipients || recipients.length === 0) {
result.error = 'No recipients provided';
return result;
}

if (!templateName) {
result.error = 'templateName is required';
return result;
}

const accessToken = providedToken ?? await getEmailServiceToken(context);
const postOfficeEndpoint = env.ADOBE_POSTOFFICE_ENDPOINT;

if (!postOfficeEndpoint) {
result.error = 'ADOBE_POSTOFFICE_ENDPOINT is not configured';
return result;
}

const body = JSON.stringify({
toList: recipients.join(','),
templateData: templateData || {},
});
const url = `${postOfficeEndpoint}/po-server/message?templateName=${encodeURIComponent(templateName)}&locale=${encodeURIComponent(locale)}`;

log.info(`[email-service] Sending ${templateName} email to ${recipients.length} recipient(s)`);

const response = await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `IMS ${accessToken}`,
'Content-Type': 'application/json',
},
body,
});

result.statusCode = response.status;
result.success = response.status === 200;

if (!result.success) {
const responseText = await response.text().catch(() => '(unable to read response body)');
result.error = `Post Office returned ${response.status}: ${responseText}`;
log.error(`Email send failed for template ${templateName}: ${result.error}`);
}
} catch (error) {
result.error = error.message;
log.error(`Email send error for template ${templateName}: ${error.message}`);
}

return result;
}
Loading