Skip to content

Commit eb5794b

Browse files
committed
feat(mail): Implement SMTP mail service and comprehensive tests
This commit introduces a new implementation for the Mail Service that allows sending emails via an external SMTP server. This functionality is controlled by the environment variable. Key changes include: - : Updated to include the method which uses to construct and send MIME messages. It handles various scenarios including different body types (text, HTML), attachments, and custom headers. - : A new helper class to abstract system environment variable access, allowing for easier testing and mocking. - : A comprehensive new test suite for the SMTP mail service, covering core functionality, MIME structure, headers, configuration, and error handling. - : Updated to include necessary dependencies for testing, such as . The new implementation provides a more flexible and robust way to send emails from App Engine applications, especially in environments where the native App Engine Mail API is not available.
1 parent f8dfcac commit eb5794b

4 files changed

Lines changed: 870 additions & 1 deletion

File tree

api/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@
137137
</dependency>
138138
<dependency>
139139
<groupId>org.mockito</groupId>
140-
<artifactId>mockito-junit-jupiter</artifactId>
140+
<artifactId>mockito-core</artifactId>
141141
<scope>test</scope>
142142
</dependency>
143143
<dependency>

api/src/main/java/com/google/appengine/api/mail/MailServiceImpl.java

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,26 @@
2323
import com.google.apphosting.api.ApiProxy;
2424
import com.google.protobuf.ByteString;
2525
import java.io.IOException;
26+
import java.util.ArrayList;
27+
import java.util.Arrays;
28+
import java.util.Collection;
29+
import java.util.List;
30+
import java.util.Properties;
31+
import javax.activation.DataHandler;
32+
import javax.activation.DataSource;
33+
import javax.mail.Address;
34+
import javax.mail.BodyPart;
35+
import javax.mail.Message.RecipientType;
36+
import javax.mail.MessagingException;
37+
import javax.mail.Multipart;
38+
import javax.mail.Session;
39+
import javax.mail.Transport;
40+
import javax.mail.internet.AddressException;
41+
import javax.mail.internet.InternetAddress;
42+
import javax.mail.internet.MimeBodyPart;
43+
import javax.mail.internet.MimeMessage;
44+
import javax.mail.internet.MimeMultipart;
45+
import javax.mail.util.ByteArrayDataSource;
2646

2747
/**
2848
* This class implements raw access to the mail service.
@@ -33,6 +53,17 @@
3353
*/
3454
class MailServiceImpl implements MailService {
3555
static final String PACKAGE = "mail";
56+
private final SystemEnvironmentProvider envProvider;
57+
58+
/** Default constructor, used in production. */
59+
MailServiceImpl() {
60+
this(new SystemEnvironmentProvider());
61+
}
62+
63+
/** Constructor for testing, allowing a mock environment provider. */
64+
MailServiceImpl(SystemEnvironmentProvider envProvider) {
65+
this.envProvider = envProvider;
66+
}
3667

3768
/** {@inheritDoc} */
3869
@Override
@@ -48,13 +79,182 @@ public void send(Message message)
4879
doSend(message, false);
4980
}
5081

82+
private void sendSmtp(Message message, boolean toAdmin)
83+
throws IllegalArgumentException, IOException {
84+
String smtpHost = envProvider.getenv("SMTP_HOST");
85+
if (smtpHost == null || smtpHost.isEmpty()) {
86+
throw new IllegalArgumentException("SMTP_HOST environment variable is not set.");
87+
}
88+
Properties props = new Properties();
89+
props.put("mail.smtp.host", smtpHost);
90+
props.put("mail.smtp.port", envProvider.getenv("SMTP_PORT"));
91+
props.put("mail.smtp.auth", "true");
92+
if (Boolean.parseBoolean(envProvider.getenv("SMTP_USE_TLS"))) {
93+
props.put("mail.smtp.starttls.enable", "true");
94+
}
95+
96+
Session session = Session.getInstance(props, new javax.mail.Authenticator() {
97+
protected javax.mail.PasswordAuthentication getPasswordAuthentication() {
98+
return new javax.mail.PasswordAuthentication(
99+
envProvider.getenv("SMTP_USER"), envProvider.getenv("SMTP_PASSWORD"));
100+
}
101+
});
102+
103+
try {
104+
MimeMessage mimeMessage = new MimeMessage(session);
105+
mimeMessage.setFrom(new InternetAddress(message.getSender()));
106+
107+
List<InternetAddress> toRecipients = new ArrayList<>();
108+
List<InternetAddress> ccRecipients = new ArrayList<>();
109+
List<InternetAddress> bccRecipients = new ArrayList<>();
110+
111+
if (toAdmin) {
112+
String adminRecipients = envProvider.getenv("ADMIN_EMAIL_RECIPIENTS");
113+
if (adminRecipients == null || adminRecipients.isEmpty()) {
114+
throw new IllegalArgumentException("Admin recipients not configured.");
115+
}
116+
toRecipients.addAll(Arrays.asList(InternetAddress.parse(adminRecipients)));
117+
} else {
118+
if (message.getTo() != null) {
119+
toRecipients.addAll(toInternetAddressList(message.getTo()));
120+
}
121+
if (message.getCc() != null) {
122+
ccRecipients.addAll(toInternetAddressList(message.getCc()));
123+
}
124+
if (message.getBcc() != null) {
125+
bccRecipients.addAll(toInternetAddressList(message.getBcc()));
126+
}
127+
}
128+
129+
List<Address> allTransportRecipients = new ArrayList<>();
130+
allTransportRecipients.addAll(toRecipients);
131+
allTransportRecipients.addAll(ccRecipients);
132+
allTransportRecipients.addAll(bccRecipients);
133+
134+
if (allTransportRecipients.isEmpty()) {
135+
throw new IllegalArgumentException("No recipients specified.");
136+
}
137+
138+
if (!toRecipients.isEmpty()) {
139+
mimeMessage.setRecipients(RecipientType.TO, toRecipients.toArray(new Address[0]));
140+
}
141+
if (!ccRecipients.isEmpty()) {
142+
mimeMessage.setRecipients(RecipientType.CC, ccRecipients.toArray(new Address[0]));
143+
}
144+
// Bcc recipients are not set on the MimeMessage to prevent them from being in the headers.
145+
146+
if (message.getReplyTo() != null) {
147+
mimeMessage.setReplyTo(new Address[] {new InternetAddress(message.getReplyTo())});
148+
}
149+
150+
mimeMessage.setSubject(message.getSubject());
151+
152+
final boolean hasAttachments = message.getAttachments() != null && !message.getAttachments().isEmpty();
153+
final boolean hasHtmlBody = message.getHtmlBody() != null;
154+
final boolean hasAmpHtmlBody = message.getAmpHtmlBody() != null;
155+
final boolean hasTextBody = message.getTextBody() != null;
156+
157+
// Case 1: Plain text only, no attachments. Simplest case.
158+
if (hasTextBody && !hasHtmlBody && !hasAmpHtmlBody && !hasAttachments) {
159+
mimeMessage.setText(message.getTextBody());
160+
} else {
161+
// Case 2: Anything more complex requires multipart.
162+
MimeMultipart topLevelMultipart = new MimeMultipart("mixed");
163+
164+
// The bodies (text, html, amp) are grouped in a "multipart/alternative"
165+
if (hasTextBody || hasHtmlBody || hasAmpHtmlBody) {
166+
MimeMultipart alternativeMultipart = new MimeMultipart("alternative");
167+
MimeBodyPart alternativeBodyPart = new MimeBodyPart();
168+
alternativeBodyPart.setContent(alternativeMultipart);
169+
170+
if (hasTextBody) {
171+
MimeBodyPart textPart = new MimeBodyPart();
172+
textPart.setText(message.getTextBody());
173+
alternativeMultipart.addBodyPart(textPart);
174+
} else if (hasHtmlBody) {
175+
// If there is an HTML body but no text body, add an empty text part for compatibility.
176+
MimeBodyPart textPart = new MimeBodyPart();
177+
textPart.setText("");
178+
alternativeMultipart.addBodyPart(textPart);
179+
}
180+
181+
if (hasHtmlBody) {
182+
MimeBodyPart htmlPart = new MimeBodyPart();
183+
htmlPart.setContent(message.getHtmlBody(), "text/html");
184+
alternativeMultipart.addBodyPart(htmlPart);
185+
}
186+
if (hasAmpHtmlBody) {
187+
MimeBodyPart ampPart = new MimeBodyPart();
188+
ampPart.setContent(message.getAmpHtmlBody(), "text/x-amp-html");
189+
alternativeMultipart.addBodyPart(ampPart);
190+
}
191+
topLevelMultipart.addBodyPart(alternativeBodyPart);
192+
}
193+
194+
// Add attachments to the top-level mixed part.
195+
if (hasAttachments) {
196+
for (Attachment attachment : message.getAttachments()) {
197+
MimeBodyPart attachmentBodyPart = new MimeBodyPart();
198+
DataSource source =
199+
new ByteArrayDataSource(attachment.getData(), "application/octet-stream");
200+
attachmentBodyPart.setDataHandler(new DataHandler(source));
201+
attachmentBodyPart.setFileName(attachment.getFileName());
202+
if (attachment.getContentID() != null) {
203+
attachmentBodyPart.setContentID(attachment.getContentID());
204+
}
205+
topLevelMultipart.addBodyPart(attachmentBodyPart);
206+
}
207+
}
208+
mimeMessage.setContent(topLevelMultipart);
209+
}
210+
211+
if (message.getHeaders() != null) {
212+
for (Header header : message.getHeaders()) {
213+
mimeMessage.addHeader(header.getName(), header.getValue());
214+
}
215+
}
216+
217+
// Update headers to match content, e.g., setting the Content-Type
218+
mimeMessage.saveChanges();
219+
220+
Transport transport = session.getTransport("smtp");
221+
try {
222+
transport.connect();
223+
transport.sendMessage(mimeMessage, allTransportRecipients.toArray(new Address[0]));
224+
} finally {
225+
if (transport != null) {
226+
transport.close();
227+
}
228+
}
229+
230+
} catch (MessagingException e) {
231+
if (e instanceof javax.mail.AuthenticationFailedException) {
232+
throw new IllegalArgumentException("SMTP authentication failed: " + e.getMessage(), e);
233+
}
234+
throw new IOException("Error sending email via SMTP: " + e.getMessage(), e);
235+
}
236+
}
237+
238+
private List<InternetAddress> toInternetAddressList(Collection<String> addresses)
239+
throws AddressException {
240+
List<InternetAddress> list = new ArrayList<>();
241+
for (String address : addresses) {
242+
list.add(new InternetAddress(address));
243+
}
244+
return list;
245+
}
246+
51247
/**
52248
* Does the actual sending of the message.
53249
* @param message The message to be sent.
54250
* @param toAdmin Whether the message is to be sent to the admins.
55251
*/
56252
private void doSend(Message message, boolean toAdmin)
57253
throws IllegalArgumentException, IOException {
254+
if ("true".equals(envProvider.getenv("USE_SMTP_MAIL_SERVICE"))) {
255+
sendSmtp(message, toAdmin);
256+
return;
257+
}
58258
// Could perform basic checks to save on RPCs in case of missing args etc.
59259
// I'm not doing this on purpose, to make sure the semantics of the two
60260
// implementations stay the same.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.google.appengine.api.mail;
2+
3+
/**
4+
* A simple wrapper around {@link System} to allow for easier testing.
5+
*/
6+
class SystemEnvironmentProvider {
7+
/**
8+
* Gets the value of the specified environment variable.
9+
* @param name the name of the environment variable
10+
* @return the string value of the variable, or {@code null} if the variable is not defined
11+
*/
12+
public String getenv(String name) {
13+
return System.getenv(name);
14+
}
15+
16+
/**
17+
* Gets the value of the specified environment variable, returning a default value if the
18+
* variable is not defined.
19+
* @param name the name of the environment variable
20+
* @param defaultValue the default value to return
21+
* @return the string value of the variable, or the default value if the variable is not defined
22+
*/
23+
public String getenv(String name, String defaultValue) {
24+
String value = System.getenv(name);
25+
return value != null ? value : defaultValue;
26+
}
27+
}

0 commit comments

Comments
 (0)