На php отправка mail реализуется одной строчкой кода! А на java- нужно 3 недели??! (из разговоров с разработчиками и менеджерами) |
Статья не о том, как отправлять почту на java. Моя цель — показать сложности модульной разработки больших приложений (на примере разработки ERP River).
Итак, задача: реализовать сервис отправки по email (war).
Этапы разработки:
Начнем собственно с отправки
Если не Spring (для небольшого модуля он не нужен), подключаем apache commons-email
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-email</artifactId>
<version>1.3</version>
</dependency>
и пишем
public class MailSender {
public static void sendMail {
HtmlEmail email = ...
?
email.send();
Позвольте, откуда брать настройки почтового сервера? Хардкодить их, думаю, не придет в голову даже младшему разработчику, поэтому:
Конфигурирование почтового сервера
Надеюсь, у нас уже выполнена общая работа по конфигурированию всей системы, и нам остается только реализовать ее для почтового модуля:
...
<part key="email">
<entry key="hostName">smtp.gmail.com</entry>
<entry key="user">sendmail@mycompany.ru</entry>
<entry key="password">psw</entry>
<entry key="smtpPort">465</entry>
<entry key="useSSL">true</entry>
<entry key="debug">false</entry>
<entry key="charset">UTF-8</entry>
<entry key="useTLS">false</entry>
</part>
public class MailConfig {
public static <T extends Email> T prepareEmail(T email) {
email.setHostName(hostName);
email.setSmtpPort(port);
email.setSSL(useSSL);
email.setTLS(useTLS);
email.setDebug(debug);
email.setAuthenticator(defaultAuthenticator);
email.setCharset(charset);
return email;
}
Вызов сервиса
Ага, у нас сервис- как мы хотим его вызывать?
Бизнес хочет интеграцию по веб-сервисам, и нужно еще иметь отправку по простому HTTP GET (например, вызывать напрямую из браузера):
- Отправка по HTTP GET:
public class MailServlet extends CommonServlet { @Override protected void doProcess(HttpServletRequest request, HttpServletResponse response, Map<String, String> params) throws IOException, ServletException { String from = ConfigUtil.getProperty("from", params); ... MailSender.sendMail(from, to, cc, ..);
- Реализация вев-сервиса (JAX-WS) посложнее:
@WebService @SOAPBinding(style = Style.RPC) public interface MailService { @WebMethod public void sendMail( @WebParam(name = "from") String from, @WebParam(name = "to") String to, @WebService(endpointInterface = "mycompany.MailService") public class MailServiceImpl implements MailService { @Override public void sendMail(String from, String to, String cc, String subject, String body, String attachmentUrls) throws StateException { MailSender.sendMailAndRecordHistory(from, to, cc, subject, body, ..); }
и mailService.wsdl:
<definitions .. targetNamespace="http://mail.mycompany.com/" name="MailServiceImplService"> <message name="sendMail"> <part name="from" type="xsd:string"/> ... <portType name="MailService"> <operation name="sendMail" parameterOrder="from to cc subject body attachmentUrls"> <input wsam:Action="http://mail.mycompany.com/MailService/sendMailRequest" message="tns:sendMail"/> ... <binding name="MailServiceImplPortBinding" type="tns:MailService"> <soap:binding transport="http://schemas.xmlsoap.org/soap/http" style="rpc"/> <operation name="sendMail"> <soap:operation soapAction=""/> ... <service name="MailServiceImplService"> <port name="MailServiceImplPort" binding="tns:MailServiceImplPortBinding"> <soap:address location="http://mycompany:8080/mail/mailService"/> ...
Не забываем web.xml (Tomcat)
<listener> <listener-class>com.sun.xml.ws.transport.http.servlet.WSServletContextListener</listener-class> </listener> <servlet> <servlet-name>mailService</servlet-name> <servlet-class>com.sun.xml.ws.transport.http.servlet.WSServlet</servlet-class> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>mailService</servlet-name> <url-pattern>/mailService</url-pattern> </servlet-mapping> <servlet> <servlet-name>mailServlet</servlet-name> <servlet-class>com.mycompany.mail.MailServlet</servlet-class> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> ...
Выделение mail-client
А как теперь соседнему модулю нашей системы быстро дернуть по веб-сервису наш сервис? Проще всего — выделить maven модуль mail-client, сделать от него зависимым наш mail сервис и разрешить любому модулю — нашему клиенту включать в себя (maven dependency) mail-client:
- Делаем отдельный maven модуль mail-client и кладем в него mailService.wsdl и interface MailService
<groupId>com.mycompany</groupId> <artifactId>mail-client</artifactId> <name>Mail Client</name>
- Кроме того, для полной радости нашего внутреннего клиента делаем MailWSClient:
вызов соседнего модуля будет совсем простой:
MailWSClient.sendMail(...
public class MailWSClient { static String mailWsdl; private static final Service SERVICE; static { URL url = MailWSClient.class.getClassLoader().getResource("mailService.wsdl"); SERVICE = Service.create(url, new QName("http://mail.mycompany.com/", "MailServiceImplService")); // get mail endpoint from config mailWsdl = Config.getUrlAsString("mail/mailService?wsdl"); } public static void sendMail(String from, String to, ..){ getPort().sendMail(from, .. private static MailService getPort() { MailService port = SERVICE.getPort(MailService.class); Map<String, Object> requestContext = ((BindingProvider) port).getRequestContext(); requestContext.put(BindingProvider.ENDPOINT_ADDRESS_PROPERTY, mailWsdl); return port; }
Прикручиваем шаблоны
Эге. В модуле документооборота у нас 52 вида документов. Хорошо б было нашим клиентам дать возможность самим определять шаблон письма. Тем более, что такой сервис (TemplateService) у нас уже реализован.
Сервис шаблонов простой: реализован на jsp, по get ему отправляются ключ и параметры, возвращается готовый текст.
- Добавляем sendTemplateMail в MailService, MailWSClient, MailServiceImpl, MailSender, mailService.wsdl и MailServlet:
sendTemplateMail(.., templateKey, params);
- И реализуем его в MailSender (у нас уже есть удобная обертка MyHttpConnection, реализованная через HttpURLConnection.openConnection())
static void sendTemplateMail(..., String key, String params) { LOGGER.info("Send template mail from ... String templateUrl = getUrlAsString("template?type=mail&format=html&key=" + key ... MyHttpConnection conn = MyHttpConnection.connect(templateUrl, params); if (conn.isOk()) { String body = conn.getMsg(); sendMail(from, to, cc, MailUtil.getSubject(body), body); } else { throw LOGGER.getStateException(conn.toString(), ExceptionType.TEMPLATE); ...
Попутно пришлось решить проблему с subject: сервис шаблонов возвращает только тело письма. Шаблон возвращается в формате html, MailUtil выделает из шаблона tiltle и использует его как subject:
public class MailUtil { static Pattern MAIL_TITLE = Pattern.compile("<title>(.+)</title>", Pattern.MULTILINE); static String getSubject(String template) { Matcher m = MAIL_TITLE.matcher(template); return m.find() ? m.group(1) : null; }
Отправляем документ
Вообще-то у нас документы. А что, если вызывать нас сервис с id документа? Шаблоны для документов в TemplateService уже есть.
- Добавляем sendDocMail в MailService, MailWSClient, MailServiceImpl, MailSender, mailService.wsdl и MailServlet.
sendDocMail(String from, String to, String cc, String key, long docId);
- Опаньки, а у документов есть вложения, которые нужно аттачить к письму.
К счастью commons-email это легко позволяет, и у нас есть общий maven модуль attach-common, у которого можно попросить список аттачей по docId:
public class MailSender { static void sendDocMail(String from, String to, String cc, String key, long docId) throws StateException { List<Attach> list = AttachUtil.getList(docId); MailSender.sendTemplateMailAndRecordHistory(from, to, cc, key, "objectid=" + docId, MailUtil.formatAttach(list)); } public class MailUtil { // format attaches as // ulr1[name1], ulr2[name2], ... static String formatAttach(List<Attach> list) { return Util.collectionToDelimitedString(list, new Presentable<Attach>() { @Override public String toString(Attach attach) { return AttachConfig.downloadUrl + attach.getUuid() + '[' + attach.getName() + ']'; }
Отказоустойчивость
А если сервер временно недоступен? Нужно сохранять историю в базе и делать доталкиватель… Заодно решим проблему отправки письма пользователю по назначению на него задачи из BPM — ее можно будет реализовать через триггер в базе: вставлять в таблицу строчку TODO. Как side effect имеем историю отправки наших сообщений, можно потом сверху накрутить ui ну и просто SQL запросы к таблице поделать.
Хорошо, что у нас уже есть механизм сканирования — нужна просто еще одна ее реализация.
- Делаем в базе таблицу mail_action
CREATE TABLE hist.mail_action ( id SERIAL, _from TEXT, _to TEXT NOT NULL, _cc TEXT, subject TEXT, body TEXT, attachmenturls TEXT, state TEXT NOT NULL, date TIMESTAMP(0) WITHOUT TIME ZONE, key reference.ui_key, params TEXT );
- Добавляем в конфигурацию интервалы сканирования
<entry key="scanTodoInterval">30</entry> <entry key="scanFailInterval">600</entry>
scanTodoInterval = ConfigUtil.getInt(SCAN_TODO_INTERVAL, mailProps, 60); // default 60 sec scanFailInterval = ConfigUtil.getInt(SCAN_FAIL_INTERVAL, mailProps, 600); // default 10 min
Реализуем в MailSender запись истории отправки в базу вместе с состоянием (OK или Exception).
Сканируем таблицу mail_action и на основе состояния state (TODO, EmailException) отсылаем письмо
<listener> <listener-class>com.mycompany.common.web.SchedulerListener</listener-class> </listener>
public class MailWebScanner implements WebScheduler { private final MailScanner todoScanner = new MailScanner("TODO"); private final MailScanner failScanner = new MailScanner("org.apache.commons.mail.EmailException"); @Override public void activate(ServletContext servletContext) { todoScanner.startScanning(MailConfig.scanTodoInterval); failScanner.startScanning(MailConfig.scanFailInterval); } @Override public void deactivate() { todoScanner.deactivate(); failScanner.deactivate(); } @Override public void shutdown() { AsyncExecutor.shutdown(); } } public class MailScanner extends Scanner { private static final BeanListHandler<MailBean> HANDLER = new BeanListHandler<MailBean>(MailBean.class); private final String startWith; public MailScanner(String startWith) { this.startWith = startWith; } void startScanning(int interval) { activate(new Runnable() { @Override public void run() { for (MailBean mail : getMailToSend()) { MailSender.sendTemplateMailAndRecordHistory( } } }, interval, false); } ... List<MailBean> getMailToSend() { return SqlUtil.executeQuery("select * from hist.mail_action where state like '" + startWith + "%'", HANDLER); ...
Для тех, кто не любит ждать: асинхронность
Так как наш сервис теперь устойчив к отказам, дадим возможность клиентам нашего веб-сервиса не ждать ответа. Вместо того, чтобы дублировать все методы серсвиса с постфиксом Async и аннотацией @OneWay добавим в вызовы MailWSClient флаг async и вызов AsyncExecutor (нашей обертки поверх ScheduledThreadPoolExecutor):
public class MailWSClient {
public static void sendMail(final String from, final String to, final String cc, final String subject, final String body, final String attachmentUrls, boolean async) throws StateException {
send(new Runnable() {
@Override
public void run() {
getPort().sendMail(mask(from), mask(to), mask(cc), mask(subject), mask(body), mask(attachmentUrls));
}
}, async);
}
public static void sendTemplateMail(final String from, final String to, final String cc, final String key, final String params, final String attachmentUrls, boolean async) throws StateException {
...
public static void sendDocMail(final String from, final String to, final String cc, final String key, final long docId, boolean async) throws StateException {
...
private static void send(Runnable task, boolean async) {
if (async) {
AsyncExecutor.submit(task);
} else {
task.run();
}
}
Чиним вложения картинок
Олично, все работает! Наконец, можно фиксить баги — картинки в письме не видны снаружи нашего интранета… Ведь они у нас в шаблонах заданы через <img src=«наши внутренние ресурсы», естественно, во всем остальном мире их не увидишь.
Делаем их встроенными:
public class MailSender {
static void sendMailAndRecordHistory(String from, String to, String cc, String key, String params, String attachmentUrls, long docId) throws StateException {
...
String embedImgBody = MailUtil.embedImg(body, email);
public class MailUtil {
static final Pattern HTML_URL = Pattern.compile("<img src=(?:\"|')(.+)(?:\"|')", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE);
public static String embedImg(String body, final HtmlEmail email) throws EmailException {
return StringUtil.resolveReplacement(body, HTML_URL, new Presentable<Matcher>() {
@Override
public String toString(Matcher matcher) {
String url = matcher.group(1);
cid = email.embed(url, UUID.nameUUIDFromBytes(url.getBytes()).toString());
}
return "<img src=\"cid:" + cid + "\"";
...
Отправляем встроенные (data:image/png;base64,encoded_img) большие картинки
Новая задумка бизнеса — по ошибке из браузера клиента отправлять на support mail скриншот экрана.
Решение на UI найдено — ход за нами. Для сервиса шаблонов пишем шаблон error_mail.jsp
<%@page pageEncoding="UTF-8" %>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Error Report</title>
</head>
<body>
<h2>Error Report from '${user}'</h2>
<b>Message:</b>
<pre>
${message}
</pre>
<b>Screenshot:</b><br>
<img src="${screenshot}">
</body>
</html>
Параметры шаблона — exception message и base64_encoded_screenshot — отправляются в TemplateService из нашего сервиса. У нас проблемы: наша самописная обертка MyHttpConnection не может через GET отправлять base64_encoded_screenshot. Приходиться делать POST и еще раз делать URLEncoder.encode из за проблем с "+". Кроме того- в пришедшей почте inline картинка не видна :( Что ж, придется ее также делать вложением:
public class MailUtil {
static Pattern DATA_PROTOCOL = Pattern.compile("^data:(.+);(.+),");
public static String embedImg(String body, final HtmlEmail email) throws EmailException {
return StringUtil.resolveReplacement(body, HTML_URL, new Presentable<Matcher>() {
@Override
public String toString(Matcher matcher) {
String url = matcher.group(1);
String cid;
try {
Matcher m = DATA_PROTOCOL.matcher(url);
if (m.find()) {
final String cType = m.group(1);
final String encoding = m.group(2);
final String content = url.substring(m.toMatchResult().end());
cid = email.embed(new javax.activation.DataSource() {
@Override
public InputStream getInputStream() throws IOException {
try {
return javax.mail.internet.MimeUtility.decode(new ByteArrayInputStream(IOUtil.getBytes(content)), encoding);
} catch (MessagingException e) {
throw LOGGER.getIllegalStateException("Image encoding failed", e);
}
}
// empty realization for other javax.activation.DataSource methods
...
}, UUID.randomUUID().toString());
} else {
cid = email.embed(url, UUID.nameUUIDFromBytes(url.getBytes()).toString());
}
return "<img src=\"cid:" + cid + "\"";
...
Финальная точка: безопасность
Однако, любой пользователь отправляет get запрос из браузера- и получает письмо с совершенно секретным документом. Нехорошо. Необходимо прикрутить проверку доступа у пользователя к документу с переданным docId и вообще проверить: если запрос пришел по по get, залогинен ли пользователь в нашу систему.
Из-за того, что страница с логином уже была сделана и вокруг нее много что вертелось, а точка входа в систему у нас одна, я сделал проверку через REST и куки уровня домена с доверием к серверным запросам между самими модулями, но это уже — отдельная статья.
Итоги простой задачи отправки почты:
В результате получилось 2 maven модуля с классами (не считая инфраструктуры типа конфигурации, вложений, шаблонов, общей части и JUnit тестов)
- mail-client
- MailService: интерфейс (sendMail, sendTemplateMail, sendDocMail)
- MailWSClient: обертка к клиенту, выставляющая endPoint из конфигурации
- mailService.wsdl
- mail-service
- MailSender: собственно отправка
- MailServiceImpl: имплементация веб-сервиса, делегирование в MailSender
- MailServlet: сервлет для обработки HTTP GET
- MailBean: бин для чтения строки из базы через commons-dbutils
- MailConfig: конфигурация
- MailScanner: сканирование таблицы по состоянию отправки
- MailWebScanner: реализация листенера для нашего сервиса, запускающего 2 сканнера MailScanner
- MailUtil: утильные методы
- EmailExceptionHandler: обработка exceptions, не доталкивается AddressException
- sun-jaxws.xml, web.xml
Терпеливый читатель, дошедший до конца статьи может сам сравнить количество трудозатрат на «задачу отправку почты» и полученную реализацию. Спасибо за внимание.