-
Notifications
You must be signed in to change notification settings - Fork 63
feat(mail): Implement SMTP mail service and comprehensive tests #391
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
abhishekr700
wants to merge
5
commits into
GoogleCloudPlatform:main
from
abhishekr700:mail-sdk-changes
Closed
Changes from 1 commit
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
eb5794b
feat(mail): Implement SMTP mail service and comprehensive tests
abhishekr700 e5fb462
feat(mail): Introduce EnvironmentProvider interface for SMTP settings
abhishekr700 d6717b4
feat(mail): Prefix SMTP environment variables with APPENGINE_
abhishekr700 3e06a59
refactor(mail): Implement Strategy pattern for MailService
abhishekr700 33594d2
fix(tests): Update api_dev test to use LegacyMailServiceImpl
abhishekr700 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
|
@@ -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 | ||
|
|
@@ -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"); | ||
| 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")); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? |
||
| } | ||
| }); | ||
|
|
||
| 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; | ||
| } | ||
|
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. | ||
|
|
||
27 changes: 27 additions & 0 deletions
27
api/src/main/java/com/google/appengine/api/mail/SystemEnvironmentProvider.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 { | ||
|
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; | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.