Replaces Neo JNDI mail sessions with manual creation using Destination service config in Java web apps. Detects web.xml resource-refs and JNDI lookups.
npx claudepluginhub sap-samples/btp-neo-java-app-migration --plugin sap-btp-neo-migrationThis skill is limited to using the following tools:
Configure mail sessions via the Destination service.
Guides Next.js Cache Components and Partial Prerendering (PPR): 'use cache' directives, cacheLife(), cacheTag(), revalidateTag() for caching, invalidation, static/dynamic optimization. Auto-activates on cacheComponents: true.
Processes PDFs: extracts text/tables/images, merges/splits/rotates pages, adds watermarks, creates/fills forms, encrypts/decrypts, OCRs scans. Activates on PDF mentions or output requests.
Share bugs, ideas, or general feedback.
Configure mail sessions via the Destination service.
Replace Neo's javax.mail.Session JNDI resource with manual session creation using mail server configuration from the Destination service.
This skill applies if any of these patterns are found:
<resource-ref>
<res-ref-name>mail/Session</res-ref-name>
<res-type>javax.mail.Session</res-type>
</resource-ref>
import javax.mail.Session;
import javax.annotation.Resource;
@Resource(name = "mail/Session")
private Session mailSession;
// OR JNDI lookup
Session session = (Session) ctx.lookup("java:comp/env/mail/Session");
Working directory: This skill must run inside the
-cf-migrationcopy of your app, created byjakarta-java25-migrationorneo-to-cf-migration-orchestrator. If your current directory does not end in-cf-migration, switch to it before proceeding.
Before invoking this skill, ensure you have invoked:
sdk-replacement - Use the sdk-replacement skill
destinations - Use the destinations skill
connectivity-onpremise - Use the connectivity-onpremise skill
Remove this from web.xml:
<resource-ref>
<res-ref-name>mail/Session</res-ref-name>
<res-type>javax.mail.Session</res-type>
</resource-ref>
Add to pom.xml:
<properties>
<jakarta-mail-version>2.0.1</jakarta-mail-version>
<jakarta-activation-version>2.0.1</jakarta-activation-version>
</properties>
<dependencies>
<!-- Jakarta Mail API -->
<dependency>
<groupId>com.sun.mail</groupId>
<artifactId>jakarta.mail</artifactId>
<version>${jakarta-mail-version}</version>
</dependency>
<!-- Jakarta Activation (required for mail) -->
<dependency>
<groupId>com.sun.activation</groupId>
<artifactId>jakarta.activation</artifactId>
<version>${jakarta-activation-version}</version>
</dependency>
</dependencies>
Copy the mail session helper classes from assets/session/ to your project's source directory. These classes handle mail session creation from destination configuration.
Required files:
MailSession.java - Main session factoryMailAuthenticator.java - Password authenticationMailPropertiesHandler.java - Extracts mail properties from destinationOnPremiseSMTPProvider.java - Custom SMTP provider for on-premiseOnPremiseSMTPTransport.java - SMTP transport via Cloud ConnectorConnectivitySocks5ProxySocket.java - SOCKS5 proxy for on-premise connectivityCopy these to src/main/java/com/yourpackage/mail/session/.
Alternative: Simple MailSessionFactory
For simpler scenarios (internet mail only), you can create a minimal MailSessionFactory.java:
package com.example.mail;
import com.sap.cloud.sdk.cloudplatform.connectivity.Destination;
import com.sap.cloud.sdk.cloudplatform.connectivity.DestinationAccessor;
import jakarta.mail.Authenticator;
import jakarta.mail.PasswordAuthentication;
import jakarta.mail.Session;
import java.util.Optional;
import java.util.Properties;
public class MailSessionFactory {
/**
* Create mail session from destination configuration
*/
public static Session createSession(String destinationName) {
Destination destination = DestinationAccessor.getDestination(destinationName);
// Get mail server properties from destination
String host = getProperty(destination, "mail.smtp.host")
.orElseGet(() -> extractHost(destination));
String port = getProperty(destination, "mail.smtp.port")
.orElse("587");
String user = getProperty(destination, "mail.user")
.orElse(getProperty(destination, "User").orElse(null));
String password = getProperty(destination, "mail.password")
.orElse(getProperty(destination, "Password").orElse(null));
boolean auth = getProperty(destination, "mail.smtp.auth")
.map(Boolean::parseBoolean)
.orElse(user != null);
boolean starttls = getProperty(destination, "mail.smtp.starttls.enable")
.map(Boolean::parseBoolean)
.orElse(true);
Properties props = new Properties();
props.put("mail.smtp.host", host);
props.put("mail.smtp.port", port);
props.put("mail.smtp.auth", String.valueOf(auth));
props.put("mail.smtp.starttls.enable", String.valueOf(starttls));
// Store user/password in session properties for transport.connect() usage
if (user != null) props.put("mail.smtp.user", user);
if (password != null) props.put("mail.smtp.password", password);
// Add any additional properties from destination
addOptionalProperty(props, destination, "mail.smtp.ssl.enable");
addOptionalProperty(props, destination, "mail.smtp.ssl.trust");
addOptionalProperty(props, destination, "mail.smtp.ssl.checkserveridentity");
addOptionalProperty(props, destination, "mail.smtp.connectiontimeout");
addOptionalProperty(props, destination, "mail.smtp.timeout");
addOptionalProperty(props, destination, "mail.smtp.from");
addOptionalProperty(props, destination, "mail.from");
if (auth && user != null && password != null) {
final String finalUser = user;
final String finalPassword = password;
return Session.getInstance(props, new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(finalUser, finalPassword);
}
});
} else {
return Session.getInstance(props);
}
}
private static String extractHost(Destination destination) {
// Try to extract host from URL property
return getProperty(destination, "URL")
.map(url -> {
try {
return new java.net.URL(url).getHost();
} catch (Exception e) {
return url; // Return as-is if not a valid URL
}
})
.orElseThrow(() -> new RuntimeException("Mail host not configured"));
}
private static Optional<String> getProperty(Destination destination, String key) {
return destination.get(key).toJavaOptional()
.map(Object::toString);
}
private static void addOptionalProperty(Properties props, Destination destination, String key) {
getProperty(destination, key).ifPresent(value -> props.put(key, value));
}
}
Before (Neo):
import javax.annotation.Resource;
import javax.mail.*;
import javax.mail.internet.*;
import javax.servlet.http.*;
public class MailServlet extends HttpServlet {
@Resource(name = "mail/Session")
private Session mailSession;
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String to = request.getParameter("to");
String subject = request.getParameter("subject");
String body = request.getParameter("body");
try {
Message message = new MimeMessage(mailSession);
message.setFrom(new InternetAddress("sender@example.com"));
message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(to));
message.setSubject(subject);
message.setText(body);
Transport.send(message);
response.getWriter().println("Email sent successfully");
} catch (MessagingException e) {
throw new ServletException("Failed to send email", e);
}
}
}
After (Cloud Foundry):
import com.example.mail.MailSessionFactory;
import jakarta.mail.*;
import jakarta.mail.internet.*;
import jakarta.servlet.http.*;
public class MailServlet extends HttpServlet {
private static final String MAIL_DESTINATION = "mail-destination";
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String to = request.getParameter("to");
String subject = request.getParameter("subject");
String body = request.getParameter("body");
try {
// Create session from destination
Session mailSession = MailSessionFactory.createSession(MAIL_DESTINATION);
Message message = new MimeMessage(mailSession);
// Use mail.smtp.from from destination instead of hardcoded address
String from = mailSession.getProperty("mail.smtp.from");
if (from == null) from = mailSession.getProperty("mail.from");
message.setFrom(new InternetAddress(from != null ? from : "sender@example.com"));
message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(to));
message.setSubject(subject);
message.setText(body);
// Important: use Transport instance with explicit credentials.
// Transport.send(message) does NOT pass credentials from session properties.
Transport transport = mailSession.getTransport();
String user = mailSession.getProperty("mail.smtp.user");
String password = mailSession.getProperty("mail.smtp.password");
try {
if (user != null) {
transport.connect(
mailSession.getProperty("mail.smtp.host"),
Integer.parseInt(mailSession.getProperty("mail.smtp.port")),
user, password);
} else {
transport.connect();
}
transport.sendMessage(message, message.getAllRecipients());
} finally {
transport.close();
}
response.getWriter().println("Email sent successfully");
} catch (MessagingException e) {
throw new ServletException("Failed to send email", e);
}
}
}
Important: Do not use
Transport.send(message)— it creates a new transport internally and does NOT pass user/password from session properties. Always usetransport.connect(host, port, user, password)explicitly.
Create destination in BTP Cockpit:
| Property | Value |
|---|---|
| Name | mail-destination |
| Type | HTTP |
| URL | smtp://smtp.example.com |
| Proxy Type | Internet |
| Authentication | BasicAuthentication |
| User | your-email@example.com |
| Password | your-password |
Additional Properties:
| Property | Value |
|---|---|
mail.smtp.host | smtp.example.com |
mail.smtp.port | 587 |
mail.smtp.auth | true |
mail.smtp.starttls.enable | true |
mail.smtp.from | sender@example.com |
mail.smtp.ssl.checkserveridentity | true |
Important: Use
mail.smtp.fromto set the sender address. Do NOT hardcode sender addresses likenoreply@mail.hana.ondemand.comin your code — these Neo-era addresses won't work on CF. Themail.smtp.fromaddress must be verified with your SMTP provider.
For on-premise mail servers via Cloud Connector:
| Property | Value |
|---|---|
| Name | mail-destination |
| Type | HTTP |
| URL | smtp://mail-virtual-host:25 |
| Proxy Type | OnPremise |
| Authentication | NoAuthentication (or as needed) |
Cloud Connector Configuration:
mail.internal.company.com25mail-virtual-host25modules:
- name: ${app-name}
type: java.tomcat
path: target/${app-name}.war
parameters:
buildpack: sap_java_buildpack_jakarta
disk-quota: 512MB
memory: 512MB
properties:
ENABLE_SECURITY_JAVA_API_V2: true
SET_LOGGING_LEVEL: 'ROOT: INFO'
requires:
- name: ${app-name}-destination
- name: ${app-name}-connectivity # Only needed for on-premise
resources:
- name: ${app-name}-destination
type: org.cloudfoundry.managed-service
parameters:
service: destination
service-plan: lite
- name: ${app-name}-connectivity
type: org.cloudfoundry.managed-service
parameters:
service: connectivity
service-plan: lite
No new configuration files required. Mail configuration is stored in destinations.
| Service | Plan | Purpose |
|---|---|---|
destination | lite | Store mail server configuration |
connectivity | lite | Required for on-premise mail servers |
mvn clean compile
In BTP Cockpit, test the destination connection.
curl -X POST "https://${app-url}/mail" \
-d "to=test@example.com" \
-d "subject=Test Email" \
-d "body=Hello from Cloud Foundry!"
cf logs ${app-name} --recent | grep -i mail
transport.connect()Cause: Transport.connect() without arguments does not read credentials from the mail session properties. The destination service provides credentials as mail.user and mail.password (with mail. prefix), but transport.connect() only reads mail.smtp.user from session properties and never reads any password property automatically.
Solution: Extract user/password from the destination properties and pass them explicitly:
String user = destProperties.getOrDefault("User", destProperties.get("mail.user"));
String password = destProperties.getOrDefault("Password", destProperties.get("mail.password"));
if (user != null && !user.isBlank()) {
String host = session.getProperty("mail.smtp.host");
int port = Integer.parseInt(session.getProperty("mail.smtp.port"));
transport.connect(host, port, user, password);
} else {
transport.connect();
}
Cause: Microsoft has disabled basic authentication (username + password) for SMTP on Office 365 / Outlook.com accounts by default. Solution:
Set-CASMailbox -Identity "user@domain.com" -SmtpClientAuthenticationDisabled $falseCause: Neo applications often hardcode a sender address like noreply@mail.hana.ondemand.com in the message builder. After migration, the SMTP service rejects this unverified address.
Solution: Use mail.smtp.from from the destination configuration instead of a hardcoded address:
String fromAddress = mailSession.getProperty("mail.smtp.from");
if (fromAddress == null || fromAddress.isBlank()) {
fromAddress = DEFAULT_MAIL_FROM; // fallback
}
message.setFrom(new InternetAddress(fromAddress));
Make sure to configure mail.smtp.from in the BTP destination with a verified sender address.
mail.* prefixCause: The SAP Cloud SDK returns destination properties with their original keys. Mail destinations in BTP Cockpit use mail.user and mail.password (with mail. prefix), not the SDK's typical User/Password keys.
Solution: When extracting credentials, check both key formats:
String user = destProperties.getOrDefault("User", destProperties.get("mail.user"));
String password = destProperties.getOrDefault("Password", destProperties.get("mail.password"));
Cause: Invalid credentials or app password required. Solution:
Cause: Port blocked or wrong server. Solution:
Solution: Add destination properties:
mail.smtp.ssl.trust=*
mail.smtp.ssl.enable=true
Cause: Cloud Connector not configured for TCP. Solution: Add TCP protocol mapping in Cloud Connector.
After completing this skill, proceed to other applicable skills: