Дневник одного бага: как я чинил картинки в электронной почте

в 9:23, , рубрики: 6строчекЗа6Часов, email, java, Weblogic, да-беги-оно-конем

Есть внутренняя система, которая крутится на Weblogic, есть готовый шаблон почты, есть программист и есть баг. Вот вы знали, что почтовые клиенты с большОй вероятностью не смогут показать картинку, которая вставлена в разметку письма, источник которой начинается на data:image/gif;base64?

Я, например не знал. Да что уж там, я до этого даже не знал что картинки можно вставлять в HTML разметку без, собственно, самой картинки. Так получилось, что одну и ту же .jsp используют и для составления страницы для печати, и, в отдельных случаях, для электронного письма. В итоге в обозревателе письмо открывается нормально, а почтовые клиенты показывают битую картинку.

Это будет рассказик про процесс нахождения одного решения. Теперь обо всем по порядку.

Посреди дня приходит жалоба от клиента, что в письме, которое посылает система не показываются картинки. Руководитель проекта сделал задачу, дал мне, впрочем, все как обычно. Полез проверять через интерфейс и взаправду, мой Outlook выдал заготовку с красными крестами в квадратах вместо картинок. В коде приметил, что тот же .jsp файл-шаблон используется чтобы составлять данные документа для распечатки. В тот момент я подумал, что как хорошо, что не надо будет проходить всю бизнес логику чтобы получить письмо, а смогу просто смотреть на заготовку для печати. Открыв заготовочку все оказалось красиво и как надо.

Задачка стала немного менее приятной, но кто боится вызовов? Сначала пошерстил интернет в поисках способов, как заставить Outlook показать мне разметку письма, что удалось довольно быстро. В разметке нашел, что в отличие от веб интерфейса в base64 картинке вместо "+" было "+". Первая догадка была, что метод который посылает HTML заготовку на SMTP сервер фильтрует какие-то спецсимволы. Это было не так, до самого "email.send();" строка содержала все нужные символы.

Пора обратиться к компании добра за направлением к причинам такого поведения. Через некоторое время нашел ресурс, в котором автор проверял такие картинки на совместимость с почтовыми клиентами. В приведенной им табличке без огрехов показывал картинки только яблочный клиент. В коде его письма я увидел заветный "+" и понял, что это мой случай. Рассказал об открытии аналитику. Он сказал, что все равно надо чинить, и заодно пожаловался, что раньше же было все хорошо.

А раньше система крутилась на OC4J и после переезда на Weblogic относительные пути тоже куда-то съехали. Из-за путей кидала ошибку уже упомянутая заготовка для печати. Коллега заготовочку для печати поправил, но письма или никто не приметил, или просто не тестили, ведь печать-то работает. Нашел метод, где формировалась строка из .jsp, и чуть глубже нашел и что-то интересное.

Метод-отправитель письма

protected boolean sendHtmlEmail(String fromAddress, String fromName, String toAddress, String toName, String subject, String textContent, String htmlContent, String serverName, String serverPort, String serverPath, String docRoot) {

        boolean result = true;
        HtmlEmail email = new HtmlEmail();

        try {
            // Setup email parameters
            email.setHostName(SMTP_HOST);
            email.setSmtpPort(SMTP_PORT);
            email.setCharset("UTF-8");
            email.addTo(toAddress, toName);
            email.setFrom(fromAddress, fromName);
            email.setSubject(subject);
            // Add text body
            if (!"".equals(textContent)) {
                email.setTextMsg(textContent);
            }
            // Add HTML body with inline images
            if (!"".equals(htmlContent)) {
//        System.out.println("htmlContent src: " + htmlContent);

                Matcher matcher = ManagerBase.imageSrcPattern.matcher(htmlContent);
                //URL imageURL = null;
                String imageCID = null;
                while (matcher.find()) {
                    //System.out.println("sendHtmlEmail:");
                    //System.out.println("imageURL done: " + imageURL.toString());
                    
                    //ClassLoader classLoader = getClass().getClassLoader();
                    //File file = new File(classLoader.getResource("../../" + matcher.group(2)).getFile());

                   // File image = new File(docRoot + matcher.group(2));
                   // imageCID = email.embed(image/*, matcher.group( 2 )*/);
//          System.out.println("imageCID done:" + imageCID);
                   // htmlContent = htmlContent.replaceFirst(matcher.group(2), "cid:" + imageCID);
                }
                //System.out.println("htmlContent done: " + htmlContent);
                email.setHtmlMsg(htmlContent);

            }

            // Build && send email
            //email.buildMimeMessage();
            result = result && filterDebugMail(toAddress, errors);

            if (result == true)
                email.send();

        }
        catch (Exception e) {
            // ......тут обработка ошибок, тут неинтересно
           // ...... хотя и вне спойлера наверное не очень :)
		}

        return result;
    }

Похоже, что это использовалось до кодированных картинок? Я подумал, что да. Но CVS мне сказал, что первая версия файла была уже с таким вот методом. Но ничего, тут же почти все готово. Почитаю про «cid» и будет все хорошо. Так я думал…

С начала исследования проблемы прошел может час, хотя скорее всего все два. Пора бы и что-нибудь закодить наконец. Сначала надо найти картинки. В ресурсах только шаблоны .doc документов, странно. Нашел картинки в папке рядом с пачкой .jsp. Ладно, пускай, но все равно скопирую нужные мне картинки в ресурсы, оттуда хотя бы стандартным getResource() можно достать. Так, что надо для cid?

О! Рядом с HtmlEmail есть его приемник ImageHtmlEmail, он явно лучше подойдет, мне как раз картинки и нужны. Дальше решил все таки разделить начальный .jsp и сделал для почты практически идентичный прошлому (знаю про DRY, не бейте, пожалуйста). Но по гайдам поменял значения src атрибутов на «cid:[название файла без расширения]». Не обязательно должно было быть название файла, но мне так показалось логичнее, тем более, что паттерн для Matcher уже отбирает то что внутри тэга img.
Дальше написал чтобы система по Matcher выбирала правильный файл из ресурсов:

Начало

while (matcher.find()) {                    
		    // находим имг тэг, src будет в стиле cid:[название файла]
                    String cidInFile = matcher.group(2);
                    // название файла
                    String imageName = cidInFile.substring(cidInFile.indexOf(":")+1);
                    // файл в ресурсах
                    String imageFullResPath = "/images/print/" + imageName + ".gif";
}

Так мне надо запихнуть картинку в email.embed(), метод требует DataSource. Обращение в корпорацию добра, 5 новых открытых вкладок, новое представление о новом знакомом. Из четырех полноценных классов-реализаций интересны оказались два — ByteArrayDataSource и FileDataSource. Но файл предполагает работу с путями, а так как прошлое решение было переделано из-за путей, оставим это на крайний случай. ByteArrayDataSource в конструкторе хочет массив байтов и тип данных. Еще одно обращение к добру, еще 7 вкладок. Пусть меня поправят, если я не прав, но в тип надо подавать MIME тип. У меня гифки — «image/gif». Собственно массив данных получаем с помощью getResourceAsStream() и IOUtils из apache-commons-io.

Теперь все как то так:

Пол пути

while (matcher.find()) {                    
		    // находим имг тэг, src будет в стиле cid:[название файла]
                    String cidInFile = matcher.group(2);
                    // название файла
                    String imageName = cidInFile.substring(cidInFile.indexOf(":")+1);
                    // файл в ресурсах
                    String imageFullResPath = "/images/print/" + imageName + ".gif";
                    // сделаем массив байтов
                    ClassLoader classLoader = getClass().getClassLoader();
                    InputStream is = classLoader.getResourceAsStream(imageFullResPath);
                    byte[] imageData = IOUtils.toByteArray(is);
                    // embed был нужеy DataSource
                    DataSource ds = new ByteArrayDataSource(imageData, "image/gif");
             
                    String cid = email.embed(ds, imageName);
}

Выглядит намного лучше, тем более, что когда прогнал через дебагер, ошибок не было. Откомментировал email.send(), запускаю — NullPointerException, проблема. Вроде все у меня не null, но повторные попытки приводят к тем же результатам. Хорошо что добро показало, где можно посмотреть исходники ImageHtmlEmail, которое кидает NPE. Хмм… там единственное что может кидать NPE — DataSourceResolver, его же не было не в одном из трех прочитанных обучений про ImageHtmlEmail. Ну вот же — не… есть, и в том есть, и в последнем тоже.

Попытка добавить нужное остановилась на выборе имплементации, потому что DataSourceFileResolver был, а какого-нибудь DataSourceByteResolver — не было.
Тут пропустим два часа тщетных попыток все уже написанное перевести под FileDataSource. Но в конце отчаявшись, я подсмотрел, что DataSourceFileResolver не важно, что у меня за DataSource, если src картинки начинается с «cid».

В общем, конечный вариант выглядел как-то так:

Конец

protected boolean sendHtmlEmail(String fromAddress, String fromName, String toAddress, String toName, String subject, String textContent, String htmlContent, String serverName, String serverPort, String serverPath, String docRoot) {

        boolean result = true;
        ImageHtmlEmail email = new ImageHtmlEmail();

        try {
            // Setup email parameters
            email.setHostName(SMTP_HOST);
            email.setSmtpPort(SMTP_PORT);
            email.setCharset("UTF-8");
            email.addTo(toAddress, toName);
            email.setFrom(fromAddress, fromName);
            email.setSubject(subject);

            // Add text body
            if (!"".equals(textContent)) {
                email.setTextMsg(textContent);
            }

            // Add HTML body with inline images
            if (!"".equals(htmlContent)) {

                URL resurl = getClass().getResource("/");
                URI resURI = resurl.toURI();

                File resFolder =  new File( resURI );
                resFolder = resFolder.getParentFile();
                DataSourceResolver resolver = new DataSourceFileResolver(resFolder);

                email.setDataSourceResolver(resolver);

                Matcher matcher = ManagerBase.imageSrcPattern.matcher(htmlContent);

                while (matcher.find()) {
                    // находим имг тэг, src будет в стиле cid:[название файла]
                    String cidInFile = matcher.group(2);
                    // название файла
                    String imageName = cidInFile.substring(cidInFile.indexOf(":")+1);
                    // файл в ресурсах
                    String imageFullResPath = "/images/print/" + imageName + ".gif";
                    // сделаем массив байтов
                    ClassLoader classLoader = getClass().getClassLoader();
                    InputStream is = classLoader.getResourceAsStream(imageFullResPath);
                    byte[] imageData = IOUtils.toByteArray(is);
                    // embed был нужеy DataSource
                    DataSource ds = new ByteArrayDataSource(imageData, "image/gif");
                    String cid = email.embed(ds, imageName);

                }
                // email ready, enjoy your transfer
                email.setHtmlMsg(htmlContent);

            }

            result = result && filterDebugMail(toAddress, errors);

            if (result == true)
                email.send();

        }
        catch (Exception e) {
        //            .........
        }

        return result;
    }

Считаю нужным отметить, что пропущено и по сути ~6 строчек кода потребовала:

— 6 часов рабочего времени
— >30 вкладок с обучениями, StackOverflow, итд.
— по одному сдержанному крепкому словечку каждый третий локальный билд
— >30 локальных билдов

Хотел написать себе заметку о найденном решении, но вдруг кому еще пригодится… если найдут.

Автор: xKiMaNx

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js