001/* 002 * acme4j - Java ACME client 003 * 004 * Copyright (C) 2021 Richard "Shred" Körber 005 * http://acme4j.shredzone.org 006 * 007 * Licensed under the Apache License, Version 2.0 (the "License"); 008 * you may not use this file except in compliance with the License. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 013 */ 014package org.shredzone.acme4j.smime.email; 015 016import static java.util.Objects.requireNonNull; 017import static jakarta.mail.Message.RecipientType.TO; 018import static org.shredzone.acme4j.smime.email.ResponseBodyGenerator.RESPONSE_BODY_TYPE; 019 020import java.util.Properties; 021 022import edu.umd.cs.findbugs.annotations.Nullable; 023import jakarta.mail.Address; 024import jakarta.mail.Message; 025import jakarta.mail.MessagingException; 026import jakarta.mail.Session; 027import jakarta.mail.internet.MimeMessage; 028 029/** 030 * A helper for creating an email response to the "challenge" email. 031 * <p> 032 * According to RFC-8823, the response email <em>must</em> be DKIM signed. This is 033 * <em>not</em> done by the response generator, but must be done by the outbound MTA. 034 * 035 * @see <a href="https://datatracker.ietf.org/doc/html/rfc8823">RFC 8823</a> 036 * @since 2.12 037 */ 038public class ResponseGenerator { 039 private static final int LINE_LENGTH = 72; 040 private static final String CRLF = "\r\n"; 041 042 private final EmailProcessor processor; 043 private ResponseBodyGenerator generator = this::defaultBodyGenerator; 044 private @Nullable String header; 045 private @Nullable String footer; 046 047 /** 048 * Creates a new {@link ResponseGenerator}. 049 * 050 * @param processor 051 * {@link EmailProcessor} of the challenge email. 052 */ 053 public ResponseGenerator(EmailProcessor processor) { 054 this.processor = requireNonNull(processor, "processor"); 055 } 056 057 /** 058 * Adds a custom header to the response mail body. 059 * <p> 060 * There is no need to set a header, since the response email is usually not read by 061 * humans. If a header is set, it must contain ASCII encoded plain text. 062 * 063 * @param header 064 * Header text to be used, or {@code null} if no header is to be used. 065 * @return itself 066 */ 067 public ResponseGenerator withHeader(@Nullable String header) { 068 if (header != null && !header.endsWith(CRLF)) { 069 this.header = header.concat(CRLF); 070 } else { 071 this.header = header; 072 } 073 return this; 074 } 075 076 /** 077 * Adds a custom footer to the response mail body. 078 * <p> 079 * There is no need to set a footer, since the response email is usually not read by 080 * humans. If a footer is set, it must contain ASCII encoded plain text. 081 * 082 * @param footer 083 * Footer text to be used, or {@code null} if no footer is to be used. 084 * @return itself 085 */ 086 public ResponseGenerator withFooter(@Nullable String footer) { 087 this.footer = footer; 088 return this; 089 } 090 091 /** 092 * Sets a {@link ResponseBodyGenerator} that is used for generating a response body. 093 * <p> 094 * Use this generator to individually style the email body, for example to use a 095 * multipart body. However, be aware that the response mail is evaluated by a machine, 096 * and usually not read by humans, so the body should be designed as simple as 097 * possible. 098 * <p> 099 * The default body generator will just concatenate the header, the armored key 100 * authorization body, and the footer. 101 * 102 * @param generator 103 * {@link ResponseBodyGenerator} to be used, or {@code null} to use the 104 * default one. 105 * @return itself 106 */ 107 public ResponseGenerator withGenerator(@Nullable ResponseBodyGenerator generator) { 108 this.generator = generator != null ? generator : this::defaultBodyGenerator; 109 return this; 110 } 111 112 /** 113 * Generates the response email. 114 * <p> 115 * A simple default mail session is used for generation. 116 * 117 * @return Generated {@link Message}. 118 * @since 2.16 119 */ 120 public Message generateResponse() throws MessagingException { 121 return generateResponse(Session.getDefaultInstance(new Properties())); 122 } 123 124 /** 125 * Generates the response email. 126 * <p> 127 * Note that according to RFC-8823, this message must have a valid DKIM or S/MIME 128 * signature. This is <em>not</em> done here, but usually performed by the outbound 129 * MTA. 130 * 131 * @param session 132 * {@code javax.mail} {@link Session} to be used for this mail. 133 * @return Generated {@link Message}. 134 */ 135 public Message generateResponse(Session session) throws MessagingException { 136 var response = new MimeMessage(requireNonNull(session, "session")); 137 138 response.setSubject("Re: ACME: " + processor.getToken1()); 139 response.setFrom(processor.getRecipient()); 140 141 if (!processor.getReplyTo().isEmpty()) { 142 for (var rto : processor.getReplyTo()) { 143 response.addRecipient(TO, rto); 144 } 145 } else { 146 response.addRecipients(TO, new Address[] {processor.getSender()}); 147 } 148 149 if (processor.getMessageId().isPresent()) { 150 response.setHeader("In-Reply-To", processor.getMessageId().get()); 151 } 152 153 var wrappedAuth = processor.getAuthorization() 154 .replaceAll("(.{" + LINE_LENGTH + "})", "$1" + CRLF); 155 var responseBody = new StringBuilder(); 156 responseBody.append("-----BEGIN ACME RESPONSE-----").append(CRLF); 157 responseBody.append(wrappedAuth); 158 if (!wrappedAuth.endsWith(CRLF)) { 159 responseBody.append(CRLF); 160 } 161 responseBody.append("-----END ACME RESPONSE-----").append(CRLF); 162 163 generator.setContent(response, responseBody.toString()); 164 return response; 165 } 166 167 /** 168 * The default body generator. It just sets the response body, optionally framed by 169 * the given header and footer. 170 * 171 * @param response 172 * response {@link Message} to fill. 173 * @param responseBody 174 * Response body that must be added to the message. 175 */ 176 private void defaultBodyGenerator(Message response, String responseBody) 177 throws MessagingException { 178 var body = new StringBuilder(); 179 if (header != null) { 180 body.append(header); 181 } 182 body.append(responseBody); 183 if (footer != null) { 184 body.append(footer); 185 } 186 response.setContent(body.toString(), RESPONSE_BODY_TYPE); 187 } 188 189}