/******************************************************************************** * CruiseControl, a Continuous Integration Toolkit * Copyright (c) 2001-2003, ThoughtWorks, Inc. * 200 E. Randolph, 25th Floor * Chicago, IL 60601 USA * All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions * are met: * * + Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * * + Redistributions in binary form must reproduce the above * copyright notice, this list of conditions and the following * disclaimer in the documentation and/or other materials provided * with the distribution. * * + Neither the name of ThoughtWorks, Inc., CruiseControl, nor the * names of its contributors may be used to endorse or promote * products derived from this software without specific prior * written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ********************************************************************************/ package net.sourceforge.cruisecontrol.publishers; import java.io.CharArrayWriter; import java.io.File; import java.io.IOException; import java.util.LinkedList; import java.util.List; import java.util.StringTokenizer; import javax.mail.Message; import javax.mail.MessagingException; import javax.mail.internet.MimeBodyPart; import javax.mail.internet.MimeMultipart; import javax.xml.transform.Source; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerException; import javax.xml.transform.TransformerFactory; import javax.xml.transform.stream.StreamResult; import javax.xml.transform.stream.StreamSource; import net.sourceforge.cruisecontrol.CruiseControlException; import net.sourceforge.cruisecontrol.builders.Property; import net.sourceforge.cruisecontrol.gendoc.annotations.Description; import net.sourceforge.cruisecontrol.gendoc.annotations.ManualChildName; import net.sourceforge.cruisecontrol.gendoc.annotations.Optional; import net.sourceforge.cruisecontrol.gendoc.annotations.Title; import net.sourceforge.cruisecontrol.launch.Launcher; import net.sourceforge.cruisecontrol.util.ValidationHelper; import net.sourceforge.cruisecontrol.util.XMLLogHelper; import net.sourceforge.cruisecontrol.util.Util; import org.apache.log4j.Logger; import org.apache.tools.ant.launch.Locator; /** * Used to publish an HTML e-mail that includes the build report * * @author Jeffrey Fredrick * @author Alden Almagro * @author <a href="vwiewior@valuecommerce.ne.jp">Victor Wiewiorowski</a> */ @Description( "<p>Sends an email with the build results embedded as HTML. By default the same information as " + "the JSP build results page is sent.</p><p>Typical usage is to define xsldir and css to point " + "to cruisecontrol locations. This publisher creates HTML email by transforming information " + "based on a set of internally pre-defined xsl files. (Currently \"header.xsl\", and " + "\"buildresults.xsl\") This list can be changed, or appended to, using xslfilelist attribute. " + "Alternatively, you can specify a single xsl file to handle the full transformation using the " + "xslfile attribute.</p>" ) public class HTMLEmailPublisher extends EmailPublisher { private static final long serialVersionUID = 7140930723694451861L; private static final Logger LOG = Logger.getLogger(HTMLEmailPublisher.class); private String xslFile; private String xslDir; private String css; private String logDir; private String messageMimeType = "text/html"; private String charset; // Should reflect the same stylesheets as buildresults.jsp in the JSP // reporting application private String[] xslFileNames = { "header.xsl", "buildresults.xsl" }; private final List<Property> xsltParameters = new LinkedList<Property>(); /* * Called after the configuration is read to make sure that all the mandatory parameters * were specified.. * * @throws CruiseControlException if there was a configuration error. */ public void validate() throws CruiseControlException { super.validate(); if (logDir != null) { verifyDirectory("HTMLEmailPublisher.logDir", logDir); } else { LOG.debug("Using default logDir \"logs/<projectname>\""); } if (xslFile == null) { if (xslDir == null) { // try to obtain the dir relative to the current classpath xslDir = getXslDirFromClasspath(); } verifyDirectory("HTMLEmailPublisher.xslDir", xslDir); if (css == null) { // same for css css = getCssFromClasspath(); } verifyFile("HTMLEmailPublisher.css", css); final String[] fileNames = getXslFileNames(); if (fileNames == null) { throw new CruiseControlException("HTMLEmailPublisher.getXslFileNames() can't return null"); } for (final String fileName : fileNames) { verifyFile( "HTMLEmailPublisher.xslDir/" + fileName, new File(xslDir, fileName)); } } else { verifyFile("HTMLEmailPublisher.xslFile", xslFile); } } /** * @return new parameter that has been added to xslt params list already. */ @Title("Parameter") @Description( "Parameters passed to the XSL files before transforming them to HTML. Check the " + "Reporting application's <a href=\"http://cruisecontrol.sourceforge.net/reporting/" + "jsp/custom.html#XSLT_parameters\">documentation</a> for parameters used in the " + "standard XSL files." ) @ManualChildName("parameter") public Property createParameter() { final Property param = new Property(); xsltParameters.add(param); return param; } /** * @return the absolute path where the cruisecontrol.css file is located, * or null if it can't be found. */ private String getCssFromClasspath() { final File cssFile = guessFileForResource("css/cruisecontrol.css"); if (cssFile != null && cssFile.exists()) { return cssFile.getAbsolutePath(); } return null; } /** * @return the absolute path where the xsl dir is located, * or null if it can't be found. */ private String getXslDirFromClasspath() { final File xsl = guessFileForResource("xsl"); if (xsl != null && xsl.isDirectory()) { return xsl.getAbsolutePath(); } return null; } /** * Try some path constellations to see if the relative resource exists somewhere. * First existing resource will be returned. At the moment we use the source-path and * cc.home-property in combination with the source-tree and binary-contribution tree. * @param relativeResource relative path to look for * @return an existing resource as file or null */ private File guessFileForResource(final String relativeResource) { final File ccHome; if (System.getProperty(Launcher.CCHOME_PROPERTY) != null) { ccHome = new File(System.getProperty(Launcher.CCHOME_PROPERTY)); } else { ccHome = getCruiseRootDir(); } final String cruise = "reporting/jsp/webcontent/"; final String binaryDistribution = "webapps/cruisecontrol/"; final File[] possiblePaths = {new File(getCruiseRootDir(), cruise + relativeResource), new File(getCruiseRootDir(), binaryDistribution + relativeResource), new File(ccHome, cruise + relativeResource), new File(ccHome, binaryDistribution + relativeResource)}; for (final File possiblePath : possiblePaths) { if (possiblePath.exists()) { return possiblePath; } } return null; } /** * @return the root directory of the running cruisecontrol installation. * Uses Ant's Locator. */ private File getCruiseRootDir() { final File classDir = Locator.getClassSource(getClass()); if (classDir != null) { try { // we're probably in main/dist/cruisecontrol.jar, so three parents up final File rootDir = classDir.getParentFile().getParentFile().getParentFile(); if (LOG.isDebugEnabled()) { LOG.debug("rootDir seems to be " + rootDir.getAbsolutePath() + " (classDir = " + classDir.getAbsolutePath() + ")"); } return rootDir; } catch (NullPointerException npe) { // don't know where we are, then... return null; } } return null; } private void verifyDirectory(final String dirName, final String dir) throws CruiseControlException { ValidationHelper.assertFalse(dir == null, dirName + " not specified in configuration file"); final File dirFile = new File(dir); ValidationHelper.assertTrue(dirFile.exists(), dirFile + " does not exist: " + dirFile.getAbsolutePath()); ValidationHelper.assertTrue(dirFile.isDirectory(), dirFile + " is not a directory: " + dirFile.getAbsolutePath()); } private void verifyFile(final String fileName, final String file) throws CruiseControlException { ValidationHelper.assertFalse(file == null, fileName + " not specified in configuration file"); verifyFile(fileName, new File(file)); } private void verifyFile(final String fileName, final File file) throws CruiseControlException { ValidationHelper.assertTrue(file.exists(), fileName + " does not exist: " + file.getAbsolutePath()); ValidationHelper.assertTrue(file.isFile(), fileName + " is not a file: " + file.getAbsolutePath()); } /** * sets the content as an attachment w/proper mime-type */ protected void addContentToMessage(final String htmlContent, final Message msg) throws MessagingException { final MimeMultipart attachments = new MimeMultipart(); final MimeBodyPart textbody = new MimeBodyPart(); final String contentType = getContentType(); textbody.setContent(htmlContent, contentType); attachments.addBodyPart(textbody); msg.setContent(attachments); } String getContentType() { if (charset != null) { return messageMimeType + "; charset=\"" + charset + "\""; } else { return messageMimeType; } } /** * updates xslFileNames, based on value of xslFileList * If first character is + the list is appended, otherwise * the list is replaced. xslFileNames is comma or space-separated * list of existing files, located in xslDir. These files are used, * in-order, to generate HTML email. If xslFileNames is not * specified, xslFileList remains as default. * if xslFile is set, this is ignored. * @param relativePathToXslFile relative path to xsl file */ @Title("XSL File List") @Description( "Works with xsldir and css. String, representing ordered list of xsl files located in " + "xsldir, which are used to format HTML email. List is comma or space separated. If first " + "character of list is plus sign (\"+\"), the listed file(s) are added to existing set of " + "xsl files used by HTMLEmailPublisher. If xslfilelist is not specified, email is published " + "using hard-coded list of xsl files." ) @Optional public void setXSLFileList(String relativePathToXslFile) { if (relativePathToXslFile == null || relativePathToXslFile.equals("")) { throw new IllegalArgumentException("xslFileList shouldn't be null or empty"); } relativePathToXslFile = relativePathToXslFile.trim(); final boolean appending = relativePathToXslFile.startsWith("+"); if (appending) { relativePathToXslFile = relativePathToXslFile.substring(1); } final StringTokenizer st = new StringTokenizer(relativePathToXslFile, " ,"); final int numTokens = st.countTokens(); int i; if (appending) { i = xslFileNames.length; } else { i = 0; } final String[] newXSLFileNames = new String[i + numTokens]; System.arraycopy(xslFileNames, 0, newXSLFileNames, 0, i); while (st.hasMoreTokens()) { newXSLFileNames[i++] = st.nextToken(); } setXSLFileNames(newXSLFileNames); } /** * If xslFile is set then both xslDir and css are ignored. Specified xslFile * must take care of entire document -- html open/close, body tags, styles, * etc. * @param fullPathToXslFile full path to xsl file */ @Title("XSL File") @Description( "If specified, xsldir, xslfilelist, and css are ignored. Must handle the " + "entire document." ) @Optional public void setXSLFile(final String fullPathToXslFile) { xslFile = fullPathToXslFile; } /** * Directory where xsl files are located. * @param xslDirectory directory where xsl files are located. */ @Title("XSL Dir") @Description( "Directory where standard CruiseControl xsl files are located. Starting with version " + "2.3, the HTMLEmailPublisher will try to determine the correct value itself when it's " + "not specified and xslfile isn't used." ) @Optional("<i>Versions up to 2.3</i>: <b>Required</b> unless xslfile specified.") public void setXSLDir(final String xslDirectory) { xslDir = xslDirectory; } /** * Method to override the default list of file names that will be looked * for in the directory specified by xslDir. By default these are the * standard CruseControl xsl files: <br> * <ul> * <li> header.xsl * <li> maven.xsl * <li> etc ... * </ul> * I expect this to be used by a derived class to allow someone to * change the order of xsl files or to add/remove one to/from the list * or a combination. * @param fileNames xsl file names to look for */ protected void setXSLFileNames(final String[] fileNames) { if (fileNames == null) { throw new IllegalArgumentException("xslFileNames can't be null (but can be empty)"); } xslFileNames = fileNames; } /** * Provided as an alternative to setXSLFileNames for changing the list of * files to use. * @return xsl files to use in generating the email */ protected String[] getXslFileNames() { return xslFileNames; } /** * Path to cruisecontrol.css. Only used with xslDir, not xslFile. * @param cssFilename css file name */ @Title("CSS") @Description( "Path to cruisecontrol.css. Used only if xsldir set and not xslfile. Starting with " + "version 2.3, the HTMLEmailPublisher will try to determine the correct value itself " + "when it's not specified and xslfile isn't used." ) @Optional("<i>Versions up to 2.3</i>: <b>Required</b> unless xslfile specified.") public void setCSS(final String cssFilename) { css = cssFilename; } /** * Path to the log file as set in the log element of the configuration * xml file. * @param directory log dir */ @Title("Log Dir") @Description( "Path to the log directory as set in the log element of the configuration xml file. " + "Follows default of <a href=\"#log\">log</a>'s dir-attribute since version 2.2" ) @Optional("Required for versions < 2.2") public void setLogDir(final String directory) { if (directory == null) { throw new IllegalArgumentException("logDir cannot be null!"); } logDir = directory; } @Title("Charset") @Description( "If not set the content type will be set to 'text/html'. If set the " + "content type will be 'text/html;charset=\"value\"'." ) @Optional public void setCharset(final String characterSet) { charset = characterSet; } /** * Create the message to be mailed * * @param logHelper utility object that has parsed the log files * @return created message; empty string if logDir not set */ // TODO: address whether this should ever return null; // dependent also on transform(File) and createLinkLine() protected String createMessage(final XMLLogHelper logHelper) { String message = ""; File inFile = null; try { if (logDir == null) { // use the same default as ProjectXMLHelper.getLog() logDir = "logs" + File.separator + logHelper.getProjectName(); } inFile = new File(logDir, logHelper.getLogFileName()); message = transform(inFile); } catch (Exception ex) { LOG.error("error transforming " + (inFile == null ? null : inFile.getAbsolutePath()), ex); try { final String logFileName = logHelper.getLogFileName(); message = createLinkLine(logFileName); } catch (CruiseControlException ccx) { LOG.error("exception getting logfile name", ccx); } } return message; } protected String transform(final File inFile) throws TransformerException, IOException { final StringBuilder messageBuffer = new StringBuilder(); final TransformerFactory tFactory = TransformerFactory.newInstance(); if (xslFile != null) { final File xslFileAsFile = new File(xslFile); appendTransform(inFile, messageBuffer, tFactory, xslFileAsFile); } else { appendHeader(messageBuffer); messageBuffer.append(createLinkLine(inFile.getName())); final File xslDirectory = new File(xslDir); final String[] fileNames = getXslFileNames(); for (final String fileName : fileNames) { final File xsl = new File(xslDirectory, fileName); messageBuffer.append("<p>\n"); appendTransform(inFile, messageBuffer, tFactory, xsl); } appendFooter(messageBuffer); } return messageBuffer.toString(); } protected String createLinkLine(final String logFileName) { final StringBuilder linkLine = new StringBuilder(""); final String buildResultsURL = getBuildResultsURL(); if (buildResultsURL == null) { return ""; } final int startName = logFileName.lastIndexOf(File.separator) + 1; final int endName = logFileName.lastIndexOf("."); final String baseLogFileName = logFileName.substring(startName, endName); final StringBuilder url = new StringBuilder(buildResultsURL); if (buildResultsURL.indexOf("?") == -1) { url.append("?"); } else { url.append("&"); } url.append("log="); url.append(baseLogFileName); linkLine.append("View results here -> <a href=\""); linkLine.append(url); linkLine.append("\">"); linkLine.append(url); linkLine.append("</a>"); return linkLine.toString(); } protected void appendTransform(final File inFile, final StringBuilder messageBuffer, final TransformerFactory tFactory, final File xsl) { try { final String result = transformFile(new StreamSource(inFile), tFactory, new StreamSource(xsl)); messageBuffer.append(result); } catch (Exception e) { LOG.error("error transforming with xslFile " + xsl.getName(), e); } } protected String transformFile(final Source logFile, final TransformerFactory tFactory, final Source xsl) throws IOException, TransformerException { final Transformer transformer = tFactory.newTransformer(xsl); final CharArrayWriter writer = new CharArrayWriter(); if (!xsltParameters.isEmpty()) { for (final Property param : xsltParameters) { transformer.setParameter(param.getName(), param.getValue()); } } transformer.transform(logFile, new StreamResult(writer)); return writer.toString(); } protected void appendHeader(final StringBuilder messageBuffer) throws IOException { messageBuffer.append("<html><head>\n"); final String baseUrl = getBuildResultsURL(); if (baseUrl != null) { messageBuffer.append("<base href=\"").append(baseUrl).append("\">\n"); } messageBuffer.append("<style>\n"); Util.appendFileToBuffer(css, messageBuffer); messageBuffer.append("\n</style>\n</head><body>\n"); } protected void appendFooter(final StringBuilder messageBuffer) { messageBuffer.append("\n</body></html>"); } }