| .. | .. |
|---|
| 1 | +/* |
|---|
| 2 | + * Copyright @ 2013 CurisTEC, S.A.S. All Rights Reserved. |
|---|
| 3 | + */ |
|---|
| 1 | 4 | package net.curisit.securis.utils; |
|---|
| 2 | 5 | |
|---|
| 3 | 6 | import java.io.File; |
|---|
| .. | .. |
|---|
| 40 | 43 | import org.apache.logging.log4j.Logger; |
|---|
| 41 | 44 | |
|---|
| 42 | 45 | /** |
|---|
| 43 | | - * Component that send emails using Mailgun API: |
|---|
| 44 | | - * http://documentation.mailgun.com/user_manual.html#sending-messages |
|---|
| 45 | | - * |
|---|
| 46 | | - * @author roberto <roberto.sanchez@curisit.net> |
|---|
| 46 | + * EmailManager |
|---|
| 47 | + * <p> |
|---|
| 48 | + * Small utility to send plain-text emails (optionally with one attachment) |
|---|
| 49 | + * using the <b>Mailgun</b> API over HTTPS. |
|---|
| 50 | + * <p> |
|---|
| 51 | + * Design notes: |
|---|
| 52 | + * <ul> |
|---|
| 53 | + * <li>Reads Mailgun credentials and the "from" address from {@link Config}.</li> |
|---|
| 54 | + * <li>Builds a preconfigured {@link HttpClientBuilder} with basic auth and a permissive SSL socket factory.</li> |
|---|
| 55 | + * <li>Exposes synchronous (blocking) and asynchronous (non-blocking) send methods.</li> |
|---|
| 56 | + * <li>Scope is {@code @ApplicationScoped}; the underlying builder is created once per container.</li> |
|---|
| 57 | + * </ul> |
|---|
| 58 | + * |
|---|
| 59 | + * Thread-safety: |
|---|
| 60 | + * <p> |
|---|
| 61 | + * The class is effectively stateless after construction; using a shared {@link HttpClientBuilder} |
|---|
| 62 | + * is safe as a new {@link HttpClient} is built per request. |
|---|
| 63 | + * |
|---|
| 64 | + * Configuration keys (see {@link Config.KEYS}): |
|---|
| 65 | + * <ul> |
|---|
| 66 | + * <li>{@code mailgun.domain}</li> |
|---|
| 67 | + * <li>{@code mailgun.api.key}</li> |
|---|
| 68 | + * <li>{@code email.from.address}</li> |
|---|
| 69 | + * </ul> |
|---|
| 70 | + * |
|---|
| 71 | + * Failure handling: |
|---|
| 72 | + * <p> |
|---|
| 73 | + * Network and HTTP errors are surfaced as {@link SeCurisServiceException} with appropriate error codes. |
|---|
| 74 | + * |
|---|
| 75 | + * @author roberto <roberto.sanchez@curisit.net> |
|---|
| 76 | + * Last reviewed by JRA on Oct 6, 2025. |
|---|
| 47 | 77 | */ |
|---|
| 48 | 78 | @ApplicationScoped |
|---|
| 49 | 79 | public class EmailManager { |
|---|
| 50 | 80 | |
|---|
| 81 | + /** Class logger. */ |
|---|
| 51 | 82 | private static final Logger LOG = LogManager.getLogger(EmailManager.class); |
|---|
| 52 | 83 | |
|---|
| 84 | + /** Mailgun endpoint composed from configured domain. */ |
|---|
| 53 | 85 | private final String serverUrl; |
|---|
| 86 | + |
|---|
| 87 | + /** Preconfigured builder that carries SSL and basic-auth configuration. */ |
|---|
| 54 | 88 | private final HttpClientBuilder httpClientBuilder; |
|---|
| 55 | 89 | |
|---|
| 90 | + // --------------------------------------------------------------------- |
|---|
| 91 | + // Constructors |
|---|
| 92 | + // --------------------------------------------------------------------- |
|---|
| 93 | + |
|---|
| 56 | 94 | /** |
|---|
| 57 | | - * |
|---|
| 58 | | - * @throws SeCurisException |
|---|
| 95 | + * EmailManager |
|---|
| 96 | + * <p> |
|---|
| 97 | + * Default constructor that validates required configuration and prepares an |
|---|
| 98 | + * HTTP client builder with Mailgun credentials and SSL settings. |
|---|
| 99 | + * |
|---|
| 100 | + * @throws SeCurisException if mandatory configuration is missing or the SSL builder cannot be created |
|---|
| 59 | 101 | */ |
|---|
| 60 | 102 | public EmailManager() throws SeCurisException { |
|---|
| 61 | 103 | String domain = Config.get(Config.KEYS.MAILGUN_DOMAIN); |
|---|
| .. | .. |
|---|
| 64 | 106 | } |
|---|
| 65 | 107 | serverUrl = String.format("https://api.mailgun.net/v2/%s/messages", domain); |
|---|
| 66 | 108 | httpClientBuilder = createHttpClient(); |
|---|
| 67 | | - |
|---|
| 68 | 109 | } |
|---|
| 69 | 110 | |
|---|
| 111 | + // --------------------------------------------------------------------- |
|---|
| 112 | + // Internal helpers |
|---|
| 113 | + // --------------------------------------------------------------------- |
|---|
| 114 | + |
|---|
| 115 | + /** |
|---|
| 116 | + * createHttpClient |
|---|
| 117 | + * <p> |
|---|
| 118 | + * Builds a {@link HttpClientBuilder} that: |
|---|
| 119 | + * <ul> |
|---|
| 120 | + * <li>Accepts any server certificate (permissive trust strategy).</li> |
|---|
| 121 | + * <li>Applies HTTP Basic Auth using Mailgun's API key as the password and user "api".</li> |
|---|
| 122 | + * </ul> |
|---|
| 123 | + * |
|---|
| 124 | + * @return a preconfigured {@link HttpClientBuilder} ready to build clients |
|---|
| 125 | + * @throws SeCurisException if SSL initialization fails |
|---|
| 126 | + */ |
|---|
| 70 | 127 | private HttpClientBuilder createHttpClient() throws SeCurisException { |
|---|
| 71 | 128 | SSLContextBuilder builder = new SSLContextBuilder(); |
|---|
| 72 | 129 | SSLConnectionSocketFactory sslsf = null; |
|---|
| 73 | 130 | try { |
|---|
| 131 | + // Trust all X509 certificates (relies on HTTPS + Basic Auth; consider hardening in production). |
|---|
| 74 | 132 | builder.loadTrustMaterial((KeyStore) null, new TrustStrategy() { |
|---|
| 75 | 133 | @Override |
|---|
| 76 | 134 | public boolean isTrusted(X509Certificate[] chain, String authType) throws CertificateException { |
|---|
| .. | .. |
|---|
| 82 | 140 | LOG.error(e1); |
|---|
| 83 | 141 | throw new SeCurisException("Error creating SSL socket factory"); |
|---|
| 84 | 142 | } |
|---|
| 143 | + |
|---|
| 144 | + // Configure Basic Auth with Mailgun API key |
|---|
| 85 | 145 | CredentialsProvider provider = new BasicCredentialsProvider(); |
|---|
| 86 | | - UsernamePasswordCredentials credentials = new UsernamePasswordCredentials("api", Config.get(Config.KEYS.MAILGUN_API_KEY)); |
|---|
| 146 | + UsernamePasswordCredentials credentials = |
|---|
| 147 | + new UsernamePasswordCredentials("api", Config.get(Config.KEYS.MAILGUN_API_KEY)); |
|---|
| 87 | 148 | provider.setCredentials(AuthScope.ANY, credentials); |
|---|
| 88 | 149 | |
|---|
| 89 | | - return HttpClientBuilder.create().setDefaultCredentialsProvider(provider).setSSLSocketFactory(sslsf); |
|---|
| 150 | + return HttpClientBuilder.create() |
|---|
| 151 | + .setDefaultCredentialsProvider(provider) |
|---|
| 152 | + .setSSLSocketFactory(sslsf); |
|---|
| 90 | 153 | } |
|---|
| 91 | 154 | |
|---|
| 155 | + // --------------------------------------------------------------------- |
|---|
| 156 | + // Email sending API |
|---|
| 157 | + // --------------------------------------------------------------------- |
|---|
| 158 | + |
|---|
| 92 | 159 | /** |
|---|
| 93 | | - * Basic method to send emails in text mode with attachment. The method is |
|---|
| 94 | | - * synchronous, It waits until server responses. |
|---|
| 95 | | - * |
|---|
| 96 | | - * @param subject |
|---|
| 97 | | - * @param body |
|---|
| 98 | | - * @param to |
|---|
| 99 | | - * @param file |
|---|
| 100 | | - * @throws SeCurisException |
|---|
| 101 | | - * @throws UnsupportedEncodingException |
|---|
| 160 | + * sendEmail |
|---|
| 161 | + * <p> |
|---|
| 162 | + * Sends a plain-text email (UTF-8) via Mailgun. Optionally attaches a single file. |
|---|
| 163 | + * This call is <b>synchronous</b> (blocking) and only returns once the HTTP response is received. |
|---|
| 164 | + * |
|---|
| 165 | + * @param subject Email subject (will be sent as UTF-8). |
|---|
| 166 | + * @param body Email body in plain text (UTF-8). |
|---|
| 167 | + * @param to Recipient address (required). |
|---|
| 168 | + * @param cc Optional CC address, may be {@code null}. |
|---|
| 169 | + * @param file Optional file to attach, may be {@code null}. |
|---|
| 170 | + * |
|---|
| 171 | + * @throws SeCurisServiceException if the HTTP call fails or Mailgun responds with a non-200 status |
|---|
| 172 | + * @throws UnsupportedEncodingException kept for API compatibility (body/subject are forced to UTF-8) |
|---|
| 102 | 173 | */ |
|---|
| 103 | 174 | @SuppressWarnings("deprecation") |
|---|
| 104 | | - public void sendEmail(String subject, String body, String to, String cc, File file) throws SeCurisServiceException, UnsupportedEncodingException { |
|---|
| 175 | + public void sendEmail(String subject, String body, String to, String cc, File file) |
|---|
| 176 | + throws SeCurisServiceException, UnsupportedEncodingException { |
|---|
| 177 | + |
|---|
| 105 | 178 | HttpPost postRequest = new HttpPost(serverUrl); |
|---|
| 106 | 179 | |
|---|
| 180 | + // Build multipart form body compatible with Mailgun |
|---|
| 107 | 181 | MultipartEntityBuilder builder = MultipartEntityBuilder.create(); |
|---|
| 108 | | - |
|---|
| 109 | 182 | builder.setCharset(Charset.forName("utf-8")); |
|---|
| 110 | 183 | builder.setMode(HttpMultipartMode.BROWSER_COMPATIBLE); |
|---|
| 111 | 184 | builder.addTextBody("from", Config.get(Config.KEYS.EMAIL_FROM_ADDRESS)); |
|---|
| .. | .. |
|---|
| 113 | 186 | if (cc != null) { |
|---|
| 114 | 187 | builder.addTextBody("cc", cc); |
|---|
| 115 | 188 | } |
|---|
| 116 | | - builder.addTextBody("subject", subject, ContentType.create(ContentType.TEXT_PLAIN.getMimeType(), Charset.forName("utf-8"))); |
|---|
| 117 | | - builder.addTextBody("text", body, ContentType.create(ContentType.TEXT_PLAIN.getMimeType(), Charset.forName("utf-8"))); |
|---|
| 118 | | - if (file != null) { |
|---|
| 189 | + builder.addTextBody("subject", |
|---|
| 190 | + subject, ContentType.create(ContentType.TEXT_PLAIN.getMimeType(), Charset.forName("utf-8"))); |
|---|
| 191 | + builder.addTextBody("text", |
|---|
| 192 | + body, ContentType.create(ContentType.TEXT_PLAIN.getMimeType(), Charset.forName("utf-8"))); |
|---|
| 119 | 193 | |
|---|
| 194 | + if (file != null) { |
|---|
| 120 | 195 | LOG.info("File to attach: {}", file.getAbsoluteFile()); |
|---|
| 121 | 196 | builder.addPart("attachment", new FileBody(file)); |
|---|
| 122 | 197 | } |
|---|
| 123 | 198 | |
|---|
| 124 | 199 | postRequest.setEntity(builder.build()); |
|---|
| 200 | + |
|---|
| 201 | + // Execute HTTP request |
|---|
| 125 | 202 | HttpResponse response; |
|---|
| 126 | 203 | HttpClient httpClient = httpClientBuilder.build(); |
|---|
| 127 | 204 | try { |
|---|
| 128 | 205 | response = httpClient.execute(postRequest); |
|---|
| 129 | 206 | |
|---|
| 207 | + // Mailgun returns JSON. We parse it to a Map for logging/validation. |
|---|
| 130 | 208 | String jsonLic = IOUtils.toString(response.getEntity().getContent()); |
|---|
| 131 | 209 | if (response.getStatusLine().getStatusCode() == 200) { |
|---|
| 132 | 210 | LOG.debug("Response content read OK: {}", jsonLic); |
|---|
| 133 | 211 | Map<String, Object> responseBean = JsonUtils.json2map(jsonLic); |
|---|
| 134 | | - |
|---|
| 135 | 212 | LOG.debug("Response mail read OK: {}", responseBean); |
|---|
| 136 | 213 | } else { |
|---|
| 137 | | - throw new SeCurisServiceException(ErrorCodes.UNEXPECTED_ERROR, "Error sending email, response estatus: " + response.getStatusLine()); |
|---|
| 214 | + throw new SeCurisServiceException( |
|---|
| 215 | + ErrorCodes.UNEXPECTED_ERROR, |
|---|
| 216 | + "Error sending email, response estatus: " + response.getStatusLine()); |
|---|
| 138 | 217 | } |
|---|
| 139 | 218 | } catch (IOException e) { |
|---|
| 140 | 219 | LOG.error("Error sending email", e); |
|---|
| .. | .. |
|---|
| 143 | 222 | } |
|---|
| 144 | 223 | |
|---|
| 145 | 224 | /** |
|---|
| 146 | | - * Basic method to send emails in text mode with attachment. The method is |
|---|
| 147 | | - * asynchronous, It returns immediately |
|---|
| 148 | | - * |
|---|
| 149 | | - * @param subject |
|---|
| 150 | | - * @param body |
|---|
| 151 | | - * @param to |
|---|
| 152 | | - * @param file |
|---|
| 153 | | - * @throws SeCurisException |
|---|
| 154 | | - * @throws UnsupportedEncodingException |
|---|
| 225 | + * sendEmailAsync |
|---|
| 226 | + * <p> |
|---|
| 227 | + * Asynchronous variant of {@link #sendEmail(String, String, String, String, File)}. |
|---|
| 228 | + * The call returns immediately and performs the HTTP request in a single-thread executor. |
|---|
| 229 | + * |
|---|
| 230 | + * @param subject Email subject (UTF-8). |
|---|
| 231 | + * @param body Email body in plain text (UTF-8). |
|---|
| 232 | + * @param to Recipient address. |
|---|
| 233 | + * @param cc Optional CC address, may be {@code null}. |
|---|
| 234 | + * @param file Optional attachment, may be {@code null}. |
|---|
| 235 | + * @param callback Non-null callback to be notified on success or failure. |
|---|
| 236 | + * |
|---|
| 237 | + * @throws SeCurisException if there is a configuration or environment problem before dispatch |
|---|
| 238 | + * @throws UnsupportedEncodingException for API compatibility (subject/body are encoded as UTF-8) |
|---|
| 155 | 239 | */ |
|---|
| 156 | | - public void sendEmailAsync(String subject, String body, String to, String cc, File file, EmailCallback callback) throws SeCurisException, |
|---|
| 157 | | - UnsupportedEncodingException { |
|---|
| 240 | + public void sendEmailAsync( |
|---|
| 241 | + String subject, String body, String to, String cc, File file, EmailCallback callback) |
|---|
| 242 | + throws SeCurisException, UnsupportedEncodingException { |
|---|
| 243 | + |
|---|
| 158 | 244 | Executor ex = Executors.newSingleThreadExecutor(); |
|---|
| 159 | 245 | ex.execute(new Runnable() { |
|---|
| 160 | | - |
|---|
| 161 | 246 | @Override |
|---|
| 162 | 247 | public void run() { |
|---|
| 163 | 248 | try { |
|---|
| .. | .. |
|---|
| 168 | 253 | } catch (SeCurisServiceException e) { |
|---|
| 169 | 254 | callback.error(e); |
|---|
| 170 | 255 | } |
|---|
| 171 | | - |
|---|
| 172 | 256 | } |
|---|
| 173 | 257 | }); |
|---|
| 174 | | - |
|---|
| 175 | 258 | } |
|---|
| 176 | 259 | |
|---|
| 260 | + // --------------------------------------------------------------------- |
|---|
| 261 | + // Callback contract |
|---|
| 262 | + // --------------------------------------------------------------------- |
|---|
| 263 | + |
|---|
| 264 | + /** |
|---|
| 265 | + * EmailCallback |
|---|
| 266 | + * <p> |
|---|
| 267 | + * Functional contract to be notified when an async send finishes. |
|---|
| 268 | + */ |
|---|
| 177 | 269 | public static interface EmailCallback { |
|---|
| 270 | + /** |
|---|
| 271 | + * success<p> |
|---|
| 272 | + * Called when the email was sent and Mailgun returned HTTP 200. |
|---|
| 273 | + */ |
|---|
| 178 | 274 | public void success(); |
|---|
| 179 | 275 | |
|---|
| 276 | + /** |
|---|
| 277 | + * error<p> |
|---|
| 278 | + * Called when there was a problem sending the email. |
|---|
| 279 | + * |
|---|
| 280 | + * @param e encapsulates the reason of failure |
|---|
| 281 | + */ |
|---|
| 180 | 282 | public void error(SeCurisServiceException e); |
|---|
| 181 | 283 | } |
|---|
| 182 | 284 | |
|---|
| 285 | + // --------------------------------------------------------------------- |
|---|
| 286 | + // Manual test harness |
|---|
| 287 | + // --------------------------------------------------------------------- |
|---|
| 288 | + |
|---|
| 289 | + /** |
|---|
| 290 | + * main<p> |
|---|
| 291 | + * Simple manual test for the async email flow. Adjust addresses and file path before use. |
|---|
| 292 | + * |
|---|
| 293 | + * @param args program arguments (unused) |
|---|
| 294 | + * @throws SeCurisException if configuration is invalid |
|---|
| 295 | + * @throws UnsupportedEncodingException if UTF-8 encoding fails (unlikely) |
|---|
| 296 | + */ |
|---|
| 183 | 297 | public static void main(String[] args) throws SeCurisException, UnsupportedEncodingException { |
|---|
| 184 | | - // new EmailManager().sendEmail("España así de bien", |
|---|
| 185 | | - // "Me gusta esta prueba\nCon varias líneas\n\n\n--\nNo response", |
|---|
| 186 | | - // "info@r75.es", new File( |
|---|
| 187 | | - // "/Users/rob/Downloads/test.req")); |
|---|
| 188 | | - new EmailManager().sendEmailAsync("España así de bien", "Me gusta esta prueba\nCon varias líneas\n\n\n--\nNo response", "info@r75.es", |
|---|
| 189 | | - "dev@r75.es", new File("/Users/rob/Downloads/test.req"), new EmailCallback() { |
|---|
| 298 | + // Example async call (subject/body contain non-ASCII content to validate UTF-8 handling). |
|---|
| 299 | + new EmailManager().sendEmailAsync("España así de bien", |
|---|
| 300 | + "Me gusta esta prueba\nCon varias líneas\n\n\n--\nNo response", |
|---|
| 301 | + "info@r75.es", |
|---|
| 302 | + "dev@r75.es", |
|---|
| 303 | + new File("/Users/rob/Downloads/test.req"), |
|---|
| 304 | + new EmailCallback() { |
|---|
| 305 | + @Override |
|---|
| 306 | + public void success() { LOG.info("Success!!!"); } |
|---|
| 190 | 307 | |
|---|
| 191 | 308 | @Override |
|---|
| 192 | | - public void success() { |
|---|
| 193 | | - LOG.info("Success!!!"); |
|---|
| 194 | | - } |
|---|
| 195 | | - |
|---|
| 196 | | - @Override |
|---|
| 197 | | - public void error(SeCurisServiceException e) { |
|---|
| 198 | | - LOG.error("Error: {} !!!", e); |
|---|
| 199 | | - } |
|---|
| 309 | + public void error(SeCurisServiceException e) { LOG.error("Error: {} !!!", e); } |
|---|
| 200 | 310 | }); |
|---|
| 201 | 311 | LOG.info("Waiting for email to be sent..."); |
|---|
| 202 | 312 | } |
|---|
| 203 | | - |
|---|
| 204 | 313 | } |
|---|