diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailSoyTofuProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailSoyTofuProvider.java index badedbcca8..fa1ba9fdb1 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailSoyTofuProvider.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailSoyTofuProvider.java @@ -45,6 +45,7 @@ public class MailSoyTofuProvider implements Provider { "DeleteReviewer.soy", "DeleteVote.soy", "Footer.soy", + "FooterHtml.soy", "Merged.soy", "NewChange.soy", "RegisterNewEmail.soy", diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/OutgoingEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/OutgoingEmail.java index 11a439b069..78fe631a4c 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/OutgoingEmail.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/OutgoingEmail.java @@ -18,6 +18,7 @@ import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailSt import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.DISABLED; import static java.nio.charset.StandardCharsets.UTF_8; +import com.google.common.io.BaseEncoding; import com.google.gerrit.common.errors.EmailException; import com.google.gerrit.extensions.api.changes.NotifyHandling; import com.google.gerrit.extensions.client.GeneralPreferencesInfo; @@ -47,6 +48,7 @@ import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; import java.util.Collection; +import java.util.concurrent.ThreadLocalRandom; import java.util.Date; import java.util.HashMap; import java.util.HashSet; @@ -67,7 +69,8 @@ public abstract class OutgoingEmail { private final Map headers; private final Set
smtpRcptTo = new HashSet<>(); private Address smtpFromAddress; - private StringBuilder body; + private StringBuilder textBody; + private StringBuilder htmlBody; protected VelocityContext velocityContext; protected Map soyContext; protected Map soyContextEmailData; @@ -108,6 +111,9 @@ public abstract class OutgoingEmail { init(); format(); appendText(textTemplate("Footer")); + if (useHtml()) { + appendHtml(soyHtmlTemplate("FooterHtml")); + } if (shouldSendMessage()) { if (fromId != null) { final Account fromUser = args.accountCache.get(fromId).getAccount(); @@ -141,12 +147,29 @@ public abstract class OutgoingEmail { } } + String textPart = textBody.toString(); OutgoingEmailValidationListener.Args va = new OutgoingEmailValidationListener.Args(); va.messageClass = messageClass; va.smtpFromAddress = smtpFromAddress; va.smtpRcptTo = smtpRcptTo; va.headers = headers; - va.body = body.toString(); + + if (useHtml()) { + String htmlPart = htmlBody.toString(); + String boundary = generateMultipartBoundary(textPart, htmlPart); + + va.body = buildMultipartBody(boundary, textPart, htmlPart); + va.textBody = textPart; + va.htmlBody = htmlPart; + va.headers.put("Content-Type", new EmailHeader.String( + "multipart/alternative; " + + "boundary=\"" + boundary + "\"; " + + "charset=UTF-8")); + } else { + va.body = textPart; + va.textBody = textPart; + } + for (OutgoingEmailValidationListener validator : args.outgoingEmailValidationListeners) { try { validator.validateOutgoingEmail(va); @@ -159,6 +182,49 @@ public abstract class OutgoingEmail { } } + protected String buildMultipartBody(String boundary, String textPart, + String htmlPart) { + return + // Output the text part: + "--" + boundary + "\r\n" + + "Content-Type: text/plain; charset=UTF-8\r\n" + + "Content-Transfer-Encoding: 8bit\r\n" + + "\r\n" + + textPart + "\r\n" + + // Output the HTML part: + + "--" + boundary + "\r\n" + + "Content-Type: text/html; charset=UTF-8\r\n" + + "Content-Transfer-Encoding: 8bit\r\n" + + "\r\n" + + htmlPart + "\r\n" + + // Output the closing boundary. + + "--" + boundary + "--\r\n"; + } + + protected String generateMultipartBoundary(String textBody, String htmlBody) + throws EmailException { + byte[] bytes = new byte[8]; + ThreadLocalRandom rng = ThreadLocalRandom.current(); + + // The probability of the boundary being valid is approximately + // (2^64 - len(message)) / 2^64. + // + // The message is much shorter than 2^64 bytes, so if two tries don't + // suffice, something is seriously wrong. + for (int i = 0; i < 2; i++) { + rng.nextBytes(bytes); + String boundary = BaseEncoding.base64().encode(bytes); + String encBoundary = "--" + boundary; + if (textBody.contains(encBoundary) || htmlBody.contains(encBoundary)) { + continue; + } + return boundary; + } + throw new EmailException("Gave up generating unique MIME boundary"); + } + /** Format the message body by calling {@link #appendText(String)}. */ protected abstract void format() throws EmailException; @@ -191,7 +257,8 @@ public abstract class OutgoingEmail { } setHeader("X-Gerrit-MessageType", messageClass); - body = new StringBuilder(); + textBody = new StringBuilder(); + htmlBody = new StringBuilder(); if (fromId != null && args.fromAddressGenerator.isGenericAddress(fromId)) { appendText(getFromLine()); @@ -266,7 +333,14 @@ public abstract class OutgoingEmail { /** Append text to the outgoing email body. */ protected void appendText(final String text) { if (text != null) { - body.append(text); + textBody.append(text); + } + } + + /** Append html to the outgoing email body. */ + protected void appendHtml(String html) { + if (html != null) { + htmlBody.append(html); } } @@ -340,7 +414,7 @@ public abstract class OutgoingEmail { } protected boolean shouldSendMessage() { - if (body.length() == 0) { + if (textBody.length() == 0) { // If we have no message body, don't send. return false; } @@ -481,14 +555,22 @@ public abstract class OutgoingEmail { } } - protected String soyTextTemplate(String name) { + private String soyTemplate(String name, SanitizedContent.ContentKind kind) { return args.soyTofu .newRenderer("com.google.gerrit.server.mail.template." + name) - .setContentKind(SanitizedContent.ContentKind.TEXT) + .setContentKind(kind) .setData(soyContext) .render(); } + protected String soyTextTemplate(String name) { + return soyTemplate(name, SanitizedContent.ContentKind.TEXT); + } + + protected String soyHtmlTemplate(String name) { + return soyTemplate(name, SanitizedContent.ContentKind.HTML); + } + /** * Evaluate the named template according to the following priority: * 1) Velocity file override, OR... @@ -545,4 +627,9 @@ public abstract class OutgoingEmail { private static String safeToString(Object obj) { return obj != null ? obj.toString() : ""; } + + /** Override this method to enable HTML in a subclass. */ + protected boolean useHtml() { + return false; + } } diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java index b2899c1631..bfec97925c 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/validators/OutgoingEmailValidationListener.java @@ -14,6 +14,7 @@ package com.google.gerrit.server.validators; +import com.google.gerrit.common.Nullable; import com.google.gerrit.extensions.annotations.ExtensionPoint; import com.google.gerrit.server.mail.Address; import com.google.gerrit.server.mail.EmailHeader; @@ -32,6 +33,8 @@ public interface OutgoingEmailValidationListener { class Args { // in arguments public String messageClass; + public String textBody; + @Nullable public String htmlBody; // in/out arguments public Address smtpFromAddress; diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy new file mode 100644 index 0000000000..9befa511b4 --- /dev/null +++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/FooterHtml.soy @@ -0,0 +1,20 @@ +/** + * Copyright (C) 2016 The Android Open Source Project + * + * 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 + * + * http://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. +*/ + +{namespace com.google.gerrit.server.mail.template} + +{template .FooterHtml autoescape="strict" kind="html"} +{/template}