diff --git a/api/pom.xml b/api/pom.xml index e18ba55d2..95d6e5290 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -137,7 +137,7 @@ org.mockito - mockito-junit-jupiter + mockito-core test diff --git a/api/src/main/java/com/google/appengine/api/mail/EnvironmentProvider.java b/api/src/main/java/com/google/appengine/api/mail/EnvironmentProvider.java new file mode 100644 index 000000000..74be3c2d1 --- /dev/null +++ b/api/src/main/java/com/google/appengine/api/mail/EnvironmentProvider.java @@ -0,0 +1,22 @@ +package com.google.appengine.api.mail; + +/** + * An interface for providing environment variables. + */ +interface EnvironmentProvider { + /** + * 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 + */ + String getenv(String 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 + */ + String getenv(String name, String defaultValue); +} diff --git a/api/src/main/java/com/google/appengine/api/mail/MailServiceImpl.java b/api/src/main/java/com/google/appengine/api/mail/LegacyMailServiceImpl.java similarity index 86% rename from api/src/main/java/com/google/appengine/api/mail/MailServiceImpl.java rename to api/src/main/java/com/google/appengine/api/mail/LegacyMailServiceImpl.java index 55700f61b..f484eb858 100644 --- a/api/src/main/java/com/google/appengine/api/mail/MailServiceImpl.java +++ b/api/src/main/java/com/google/appengine/api/mail/LegacyMailServiceImpl.java @@ -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. @@ -31,9 +51,12 @@ * convenience methods that JavaMail provides. * */ -class MailServiceImpl implements MailService { +class LegacyMailServiceImpl implements MailService { static final String PACKAGE = "mail"; + /** Default constructor. */ + LegacyMailServiceImpl() {} + /** {@inheritDoc} */ @Override public void sendToAdmins(Message message) @@ -47,7 +70,7 @@ public void send(Message message) throws IllegalArgumentException, IOException { doSend(message, false); } - + /** * Does the actual sending of the message. * @param message The message to be sent. diff --git a/api/src/main/java/com/google/appengine/api/mail/MailServiceFactoryImpl.java b/api/src/main/java/com/google/appengine/api/mail/MailServiceFactoryImpl.java index a3d05702b..bbf2862df 100644 --- a/api/src/main/java/com/google/appengine/api/mail/MailServiceFactoryImpl.java +++ b/api/src/main/java/com/google/appengine/api/mail/MailServiceFactoryImpl.java @@ -21,8 +21,22 @@ */ final class MailServiceFactoryImpl implements IMailServiceFactory { + private final EnvironmentProvider envProvider; + + MailServiceFactoryImpl() { + this.envProvider = new SystemEnvironmentProvider(); + } + + // For testing + MailServiceFactoryImpl(EnvironmentProvider envProvider) { + this.envProvider = envProvider; + } + @Override public MailService getMailService() { - return new MailServiceImpl(); + if ("true".equals(envProvider.getenv("APPENGINE_USE_SMTP_MAIL_SERVICE"))) { + return new SmtpMailServiceImpl(envProvider); + } + return new LegacyMailServiceImpl(); } } diff --git a/api/src/main/java/com/google/appengine/api/mail/SmtpMailServiceImpl.java b/api/src/main/java/com/google/appengine/api/mail/SmtpMailServiceImpl.java new file mode 100644 index 000000000..d3a0cd71d --- /dev/null +++ b/api/src/main/java/com/google/appengine/api/mail/SmtpMailServiceImpl.java @@ -0,0 +1,220 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed 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 + * + * https://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 CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.appengine.api.mail; + +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.Message.RecipientType; +import javax.mail.MessagingException; +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 the MailService interface using an external SMTP server. + */ +class SmtpMailServiceImpl implements MailService { + private final EnvironmentProvider envProvider; + + /** + * Constructor. + * @param envProvider The provider for environment variables. + */ + SmtpMailServiceImpl(EnvironmentProvider envProvider) { + this.envProvider = envProvider; + } + + @Override + public void send(Message message) throws IOException { + sendSmtp(message, false); + } + + @Override + public void sendToAdmins(Message message) throws IOException { + sendSmtp(message, true); + } + + private void sendSmtp(Message message, boolean toAdmin) + throws IllegalArgumentException, IOException { + String smtpHost = envProvider.getenv("APPENGINE_SMTP_HOST"); + 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("APPENGINE_SMTP_PORT")); + props.put("mail.smtp.auth", "true"); + if (Boolean.parseBoolean(envProvider.getenv("APPENGINE_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("APPENGINE_SMTP_USER"), envProvider.getenv("APPENGINE_SMTP_PASSWORD")); + } + }); + + try { + MimeMessage mimeMessage = new MimeMessage(session); + mimeMessage.setFrom(new InternetAddress(message.getSender())); + + List toRecipients = new ArrayList<>(); + List ccRecipients = new ArrayList<>(); + List bccRecipients = new ArrayList<>(); + + if (toAdmin) { + String adminRecipients = envProvider.getenv("APPENGINE_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
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])); + } + + 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; + + if (hasTextBody && !hasHtmlBody && !hasAmpHtmlBody && !hasAttachments) { + mimeMessage.setText(message.getTextBody()); + } else { + MimeMultipart topLevelMultipart = new MimeMultipart("mixed"); + + 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) { + 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); + } + + 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()); + } + } + + 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 toInternetAddressList(Collection addresses) + throws AddressException { + List list = new ArrayList<>(); + for (String address : addresses) { + list.add(new InternetAddress(address)); + } + return list; + } +} diff --git a/api/src/main/java/com/google/appengine/api/mail/SystemEnvironmentProvider.java b/api/src/main/java/com/google/appengine/api/mail/SystemEnvironmentProvider.java new file mode 100644 index 000000000..a5807bfa5 --- /dev/null +++ b/api/src/main/java/com/google/appengine/api/mail/SystemEnvironmentProvider.java @@ -0,0 +1,29 @@ +package com.google.appengine.api.mail; + +/** + * A simple wrapper around {@link System} to allow for easier testing. + */ +class SystemEnvironmentProvider implements EnvironmentProvider { + /** + * 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 + */ + @Override + 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 + */ + @Override + public String getenv(String name, String defaultValue) { + String value = System.getenv(name); + return value != null ? value : defaultValue; + } +} diff --git a/api/src/test/java/com/google/appengine/api/mail/MailServiceFactoryImplTest.java b/api/src/test/java/com/google/appengine/api/mail/MailServiceFactoryImplTest.java new file mode 100644 index 000000000..d0ae0bb73 --- /dev/null +++ b/api/src/test/java/com/google/appengine/api/mail/MailServiceFactoryImplTest.java @@ -0,0 +1,36 @@ +package com.google.appengine.api.mail; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.when; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; + +@RunWith(MockitoJUnitRunner.class) +public class MailServiceFactoryImplTest { + + @Mock private EnvironmentProvider envProvider; + + @Test + public void testGetMailService_smtp() { + when(envProvider.getenv("APPENGINE_USE_SMTP_MAIL_SERVICE")).thenReturn("true"); + MailServiceFactoryImpl factory = new MailServiceFactoryImpl(envProvider); + assertTrue(factory.getMailService() instanceof SmtpMailServiceImpl); + } + + @Test + public void testGetMailService_legacy() { + when(envProvider.getenv("APPENGINE_USE_SMTP_MAIL_SERVICE")).thenReturn("false"); + MailServiceFactoryImpl factory = new MailServiceFactoryImpl(envProvider); + assertTrue(factory.getMailService() instanceof LegacyMailServiceImpl); + } + + @Test + public void testGetMailService_legacy_null() { + when(envProvider.getenv("APPENGINE_USE_SMTP_MAIL_SERVICE")).thenReturn(null); + MailServiceFactoryImpl factory = new MailServiceFactoryImpl(envProvider); + assertTrue(factory.getMailService() instanceof LegacyMailServiceImpl); + } +} diff --git a/api/src/test/java/com/google/appengine/api/mail/SmtpMailServiceImplTest.java b/api/src/test/java/com/google/appengine/api/mail/SmtpMailServiceImplTest.java new file mode 100644 index 000000000..aa78bce6e --- /dev/null +++ b/api/src/test/java/com/google/appengine/api/mail/SmtpMailServiceImplTest.java @@ -0,0 +1,641 @@ +package com.google.appengine.api.mail; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doThrow; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.Properties; +import javax.mail.Address; +import javax.mail.Message.RecipientType; +import javax.mail.MessagingException; +import javax.mail.Session; +import javax.mail.Transport; +import javax.mail.internet.MimeBodyPart; +import javax.mail.internet.MimeMessage; +import javax.mail.internet.MimeMultipart; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.MockitoJUnitRunner; + +// TODO: Remove Silent runner and use default runner once all tests are implemented and unnecessary stubs are removed. +@RunWith(MockitoJUnitRunner.Silent.class) +public class SmtpMailServiceImplTest { + + @Mock private Transport transport; + @Mock private Session session; + @Mock private EnvironmentProvider envProvider; + + private SmtpMailServiceImpl mailService; + + @Before + public void setUp() { + mailService = new SmtpMailServiceImpl(envProvider); + // Mock environment variables + when(envProvider.getenv("APPENGINE_SMTP_HOST")).thenReturn("smtp.example.com"); + when(envProvider.getenv("APPENGINE_SMTP_PORT")).thenReturn("587"); + when(envProvider.getenv("APPENGINE_SMTP_USER")).thenReturn("user"); + when(envProvider.getenv("APPENGINE_SMTP_PASSWORD")).thenReturn("password"); + when(envProvider.getenv("APPENGINE_SMTP_USE_TLS")).thenReturn("true"); + } + + @Test + public void testSendSmtp_basic() throws IOException, MessagingException { + // Setup + // Use try-with-resources for MockedStatic for Session + try (MockedStatic mockedSession = mockStatic(Session.class)) { + mockedSession.when(() -> Session.getInstance(any(Properties.class), any())).thenReturn(session); + when(session.getTransport("smtp")).thenReturn(transport); + + // Create the message to send + MailService.Message message = new MailService.Message(); + message.setSender("sender@example.com"); + message.setTo(Collections.singletonList("to@example.com")); + message.setCc(Collections.singletonList("cc@example.com")); + message.setBcc(Collections.singletonList("bcc@example.com")); + message.setSubject("Test Subject"); + message.setTextBody("Test Body"); + + // Act + // Call the method under test + mailService.send(message); + + // Assert + // Capture the arguments passed to transport.sendMessage + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(MimeMessage.class); + ArgumentCaptor recipientsCaptor = ArgumentCaptor.forClass(Address[].class); + verify(transport, times(1)) + .sendMessage(messageCaptor.capture(), recipientsCaptor.capture()); + + // Assertions for the MimeMessage + MimeMessage sentMessage = messageCaptor.getValue(); + assertEquals("Test Subject", sentMessage.getSubject()); + assertEquals("sender@example.com", sentMessage.getFrom()[0].toString()); + assertEquals("to@example.com", sentMessage.getRecipients(RecipientType.TO)[0].toString()); + assertEquals("cc@example.com", sentMessage.getRecipients(RecipientType.CC)[0].toString()); + + Address[] bccRecipients = sentMessage.getRecipients(RecipientType.BCC); + assertTrue( + "BCC recipients should not be in the message headers", + bccRecipients == null || bccRecipients.length == 0); + + // Assertions for the recipient list passed to the transport layer + Address[] allRecipients = recipientsCaptor.getValue(); + assertEquals(3, allRecipients.length); + assertTrue( + "Recipient list should contain TO address", + Arrays.stream(allRecipients).anyMatch(a -> a.toString().equals("to@example.com"))); + assertTrue( + "Recipient list should contain CC address", + Arrays.stream(allRecipients).anyMatch(a -> a.toString().equals("cc@example.com"))); + assertTrue( + "Recipient list should contain BCC address", + Arrays.stream(allRecipients).anyMatch(a -> a.toString().equals("bcc@example.com"))); + } + } + + @Test + public void testSendSmtp_multipleRecipients() throws IOException, MessagingException { + // Setup + try (MockedStatic mockedSession = mockStatic(Session.class)) { + mockedSession.when(() -> Session.getInstance(any(Properties.class), any())).thenReturn(session); + when(session.getTransport("smtp")).thenReturn(transport); + + // Create the message with multiple recipients + MailService.Message message = new MailService.Message(); + message.setSender("sender@example.com"); + message.setTo(Arrays.asList("to1@example.com", "to2@example.com")); + message.setCc(Arrays.asList("cc1@example.com", "cc2@example.com")); + message.setBcc(Arrays.asList("bcc1@example.com", "bcc2@example.com")); + message.setSubject("Multiple Recipients Test"); + message.setTextBody("Test Body"); + + // Act + mailService.send(message); + + // Assert + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(MimeMessage.class); + ArgumentCaptor recipientsCaptor = ArgumentCaptor.forClass(Address[].class); + verify(transport, times(1)) + .sendMessage(messageCaptor.capture(), recipientsCaptor.capture()); + + // Assertions for the MimeMessage headers + MimeMessage sentMessage = messageCaptor.getValue(); + assertEquals("to1@example.com, to2@example.com", sentMessage.getHeader("To", ", ")); + assertEquals("cc1@example.com, cc2@example.com", sentMessage.getHeader("Cc", ", ")); + + // Assertions for the recipient list passed to the transport layer + Address[] allRecipients = recipientsCaptor.getValue(); + assertEquals(6, allRecipients.length); + assertTrue( + "Recipient list should contain all TO addresses", + Arrays.stream(allRecipients) + .map(Address::toString) + .anyMatch(s -> s.equals("to1@example.com") || s.equals("to2@example.com"))); + assertTrue( + "Recipient list should contain all CC addresses", + Arrays.stream(allRecipients) + .map(Address::toString) + .anyMatch(s -> s.equals("cc1@example.com") || s.equals("cc2@example.com"))); + assertTrue( + "Recipient list should contain all BCC addresses", + Arrays.stream(allRecipients) + .map(Address::toString) + .anyMatch(s -> s.equals("bcc1@example.com") || s.equals("bcc2@example.com"))); + } + } + + @Test + public void testSendSmtp_htmlAndPlainTextBodies() throws IOException, MessagingException { + // Setup + try (MockedStatic mockedSession = mockStatic(Session.class)) { + mockedSession.when(() -> Session.getInstance(any(Properties.class), any())).thenReturn(session); + when(session.getTransport("smtp")).thenReturn(transport); + + // Create the message with HTML and plain text bodies + MailService.Message message = new MailService.Message(); + message.setSender("sender@example.com"); + message.setTo("to@example.com"); + message.setSubject("HTML and Plain Text Test"); + message.setTextBody("This is the plain text body."); + message.setHtmlBody("

This is the HTML body.

"); + + // Act + mailService.send(message); + + // Assert + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(MimeMessage.class); + verify(transport).sendMessage(messageCaptor.capture(), any()); + + MimeMessage sentMessage = messageCaptor.getValue(); + assertTrue(sentMessage.getHeader("Content-Type")[0].startsWith("multipart/mixed")); + + // Further inspection of the multipart content can be added here + // For example, checking the content of each part of the multipart message + MimeMultipart mixedMultipart = (MimeMultipart) sentMessage.getContent(); + assertEquals(1, mixedMultipart.getCount()); + + // Check the nested multipart/alternative part + MimeBodyPart alternativePart = (MimeBodyPart) mixedMultipart.getBodyPart(0); + assertTrue(alternativePart.getContentType().startsWith("multipart/alternative")); + MimeMultipart alternativeMultipart = (MimeMultipart) alternativePart.getContent(); + assertEquals(2, alternativeMultipart.getCount()); + + // Check the plain text part + MimeBodyPart textPart = (MimeBodyPart) alternativeMultipart.getBodyPart(0); + assertTrue(textPart.isMimeType("text/plain")); + assertEquals("This is the plain text body.", textPart.getContent()); + + // Check the HTML part + MimeBodyPart htmlPart = (MimeBodyPart) alternativeMultipart.getBodyPart(1); + assertTrue(htmlPart.isMimeType("text/html")); + assertEquals("

This is the HTML body.

", htmlPart.getContent()); + } + } + + @Test + public void testSendSmtp_htmlBodyOnly() throws IOException, MessagingException { + // Setup + try (MockedStatic mockedSession = mockStatic(Session.class)) { + mockedSession.when(() -> Session.getInstance(any(Properties.class), any())).thenReturn(session); + when(session.getTransport("smtp")).thenReturn(transport); + + // Create the message with only an HTML body + MailService.Message message = new MailService.Message(); + message.setSender("sender@example.com"); + message.setTo("to@example.com"); + message.setSubject("HTML Body Only Test"); + message.setHtmlBody("

This is the HTML body.

"); + + // Act + mailService.send(message); + + // Assert + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(MimeMessage.class); + verify(transport).sendMessage(messageCaptor.capture(), any()); + + MimeMessage sentMessage = messageCaptor.getValue(); + assertTrue(sentMessage.getHeader("Content-Type")[0].startsWith("multipart/mixed")); + + // Check the multipart content + MimeMultipart mixedMultipart = (MimeMultipart) sentMessage.getContent(); + assertEquals(1, mixedMultipart.getCount()); + + // Check the nested multipart/alternative part + MimeBodyPart alternativePart = (MimeBodyPart) mixedMultipart.getBodyPart(0); + assertTrue(alternativePart.getContentType().startsWith("multipart/alternative")); + MimeMultipart alternativeMultipart = (MimeMultipart) alternativePart.getContent(); + assertEquals(2, alternativeMultipart.getCount()); + + // Check that the plain text part is empty + MimeBodyPart textPart = (MimeBodyPart) alternativeMultipart.getBodyPart(0); + assertTrue(textPart.isMimeType("text/plain")); + assertEquals("", textPart.getContent()); + + // Check the HTML part + MimeBodyPart htmlPart = (MimeBodyPart) alternativeMultipart.getBodyPart(1); + assertTrue(htmlPart.isMimeType("text/html")); + assertEquals("

This is the HTML body.

", htmlPart.getContent()); + } + } + + @Test + public void testSendSmtp_singleAttachment() throws IOException, MessagingException { + // Setup + try (MockedStatic mockedSession = mockStatic(Session.class)) { + mockedSession.when(() -> Session.getInstance(any(Properties.class), any())).thenReturn(session); + when(session.getTransport("smtp")).thenReturn(transport); + + // Create the message with an attachment + MailService.Message message = new MailService.Message(); + message.setSender("sender@example.com"); + message.setTo("to@example.com"); + message.setSubject("Single Attachment Test"); + message.setTextBody("This is the body."); + + byte[] attachmentData = "This is an attachment.".getBytes(); + MailService.Attachment attachment = new MailService.Attachment("attachment.txt", attachmentData); + message.setAttachments(Collections.singletonList(attachment)); + + // Act + mailService.send(message); + + // Assert + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(MimeMessage.class); + verify(transport).sendMessage(messageCaptor.capture(), any()); + + MimeMessage sentMessage = messageCaptor.getValue(); + assertTrue(sentMessage.getContentType().startsWith("multipart/mixed")); + + MimeMultipart mixedMultipart = (MimeMultipart) sentMessage.getContent(); + assertEquals(2, mixedMultipart.getCount()); + + // Check the body part, which should be a multipart/alternative + MimeBodyPart bodyPart = (MimeBodyPart) mixedMultipart.getBodyPart(0); + assertTrue(bodyPart.getContentType().startsWith("multipart/alternative")); + MimeMultipart alternativeMultipart = (MimeMultipart) bodyPart.getContent(); + assertEquals(1, alternativeMultipart.getCount()); + MimeBodyPart textPart = (MimeBodyPart) alternativeMultipart.getBodyPart(0); + assertTrue(textPart.isMimeType("text/plain")); + assertEquals("This is the body.", textPart.getContent()); + + + // Check the attachment part + MimeBodyPart attachmentPart = (MimeBodyPart) mixedMultipart.getBodyPart(1); + assertEquals("attachment.txt", attachmentPart.getFileName()); + + // Verify the content of the attachment + byte[] actualAttachmentData = new byte[attachmentData.length]; + attachmentPart.getDataHandler().getInputStream().read(actualAttachmentData); + assertTrue(Arrays.equals(attachmentData, actualAttachmentData)); + } + } + + @Test + public void testSendSmtp_multipleAttachments() throws IOException, MessagingException { + // Setup + try (MockedStatic mockedSession = mockStatic(Session.class)) { + mockedSession.when(() -> Session.getInstance(any(Properties.class), any())).thenReturn(session); + when(session.getTransport("smtp")).thenReturn(transport); + + // Create the message with multiple attachments + MailService.Message message = new MailService.Message(); + message.setSender("sender@example.com"); + message.setTo("to@example.com"); + message.setSubject("Multiple Attachments Test"); + message.setTextBody("This is the body."); + + byte[] attachmentData1 = "This is attachment 1.".getBytes(); + MailService.Attachment attachment1 = new MailService.Attachment("attachment1.txt", attachmentData1); + byte[] attachmentData2 = "This is attachment 2.".getBytes(); + MailService.Attachment attachment2 = new MailService.Attachment("attachment2.txt", attachmentData2); + message.setAttachments(Arrays.asList(attachment1, attachment2)); + + // Act + mailService.send(message); + + // Assert + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(MimeMessage.class); + verify(transport).sendMessage(messageCaptor.capture(), any()); + + MimeMessage sentMessage = messageCaptor.getValue(); + assertTrue(sentMessage.getContentType().startsWith("multipart/mixed")); + + MimeMultipart mixedMultipart = (MimeMultipart) sentMessage.getContent(); + assertEquals(3, mixedMultipart.getCount()); + + // Check the body part + MimeBodyPart bodyPart = (MimeBodyPart) mixedMultipart.getBodyPart(0); + assertTrue(bodyPart.getContentType().startsWith("multipart/alternative")); + + // Check the first attachment + MimeBodyPart attachmentPart1 = (MimeBodyPart) mixedMultipart.getBodyPart(1); + assertEquals("attachment1.txt", attachmentPart1.getFileName()); + byte[] actualAttachmentData1 = new byte[attachmentData1.length]; + attachmentPart1.getDataHandler().getInputStream().read(actualAttachmentData1); + assertTrue(Arrays.equals(attachmentData1, actualAttachmentData1)); + + // Check the second attachment + MimeBodyPart attachmentPart2 = (MimeBodyPart) mixedMultipart.getBodyPart(2); + assertEquals("attachment2.txt", attachmentPart2.getFileName()); + byte[] actualAttachmentData2 = new byte[attachmentData2.length]; + attachmentPart2.getDataHandler().getInputStream().read(actualAttachmentData2); + assertTrue(Arrays.equals(attachmentData2, actualAttachmentData2)); + } + } + + @Test + public void testSendSmtp_htmlBodyAndAttachments() throws IOException, MessagingException { + // Setup + try (MockedStatic mockedSession = mockStatic(Session.class)) { + mockedSession.when(() -> Session.getInstance(any(Properties.class), any())).thenReturn(session); + when(session.getTransport("smtp")).thenReturn(transport); + + // Create the message with HTML body and an attachment + MailService.Message message = new MailService.Message(); + message.setSender("sender@example.com"); + message.setTo("to@example.com"); + message.setSubject("HTML Body and Attachments Test"); + message.setHtmlBody("

This is the HTML body.

"); + + byte[] attachmentData = "This is an attachment.".getBytes(); + MailService.Attachment attachment = new MailService.Attachment("attachment.txt", attachmentData); + message.setAttachments(Collections.singletonList(attachment)); + + // Act + mailService.send(message); + + // Assert + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(MimeMessage.class); + verify(transport).sendMessage(messageCaptor.capture(), any()); + + MimeMessage sentMessage = messageCaptor.getValue(); + assertTrue(sentMessage.getContentType().startsWith("multipart/mixed")); + + MimeMultipart mixedMultipart = (MimeMultipart) sentMessage.getContent(); + assertEquals(2, mixedMultipart.getCount()); + + // Check the body part (multipart/alternative) + MimeBodyPart bodyPart = (MimeBodyPart) mixedMultipart.getBodyPart(0); + assertTrue(bodyPart.getContentType().startsWith("multipart/alternative")); + MimeMultipart alternativeMultipart = (MimeMultipart) bodyPart.getContent(); + assertEquals(2, alternativeMultipart.getCount()); + + // Check the plain text part (should be empty) + MimeBodyPart textPart = (MimeBodyPart) alternativeMultipart.getBodyPart(0); + assertTrue(textPart.isMimeType("text/plain")); + assertEquals("", textPart.getContent()); + + // Check the HTML part + MimeBodyPart htmlPart = (MimeBodyPart) alternativeMultipart.getBodyPart(1); + assertTrue(htmlPart.isMimeType("text/html")); + assertEquals("

This is the HTML body.

", htmlPart.getContent()); + + // Check the attachment part + MimeBodyPart attachmentPart = (MimeBodyPart) mixedMultipart.getBodyPart(1); + assertEquals("attachment.txt", attachmentPart.getFileName()); + byte[] actualAttachmentData = new byte[attachmentData.length]; + attachmentPart.getDataHandler().getInputStream().read(actualAttachmentData); + assertTrue(Arrays.equals(attachmentData, actualAttachmentData)); + } + } + + @Test + public void testSendSmtp_attachmentWithContentId() throws IOException, MessagingException { + // Setup + try (MockedStatic mockedSession = mockStatic(Session.class)) { + mockedSession.when(() -> Session.getInstance(any(Properties.class), any())).thenReturn(session); + when(session.getTransport("smtp")).thenReturn(transport); + + // Create the message with an attachment with a Content-ID + MailService.Message message = new MailService.Message(); + message.setSender("sender@example.com"); + message.setTo("to@example.com"); + message.setSubject("Attachment with Content-ID Test"); + message.setTextBody("This is the body."); + + byte[] attachmentData = "This is an attachment.".getBytes(); + MailService.Attachment attachment = + new MailService.Attachment("attachment.txt", attachmentData, ""); + message.setAttachments(Collections.singletonList(attachment)); + + // Act + mailService.send(message); + + // Assert + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(MimeMessage.class); + verify(transport).sendMessage(messageCaptor.capture(), any()); + + MimeMessage sentMessage = messageCaptor.getValue(); + MimeMultipart mixedMultipart = (MimeMultipart) sentMessage.getContent(); + + // Check the attachment part + MimeBodyPart attachmentPart = (MimeBodyPart) mixedMultipart.getBodyPart(1); + assertEquals("", attachmentPart.getContentID()); + } + } + + @Test + public void testSendSmtp_replyToHeader() throws IOException, MessagingException { + // Setup + try (MockedStatic mockedSession = mockStatic(Session.class)) { + mockedSession.when(() -> Session.getInstance(any(Properties.class), any())).thenReturn(session); + when(session.getTransport("smtp")).thenReturn(transport); + + // Create the message with a Reply-To header + MailService.Message message = new MailService.Message(); + message.setSender("sender@example.com"); + message.setTo("to@example.com"); + message.setSubject("Reply-To Test"); + message.setTextBody("This is the body."); + message.setReplyTo("reply-to@example.com"); + + // Act + mailService.send(message); + + // Assert + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(MimeMessage.class); + verify(transport).sendMessage(messageCaptor.capture(), any()); + + MimeMessage sentMessage = messageCaptor.getValue(); + assertEquals(1, sentMessage.getReplyTo().length); + assertEquals("reply-to@example.com", sentMessage.getReplyTo()[0].toString()); + } + } + + @Test + public void testSendSmtp_customHeaders() throws IOException, MessagingException { + // Setup + try (MockedStatic mockedSession = mockStatic(Session.class)) { + mockedSession.when(() -> Session.getInstance(any(Properties.class), any())).thenReturn(session); + when(session.getTransport("smtp")).thenReturn(transport); + + // Create the message with custom headers + MailService.Message message = new MailService.Message(); + message.setSender("sender@example.com"); + message.setTo("to@example.com"); + message.setSubject("Custom Headers Test"); + message.setTextBody("This is the body."); + message.setHeaders( + Collections.singletonList(new MailService.Header("X-Custom-Header", "my-value"))); + + // Act + mailService.send(message); + + // Assert + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(MimeMessage.class); + verify(transport).sendMessage(messageCaptor.capture(), any()); + + MimeMessage sentMessage = messageCaptor.getValue(); + assertEquals("my-value", sentMessage.getHeader("X-Custom-Header")[0]); + } + } + + @Test + public void testSendSmtp_disabledTls() throws IOException, MessagingException { + // Setup + when(envProvider.getenv("APPENGINE_SMTP_USE_TLS")).thenReturn("false"); + try (MockedStatic mockedSession = mockStatic(Session.class)) { + ArgumentCaptor propsCaptor = ArgumentCaptor.forClass(Properties.class); + mockedSession + .when(() -> Session.getInstance(propsCaptor.capture(), any())) + .thenReturn(session); + when(session.getTransport("smtp")).thenReturn(transport); + + // Create a simple message + MailService.Message message = new MailService.Message(); + message.setSender("sender@example.com"); + message.setTo("to@example.com"); + message.setSubject("Disabled TLS Test"); + message.setTextBody("This is the body."); + + // Act + mailService.send(message); + + // Assert + Properties props = propsCaptor.getValue(); + assertEquals(null, props.getProperty("mail.smtp.starttls.enable")); + } + } + + @Test + public void testSendSmtp_adminEmail() throws IOException, MessagingException { + // Setup + when(envProvider.getenv("APPENGINE_ADMIN_EMAIL_RECIPIENTS")) + .thenReturn("admin1@example.com,admin2@example.com"); + try (MockedStatic mockedSession = mockStatic(Session.class)) { + mockedSession.when(() -> Session.getInstance(any(Properties.class), any())).thenReturn(session); + when(session.getTransport("smtp")).thenReturn(transport); + + // Create a message with some recipients that should be ignored + MailService.Message message = new MailService.Message(); + message.setSender("sender@example.com"); + message.setTo("to@example.com"); + message.setCc("cc@example.com"); + message.setBcc("bcc@example.com"); + message.setSubject("Admin Email Test"); + message.setTextBody("This is the body."); + + // Act + mailService.sendToAdmins(message); + + // Assert + ArgumentCaptor recipientsCaptor = ArgumentCaptor.forClass(Address[].class); + verify(transport).sendMessage(any(MimeMessage.class), recipientsCaptor.capture()); + + Address[] recipients = recipientsCaptor.getValue(); + assertEquals(2, recipients.length); + assertTrue( + "Recipient list should contain admin1@example.com", + Arrays.stream(recipients).anyMatch(a -> a.toString().equals("admin1@example.com"))); + assertTrue( + "Recipient list should contain admin2@example.com", + Arrays.stream(recipients).anyMatch(a -> a.toString().equals("admin2@example.com"))); + } + } + + @Test(expected = IllegalArgumentException.class) + public void testSendSmtp_adminEmailNoRecipients() throws IOException, MessagingException { + // Setup + when(envProvider.getenv("APPENGINE_ADMIN_EMAIL_RECIPIENTS")).thenReturn(null); + + // Create a simple message + MailService.Message message = new MailService.Message(); + message.setSender("sender@example.com"); + message.setSubject("Admin Email No Recipients Test"); + message.setTextBody("This is the body."); + + // Act + mailService.sendToAdmins(message); + } + + @Test(expected = IllegalArgumentException.class) + public void testSendSmtp_authenticationFailure() throws IOException, MessagingException { + // Setup + try (MockedStatic mockedSession = mockStatic(Session.class)) { + mockedSession.when(() -> Session.getInstance(any(Properties.class), any())).thenReturn(session); + when(session.getTransport("smtp")).thenReturn(transport); + doThrow(new javax.mail.AuthenticationFailedException("Authentication failed")) + .when(transport) + .connect(); + + // Create a simple message + MailService.Message message = new MailService.Message(); + message.setSender("sender@example.com"); + message.setTo("to@example.com"); + message.setSubject("Authentication Failure Test"); + message.setTextBody("This is the body."); + + // Act + mailService.send(message); + } + } + + @Test(expected = IOException.class) + public void testSendSmtp_connectionFailure() throws IOException, MessagingException { + // Setup + try (MockedStatic mockedSession = mockStatic(Session.class)) { + mockedSession.when(() -> Session.getInstance(any(Properties.class), any())).thenReturn(session); + when(session.getTransport("smtp")).thenReturn(transport); + doThrow(new MessagingException("Connection failed")).when(transport).connect(); + + // Create a simple message + MailService.Message message = new MailService.Message(); + message.setSender("sender@example.com"); + message.setTo("to@example.com"); + message.setSubject("Connection Failure Test"); + message.setTextBody("This is the body."); + + // Act + mailService.send(message); + } + } + + @Test(expected = IllegalArgumentException.class) + public void testSendSmtp_missingSmtpHost() throws IOException, MessagingException { + // Setup + when(envProvider.getenv("APPENGINE_SMTP_HOST")).thenReturn(null); + + // Create a simple message + MailService.Message message = new MailService.Message(); + message.setSender("sender@example.com"); + message.setTo("to@example.com"); + message.setSubject("Missing SMTP Host Test"); + message.setTextBody("This is the body."); + + // Act + mailService.send(message); + } +} diff --git a/api_dev/src/test/java/com/google/appengine/api/mail/MailServiceImplTest.java b/api_dev/src/test/java/com/google/appengine/api/mail/LegacyMailServiceImplTest.java similarity index 92% rename from api_dev/src/test/java/com/google/appengine/api/mail/MailServiceImplTest.java rename to api_dev/src/test/java/com/google/appengine/api/mail/LegacyMailServiceImplTest.java index fe6215c03..29cc93a3d 100644 --- a/api_dev/src/test/java/com/google/appengine/api/mail/MailServiceImplTest.java +++ b/api_dev/src/test/java/com/google/appengine/api/mail/LegacyMailServiceImplTest.java @@ -42,11 +42,11 @@ import org.mockito.junit.MockitoRule; /** - * Unit tests for the MailServiceImpl class. Cloned from URLFetchService. + * Unit tests for the LegacyMailServiceImpl class. Cloned from URLFetchService. * */ @RunWith(JUnit4.class) -public class MailServiceImplTest { +public class LegacyMailServiceImplTest { @Rule public MockitoRule mockitoRule = MockitoJUnit.rule(); @Mock private ApiProxy.Delegate delegate; @@ -57,7 +57,7 @@ public class MailServiceImplTest { public void setUp() throws Exception { ApiProxy.setDelegate(delegate); ApiProxy.setEnvironmentForCurrentThread(environment); - service = new MailServiceImpl(); + service = new LegacyMailServiceImpl(); } @After @@ -119,10 +119,10 @@ public void testSendAllNull() throws Exception { when(delegate.makeSyncCall(any(), any(), any(), any())).thenReturn(new byte[0]); - new MailServiceImpl().send(msg); + new LegacyMailServiceImpl().send(msg); verify(delegate) .makeSyncCall( - same(environment), eq(MailServiceImpl.PACKAGE), eq("Send"), eq(msgProto.toByteArray())); + same(environment), eq(LegacyMailServiceImpl.PACKAGE), eq("Send"), eq(msgProto.toByteArray())); } /** Tests that a message with an attachment works correctly. */ @@ -145,7 +145,7 @@ public void testDoSend_withAttachment() throws Exception { verify(delegate) .makeSyncCall( same(environment), - eq(MailServiceImpl.PACKAGE), + eq(LegacyMailServiceImpl.PACKAGE), eq("Send"), eq(msgProto.build().toByteArray())); } @@ -175,7 +175,7 @@ public void testDoSend_withContentIDAttachment() throws Exception { verify(delegate) .makeSyncCall( - same(environment), eq(MailServiceImpl.PACKAGE), eq("Send"), eq(msgProto.toByteArray())); + same(environment), eq(LegacyMailServiceImpl.PACKAGE), eq("Send"), eq(msgProto.toByteArray())); } /** Tests that sending a AMP Email message works correctly. */ @@ -199,7 +199,7 @@ public void testDoSend_ampEmail() throws Exception { service.send(msg); verify(delegate) .makeSyncCall( - same(environment), eq(MailServiceImpl.PACKAGE), eq("Send"), eq(msgProto.toByteArray())); + same(environment), eq(LegacyMailServiceImpl.PACKAGE), eq("Send"), eq(msgProto.toByteArray())); } /** Tests that a message with a header works correctly. */ @@ -220,7 +220,7 @@ public void testDoSend_withHeader() throws Exception { verify(delegate) .makeSyncCall( same(environment), - eq(MailServiceImpl.PACKAGE), + eq(LegacyMailServiceImpl.PACKAGE), eq("Send"), eq(msgProto.build().toByteArray())); } @@ -237,7 +237,7 @@ public void testDoSend_replyToAddress() throws Exception { verify(delegate) .makeSyncCall( - same(environment), eq(MailServiceImpl.PACKAGE), eq("Send"), eq(msgProto.toByteArray())); + same(environment), eq(LegacyMailServiceImpl.PACKAGE), eq("Send"), eq(msgProto.toByteArray())); } @Test @@ -306,7 +306,7 @@ private void runRecipientTest(String to, String cc, String bcc) throws Exception verify(delegate) .makeSyncCall( same(environment), - eq(MailServiceImpl.PACKAGE), + eq(LegacyMailServiceImpl.PACKAGE), eq("Send"), eq(msgProto.build().toByteArray())); } @@ -324,7 +324,7 @@ private MailService.Message setupSendCallWithApplicationException(ErrorCode code MailMessage msgProto = newMailMessage(msg); when(delegate.makeSyncCall( - same(environment), eq(MailServiceImpl.PACKAGE), eq("Send"), eq(msgProto.toByteArray()))) + same(environment), eq(LegacyMailServiceImpl.PACKAGE), eq("Send"), eq(msgProto.toByteArray()))) .thenThrow(new ApiProxy.ApplicationException(code.getNumber(), "detail")); return msg; @@ -365,7 +365,7 @@ public void testSendToAdmins() throws Exception { verify(delegate) .makeSyncCall( same(environment), - eq(MailServiceImpl.PACKAGE), + eq(LegacyMailServiceImpl.PACKAGE), eq("SendToAdmins"), eq(msgProto.toByteArray())); } @@ -383,8 +383,8 @@ public void testSendToAdmins_multiArgConstructor() throws Exception { verify(delegate) .makeSyncCall( same(environment), - eq(MailServiceImpl.PACKAGE), + eq(LegacyMailServiceImpl.PACKAGE), eq("SendToAdmins"), eq(msgProto.toByteArray())); } -} +} \ No newline at end of file