Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
2 changes: 1 addition & 1 deletion api/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
Expand Down
200 changes: 200 additions & 0 deletions api/src/main/java/com/google/appengine/api/mail/MailServiceImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,26 @@
import com.google.apphosting.api.ApiProxy;
import com.google.protobuf.ByteString;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Properties;
import javax.activation.DataHandler;
import javax.activation.DataSource;
import javax.mail.Address;
import javax.mail.BodyPart;
import javax.mail.Message.RecipientType;
import javax.mail.MessagingException;
import javax.mail.Multipart;
import javax.mail.Session;
import javax.mail.Transport;
import javax.mail.internet.AddressException;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeBodyPart;
import javax.mail.internet.MimeMessage;
import javax.mail.internet.MimeMultipart;
import javax.mail.util.ByteArrayDataSource;

/**
* This class implements raw access to the mail service.
Expand All @@ -33,6 +53,17 @@
*/
class MailServiceImpl implements MailService {
static final String PACKAGE = "mail";
private final SystemEnvironmentProvider envProvider;

/** Default constructor, used in production. */
MailServiceImpl() {
this(new SystemEnvironmentProvider());
}

/** Constructor for testing, allowing a mock environment provider. */
MailServiceImpl(SystemEnvironmentProvider envProvider) {
this.envProvider = envProvider;
}

/** {@inheritDoc} */
@Override
Expand All @@ -48,13 +79,182 @@ public void send(Message message)
doSend(message, false);
}

private void sendSmtp(Message message, boolean toAdmin)
throws IllegalArgumentException, IOException {
String smtpHost = envProvider.getenv("SMTP_HOST");
Comment thread
abhishekr700 marked this conversation as resolved.
Outdated
if (smtpHost == null || smtpHost.isEmpty()) {
throw new IllegalArgumentException("SMTP_HOST environment variable is not set.");
}
Properties props = new Properties();
props.put("mail.smtp.host", smtpHost);
props.put("mail.smtp.port", envProvider.getenv("SMTP_PORT"));
props.put("mail.smtp.auth", "true");
if (Boolean.parseBoolean(envProvider.getenv("SMTP_USE_TLS"))) {
props.put("mail.smtp.starttls.enable", "true");
}

Session session = Session.getInstance(props, new javax.mail.Authenticator() {
protected javax.mail.PasswordAuthentication getPasswordAuthentication() {
return new javax.mail.PasswordAuthentication(
envProvider.getenv("SMTP_USER"), envProvider.getenv("SMTP_PASSWORD"));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PASSWORD: does it mean it is in clear in config file? also visible when we dump env vars?
Might need to find a more secure solution, maybe https://stackoverflow.com/questions/58371905/how-to-handle-secrets-in-google-app-engine or other best practice.

}
});

try {
MimeMessage mimeMessage = new MimeMessage(session);
mimeMessage.setFrom(new InternetAddress(message.getSender()));

List<InternetAddress> toRecipients = new ArrayList<>();
List<InternetAddress> ccRecipients = new ArrayList<>();
List<InternetAddress> bccRecipients = new ArrayList<>();

if (toAdmin) {
String adminRecipients = envProvider.getenv("ADMIN_EMAIL_RECIPIENTS");
if (adminRecipients == null || adminRecipients.isEmpty()) {
throw new IllegalArgumentException("Admin recipients not configured.");
}
toRecipients.addAll(Arrays.asList(InternetAddress.parse(adminRecipients)));
} else {
if (message.getTo() != null) {
toRecipients.addAll(toInternetAddressList(message.getTo()));
}
if (message.getCc() != null) {
ccRecipients.addAll(toInternetAddressList(message.getCc()));
}
if (message.getBcc() != null) {
bccRecipients.addAll(toInternetAddressList(message.getBcc()));
}
}

List<Address> allTransportRecipients = new ArrayList<>();
allTransportRecipients.addAll(toRecipients);
allTransportRecipients.addAll(ccRecipients);
allTransportRecipients.addAll(bccRecipients);

if (allTransportRecipients.isEmpty()) {
throw new IllegalArgumentException("No recipients specified.");
}

if (!toRecipients.isEmpty()) {
mimeMessage.setRecipients(RecipientType.TO, toRecipients.toArray(new Address[0]));
}
if (!ccRecipients.isEmpty()) {
mimeMessage.setRecipients(RecipientType.CC, ccRecipients.toArray(new Address[0]));
}
// Bcc recipients are not set on the MimeMessage to prevent them from being in the headers.

if (message.getReplyTo() != null) {
mimeMessage.setReplyTo(new Address[] {new InternetAddress(message.getReplyTo())});
}

mimeMessage.setSubject(message.getSubject());

final boolean hasAttachments = message.getAttachments() != null && !message.getAttachments().isEmpty();
final boolean hasHtmlBody = message.getHtmlBody() != null;
final boolean hasAmpHtmlBody = message.getAmpHtmlBody() != null;
final boolean hasTextBody = message.getTextBody() != null;

// Case 1: Plain text only, no attachments. Simplest case.
if (hasTextBody && !hasHtmlBody && !hasAmpHtmlBody && !hasAttachments) {
mimeMessage.setText(message.getTextBody());
} else {
// Case 2: Anything more complex requires multipart.
MimeMultipart topLevelMultipart = new MimeMultipart("mixed");

// The bodies (text, html, amp) are grouped in a "multipart/alternative"
if (hasTextBody || hasHtmlBody || hasAmpHtmlBody) {
MimeMultipart alternativeMultipart = new MimeMultipart("alternative");
MimeBodyPart alternativeBodyPart = new MimeBodyPart();
alternativeBodyPart.setContent(alternativeMultipart);

if (hasTextBody) {
MimeBodyPart textPart = new MimeBodyPart();
textPart.setText(message.getTextBody());
alternativeMultipart.addBodyPart(textPart);
} else if (hasHtmlBody) {
// If there is an HTML body but no text body, add an empty text part for compatibility.
MimeBodyPart textPart = new MimeBodyPart();
textPart.setText("");
alternativeMultipart.addBodyPart(textPart);
}

if (hasHtmlBody) {
MimeBodyPart htmlPart = new MimeBodyPart();
htmlPart.setContent(message.getHtmlBody(), "text/html");
alternativeMultipart.addBodyPart(htmlPart);
}
if (hasAmpHtmlBody) {
MimeBodyPart ampPart = new MimeBodyPart();
ampPart.setContent(message.getAmpHtmlBody(), "text/x-amp-html");
alternativeMultipart.addBodyPart(ampPart);
}
topLevelMultipart.addBodyPart(alternativeBodyPart);
}

// Add attachments to the top-level mixed part.
if (hasAttachments) {
for (Attachment attachment : message.getAttachments()) {
MimeBodyPart attachmentBodyPart = new MimeBodyPart();
DataSource source =
new ByteArrayDataSource(attachment.getData(), "application/octet-stream");
attachmentBodyPart.setDataHandler(new DataHandler(source));
attachmentBodyPart.setFileName(attachment.getFileName());
if (attachment.getContentID() != null) {
attachmentBodyPart.setContentID(attachment.getContentID());
}
topLevelMultipart.addBodyPart(attachmentBodyPart);
}
}
mimeMessage.setContent(topLevelMultipart);
}

if (message.getHeaders() != null) {
for (Header header : message.getHeaders()) {
mimeMessage.addHeader(header.getName(), header.getValue());
}
}

// Update headers to match content, e.g., setting the Content-Type
mimeMessage.saveChanges();

Transport transport = session.getTransport("smtp");
try {
transport.connect();
transport.sendMessage(mimeMessage, allTransportRecipients.toArray(new Address[0]));
} finally {
if (transport != null) {
transport.close();
}
}

} catch (MessagingException e) {
if (e instanceof javax.mail.AuthenticationFailedException) {
throw new IllegalArgumentException("SMTP authentication failed: " + e.getMessage(), e);
}
throw new IOException("Error sending email via SMTP: " + e.getMessage(), e);
}
}

private List<InternetAddress> toInternetAddressList(Collection<String> addresses)
throws AddressException {
List<InternetAddress> list = new ArrayList<>();
for (String address : addresses) {
list.add(new InternetAddress(address));
}
return list;
}

/**
* Does the actual sending of the message.
* @param message The message to be sent.
* @param toAdmin Whether the message is to be sent to the admins.
*/
private void doSend(Message message, boolean toAdmin)
throws IllegalArgumentException, IOException {
if ("true".equals(envProvider.getenv("USE_SMTP_MAIL_SERVICE"))) {
sendSmtp(message, toAdmin);
return;
}
Comment thread
abhishekr700 marked this conversation as resolved.
Outdated
// Could perform basic checks to save on RPCs in case of missing args etc.
// I'm not doing this on purpose, to make sure the semantics of the two
// implementations stay the same.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.google.appengine.api.mail;

/**
* A simple wrapper around {@link System} to allow for easier testing.
*/
class SystemEnvironmentProvider {
Comment thread
abhishekr700 marked this conversation as resolved.
Outdated
/**
* Gets the value of the specified environment variable.
* @param name the name of the environment variable
* @return the string value of the variable, or {@code null} if the variable is not defined
*/
public String getenv(String name) {
return System.getenv(name);
}

/**
* Gets the value of the specified environment variable, returning a default value if the
* variable is not defined.
* @param name the name of the environment variable
* @param defaultValue the default value to return
* @return the string value of the variable, or the default value if the variable is not defined
*/
public String getenv(String name, String defaultValue) {
String value = System.getenv(name);
return value != null ? value : defaultValue;
}
}
Loading
Loading