From 8aca5d0fe2f557e6c451cc19d76e4fe95ad97e78 Mon Sep 17 00:00:00 2001 From: zzz Date: Fri, 2 Feb 2024 15:43:43 +0000 Subject: [PATCH] Susimail: Add support for HTML email --- apps/susimail/src/js/iframer.js | 29 +++ .../src/i2p/susi/util/RegexOutputStream.java | 6 +- .../src/src/i2p/susi/webmail/CSPDetector.java | 78 ++++++ .../src/src/i2p/susi/webmail/WebMail.java | 239 +++++++++++++++--- apps/susimail/src/susimail.properties | 3 + apps/susimail/src/themes/dark/susimail.css | 4 + apps/susimail/src/themes/light/susimail.css | 6 +- 7 files changed, 328 insertions(+), 37 deletions(-) create mode 100644 apps/susimail/src/js/iframer.js create mode 100644 apps/susimail/src/src/i2p/susi/webmail/CSPDetector.java diff --git a/apps/susimail/src/js/iframer.js b/apps/susimail/src/js/iframer.js new file mode 100644 index 000000000..d2901766b --- /dev/null +++ b/apps/susimail/src/js/iframer.js @@ -0,0 +1,29 @@ +/* @license http://www.gnu.org/licenses/gpl-2.0.html GPL-2.0 */ +/* see also licenses/LICENSE-GPLv2.txt */ + +// called from iframed.js + +function setupFrame() { + var frames = document.getElementsByClassName("iframedsusi"); + for(index = 0; index < frames.length; index++) + { + var frame = frames[index]; + frame.addEventListener("load", function() { + // old way, iframed.js. we use this as a backup in case + // the js injection didn't work + + resizeFrame(frame); + + // new way, iframe-resizer + + // By default the height of the iFrame is calculated by converting the margin of the body to px and then adding the top and bottom figures to the offsetHeight of the body tag. + // In cases where CSS styles causes the content to flow outside the body you may need to change this setting to one of the following options. + // If the default option doesn't work then the best solutions is to use either taggedElement, or lowestElement. + // The **lowestElement** option is the most reliable way of determining the page height. + // However, it does have a performance impact, as it requires checking the position of every element on the page. + iFrameResize({ log: false, heightCalculationMethod: 'lowestElement' }, frame) + }, true); + } +} + +/* @license-end */ diff --git a/apps/susimail/src/src/i2p/susi/util/RegexOutputStream.java b/apps/susimail/src/src/i2p/susi/util/RegexOutputStream.java index 9df99467c..b0525bfed 100644 --- a/apps/susimail/src/src/i2p/susi/util/RegexOutputStream.java +++ b/apps/susimail/src/src/i2p/susi/util/RegexOutputStream.java @@ -3,6 +3,7 @@ package i2p.susi.util; import java.io.FilterOutputStream; import java.io.IOException; import java.io.OutputStream; +import java.util.Locale; import net.i2p.I2PAppContext; import net.i2p.data.DataHelper; @@ -10,6 +11,7 @@ import net.i2p.util.Log; /** * Replace patterns with a simple regex on the fly. + * Case insensitive. * * @since 0.9.62 */ @@ -34,7 +36,7 @@ public class RegexOutputStream extends FilterOutputStream { super(out); if (pattern.length() == 0 || pattern.startsWith("*") || pattern.endsWith("*") || pattern.contains("**")) throw new IllegalArgumentException(); - match = pattern; + match = pattern.toLowerCase(Locale.US); repl = replace; noMatch = onNoMatch; buf = new StringBuilder(64); @@ -60,7 +62,7 @@ public class RegexOutputStream extends FilterOutputStream { found = true; } } - } else if (m == c) { + } else if (m == Character.toLowerCase(c)) { pushit(c); idx++; if (idx == match.length()) { diff --git a/apps/susimail/src/src/i2p/susi/webmail/CSPDetector.java b/apps/susimail/src/src/i2p/susi/webmail/CSPDetector.java new file mode 100644 index 000000000..c6790973f --- /dev/null +++ b/apps/susimail/src/src/i2p/susi/webmail/CSPDetector.java @@ -0,0 +1,78 @@ +package i2p.susi.webmail; + +import java.util.Locale; + +/** + * Check user-agent for support of CSP + * @since 0.9.62 + */ +class CSPDetector { + /** + * ref: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy + */ + public static boolean supportsCSP(String ua) { + if (ua == null) + return false; + ua = ua.toLowerCase(Locale.US); + // mobile anything: assume no + if (ua.contains("mobile")) + return false; + + // ref: https://www.useragents.me/ + // min versions + // chrome: 25 + // covers edge, opera + int idx = ua.indexOf("chrome/"); + if (idx >= 0) { + idx += 7; + return getVersion(ua, idx) >= 25; + } + // safari: 7 + idx = ua.indexOf("safari/"); + if (idx >= 0) { + idx = ua.indexOf("version/"); + if (idx >= 0) { + idx += 8; + return getVersion(ua, idx) >= 7; + } + } + // firefox: 23 + idx = ua.indexOf("firefox/"); + if (idx >= 0) { + idx += 8; + return getVersion(ua, idx) >= 23; + } + return false; + } + + private static int getVersion(String ua, int idx) { + int rv = 0; + for (int i = idx; i < ua.length(); i++) { + char c = ua.charAt(i); + if (c < '0' || c > '9') + break; + if (i > idx) + rv *= 10; + rv += c - '0'; + } + //System.out.println("Found version " + rv + " in " + ua); + return rv; + } + +/**** + public static void main(String[] args) { + String s = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0"; + System.out.println(supportsCSP(s) + " " + s); + s = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"; + System.out.println(supportsCSP(s) + " " + s); + s = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.1"; + System.out.println(supportsCSP(s) + " " + s); + s = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0"; + System.out.println(supportsCSP(s) + " " + s); + s = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/11.0"; + System.out.println(supportsCSP(s) + " " + s); + s = "xxx"; + System.out.println(supportsCSP(s) + " " + s); + } +****/ +} diff --git a/apps/susimail/src/src/i2p/susi/webmail/WebMail.java b/apps/susimail/src/src/i2p/susi/webmail/WebMail.java index ca390e2e5..8d498f8d0 100644 --- a/apps/susimail/src/src/i2p/susi/webmail/WebMail.java +++ b/apps/susimail/src/src/i2p/susi/webmail/WebMail.java @@ -33,6 +33,7 @@ import i2p.susi.util.FilenameUtil; import i2p.susi.util.Folder; import i2p.susi.util.Folder.SortOrder; import i2p.susi.util.OutputStreamBuffer; +import i2p.susi.util.RegexOutputStream; import i2p.susi.util.StringBuilderWriter; import i2p.susi.webmail.Messages; import static i2p.susi.webmail.Sorters.*; @@ -57,6 +58,8 @@ import java.io.PrintWriter; import java.io.Serializable; import java.io.UnsupportedEncodingException; import java.io.Writer; +import java.net.URI; +import java.net.URISyntaxException; import java.text.Collator; import java.util.ArrayList; import java.util.Arrays; @@ -108,6 +111,8 @@ public class WebMail extends HttpServlet private static final int DEFAULT_SMTPPORT = 7659; private enum State { AUTH, LOADING, LIST, SHOW, NEW, CONFIG } + /** @since 0.9.62 */ + private enum HtmlMode { NONE, LINK, ALLOW, PREFER } // TODO generate from servlet name to allow for renaming or multiple instances private static final String myself = "/susimail/"; @@ -166,7 +171,9 @@ public class WebMail extends HttpServlet private static final String SHOW = "show"; private static final String DOWNLOAD = "download"; private static final String RAW_ATTACHMENT = "att"; + private static final String CID_ATTACHMENT = "cid"; private static final String DRAFT_ATTACHMENT = "datt"; + private static final String HTML = "html"; private static final String MARKALL = "markall"; private static final String CLEAR = "clearselection"; @@ -242,6 +249,9 @@ public class WebMail extends HttpServlet private static final String CONFIG_COMPOSER_COLS = "composer.cols"; private static final String CONFIG_COMPOSER_ROWS = "composer.rows"; + private static final String CONFIG_HTML_ALLOWED = "view.html.allowed"; + private static final String CONFIG_HTML_PREFERRED = "view.html.preferred"; + private static final String CONFIG_COPY_TO_SENT = "composer.copy.to.sent"; static final String CONFIG_LEAVE_ON_SERVER = "pop3.leave.on.server"; public static final String CONFIG_BACKGROUND_CHECK = "pop3.check.enable"; @@ -442,7 +452,7 @@ public class WebMail extends HttpServlet } else { buf.append(""); - buf.append("\"^\""); + buf.append("\"^\""); buf.append("\n"); } if (name.equals(currentName) && currentOrder == SortOrder.DOWN) { @@ -450,7 +460,7 @@ public class WebMail extends HttpServlet } else { buf.append(""); - buf.append("\"v\""); + buf.append("\"v\""); buf.append(""); } return buf.toString(); @@ -471,7 +481,7 @@ public class WebMail extends HttpServlet /** * recursively render all mail body parts * - * 1. if type is multipart/alternative, look for text/plain section and ignore others + * 1. if type is multipart/alternative, look for preferred section and ignore others * 2. if type is multipart/*, recursively call all these parts * 3. if type is text/plain (or mail is not mime), print out * 4. in all other cases print out message, that part is not displayed @@ -479,14 +489,17 @@ public class WebMail extends HttpServlet * @param out * @param mailPart * @param level is increased by recursively calling sub parts + * @param html use html styling + * @param allowHtml allow display of text/html parts */ - private static void showPart( PrintWriter out, MailPart mailPart, int level, boolean html ) + private static void showPart(PrintWriter out, MailPart mailPart, int level, boolean html, HtmlMode allowHtml) { String br = html ? "
\r\n" : "\r\n"; if( html ) { out.println( "" ); + if (allowHtml != HtmlMode.NONE) { + out.println( "" ); + out.println("

"); + out.println("" ); + out.println("

" + _t("To protect your privacy, SusiMail has blocked remote content in this message.") + ""); + out.println("

"); + // TODO scrolling=no if js is on + } else if (showBody) { if( html ) out.println( "


" ); String charset = mailPart.charset; @@ -1124,7 +1198,8 @@ public class WebMail extends HttpServlet sessionObject.reallyDelete = false; } } else if (buttonPressed(request, DOWNLOAD) || - buttonPressed(request, RAW_ATTACHMENT)) { + buttonPressed(request, RAW_ATTACHMENT) || + buttonPressed(request, CID_ATTACHMENT)) { // GET params state = State.SHOW; } else if (buttonPressed(request, DRAFT_ATTACHMENT)) { @@ -1198,7 +1273,7 @@ public class WebMail extends HttpServlet pw.println( _t("On {0} {1} wrote:", mail.formattedDate + " UTC", to)); StringBuilderWriter text2 = new StringBuilderWriter(); PrintWriter pw2 = new PrintWriter( text2 ); - showPart( pw2, part, 0, TEXT_ONLY ); + showPart(pw2, part, 0, TEXT_ONLY, HtmlMode.NONE); pw2.flush(); String[] lines = DataHelper.split(text2.toString(), "\r\n"); for( int i = 0; i < lines.length; i++ ) @@ -1293,7 +1368,7 @@ public class WebMail extends HttpServlet if( mail.dateString != null ) pw.print( "Date: " + mail.dateString ); pw.println(); - showPart( pw, part, 0, TEXT_ONLY ); + showPart(pw, part, 0, TEXT_ONLY, HtmlMode.NONE); pw.println( "---- " + _t("end forwarded mail") + " ----" ); pw.flush(); } @@ -1595,16 +1670,33 @@ public class WebMail extends HttpServlet { String str = request.getParameter(DOWNLOAD); boolean isRaw = false; + boolean isCID = false; if (str == null) { str = request.getParameter(RAW_ATTACHMENT); + if (str == null) { + str = request.getParameter(CID_ATTACHMENT); + isCID = true; + } isRaw = true; } if( str != null ) { try { - int id = Integer.parseInt( str ); MailCache mc = getCurrentMailCache(sessionObject, request); Mail mail = (mc != null) ? mc.getMail(showUIDL, MailCache.FetchMode.ALL) : null; - MailPart part = mail != null ? getMailPartFromID(mail.getPart(), id) : null; + MailPart part = null; + if (mail != null) { + if (isCID) { + // strip @ part, see RFC 2045 + // https://stackoverflow.com/questions/39577386/the-precise-format-of-content-id-header + int idx = str.indexOf('@'); + if (idx > 0) + str = str.substring(0, idx); + part = getMailPartFromID(mail.getPart(), str); + } else { + int id = Integer.parseInt( str ); + part = getMailPartFromID(mail.getPart(), id); + } + } if( part != null ) { if (sendAttachment(sessionObject, part, response, isRaw)) return true; @@ -1715,6 +1807,35 @@ public class WebMail extends HttpServlet return null; } + /** + * Recursive. + * @param id a content-id, without the surrounding <> or trailing @ part + * @return the part or null + * @since 0.9.62 + */ + private static MailPart getMailPartFromID(MailPart part, String id) { + if( part == null ) + return null; + if (id.equals(part.cid)) + return part; + if (part.cid != null) { + // strip @ and try again, + int idx = part.cid.indexOf('@'); + if (idx > 0) { + if (id.equals(part.cid.substring(0, idx))) + return part; + } + } + if( part.multipart || part.message ) { + for( MailPart p : part.parts ) { + MailPart subPart = getMailPartFromID(p, id); + if( subPart != null ) + return subPart; + } + } + return null; + } + /** * process buttons of folder view * @param sessionObject @@ -2070,10 +2191,27 @@ public class WebMail extends HttpServlet boolean isMobile = (forceMobileConsole || isMobile(httpRequest.getHeader("User-Agent"))); httpRequest.setCharacterEncoding("UTF-8"); - response.setCharacterEncoding("UTF-8"); - response.setHeader("X-Frame-Options", "SAMEORIGIN"); - response.setHeader("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'; media-src 'none'"); - response.setHeader("X-XSS-Protection", "1; mode=block"); + response.setHeader("X-Frame-Options", "SAMEORIGIN"); + // very strict CSP for HTML emails in iframes + if (httpRequest.getParameter(RAW_ATTACHMENT) != null || + httpRequest.getParameter(CID_ATTACHMENT) != null || + httpRequest.getParameter(DRAFT_ATTACHMENT) != null) { + // we don't even let the iframe get to the console, it must be within /susimail/ + String me = "127.0.0.1:7657"; + String requrl = httpRequest.getRequestURL().toString(); + try { + URI uri = new URI(requrl); + me = uri.getHost() + ':' + uri.getPort(); + } catch(URISyntaxException use) {} + // img-src allows for cid: urls that were fixed up by RegexOutputStream + response.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; script-src " + me + "/js/iframeResizer.contentWindow.js; form-action 'none'; frame-ancestors " + me + myself + "; object-src 'none'; media-src 'none'; img-src " + me + myself + "; font-src 'none'; frame-src 'none'; worker-src 'none'"); + // character encoding will be set in sendAttachment() + } else { + response.setHeader("Content-Security-Policy", "default-src 'self'; style-src 'self'; script-src 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'; media-src 'none'; img-src 'self'"); + response.setCharacterEncoding("UTF-8"); + } + response.setHeader("Permissions-Policy", "accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), display-capture(), fullscreen=(self), geolocation=(), gyroscope=(), interest-cohort=(), magnetometer=(), microphone=(), midi=(), payment=(), usb=(), vibrate=(), vr=()"); + response.setHeader("X-XSS-Protection", "1; mode=block"); response.setHeader("X-Content-Type-Options", "nosniff"); response.setHeader("Referrer-Policy", "no-referrer"); response.setHeader("Accept-Ranges", "none"); @@ -2424,6 +2562,9 @@ public class WebMail extends HttpServlet out.println(""); out.println(""); out.println(""); + out.println(""); + out.println(""); + out.println(""); } out.println(""); out.print("\n"); @@ -2523,8 +2664,24 @@ public class WebMail extends HttpServlet else if( state == State.LIST ) showFolder( out, sessionObject, mc, request ); - else if( state == State.SHOW ) - showMessage(out, sessionObject, mc, showUIDL, buttonPressed(request, DELETE)); + else if( state == State.SHOW ) { + // Determine what HtmlMode we're going to show the mail in + boolean disable = isMobile || + !CSPDetector.supportsCSP(httpRequest.getHeader("User-Agent")); + boolean link = !disable; + String hp = httpRequest.getParameter(HTML); + boolean allow = link && + ("1".equals(hp)) || + (!"0".equals(hp) && + Boolean.parseBoolean(Config.getProperty(CONFIG_HTML_ALLOWED, "true"))); + boolean prefer = allow && + ("1".equals(hp)) || + (!"0".equals(hp) && + Boolean.parseBoolean(Config.getProperty(CONFIG_HTML_PREFERRED, "true"))); + HtmlMode allowHTML = prefer ? HtmlMode.PREFER : allow ? HtmlMode.ALLOW : + link ? HtmlMode.LINK : HtmlMode.NONE; + showMessage(out, sessionObject, mc, showUIDL, buttonPressed(request, DELETE), allowHTML); + } else if( state == State.NEW ) showCompose( out, sessionObject, request ); @@ -2603,10 +2760,23 @@ public class WebMail extends HttpServlet response.setContentType(part.type); if (part.charset != null) response.setCharacterEncoding(part.charset); - if (part.decodedLength >= 0) - response.setContentLength(part.decodedLength); - if (log.shouldDebug()) log.debug("Sending raw attachment " + name + " length " + part.decodedLength); out = response.getOutputStream(); + if ("text/html".equals(part.type)) { + // inject the js into the iframe + // For content without , we put it at the end and hope for the best. + out = new RegexOutputStream(out, "", + "\n\n\n\n", + "\n\n\n"); + // convert cid: urls to /susimail/?cid= urls + out = new RegexOutputStream(out, " src=\"cid:", + " src=\"/susimail/?msg=" + Base64.encode(part.uidl) + "&cid=", + null); + // don't set content length, as it may change due to replacements + } else { + if (part.decodedLength >= 0) + response.setContentLength(part.decodedLength); + } + if (log.shouldDebug()) log.debug("Sending raw attachment " + name + " length " + part.decodedLength); part.decode(0, new OutputStreamBuffer(out)); shown = true; } catch (IOException e) { @@ -3501,9 +3671,10 @@ public class WebMail extends HttpServlet * @param out * @param sessionObject * @param reallyDelete was the delete button pushed, if so, show the really delete? message + * @param allowHtml allow display of text/html parts */ private static void showMessage(PrintWriter out, SessionObject sessionObject, MailCache mc, - String showUIDL, boolean reallyDelete) + String showUIDL, boolean reallyDelete, HtmlMode allowHTML) { if (reallyDelete) { out.println( "

" + _t("Really delete this message?") + ' ' + @@ -3611,7 +3782,7 @@ public class WebMail extends HttpServlet "\n" ); if( mail.hasPart()) { mail.setNew(false); - showPart( out, mail.getPart(), 0, SHOW_HTML ); + showPart(out, mail.getPart(), 0, SHOW_HTML, allowHTML); } else { out.println( "

" + _t("Could not fetch mail body.") + "

\n" ); diff --git a/apps/susimail/src/susimail.properties b/apps/susimail/src/susimail.properties index b5012e897..11419e753 100644 --- a/apps/susimail/src/susimail.properties +++ b/apps/susimail/src/susimail.properties @@ -9,6 +9,9 @@ susimail.sender.domain=mail.i2p susimail.pager.pagesize=30 +susimail.view.html.allowed=true +susimail.view.html.preferred=true + susimail.composer.cols=80 susimail.composer.rows=15 susimail.composer.copy.to.sent=true diff --git a/apps/susimail/src/themes/dark/susimail.css b/apps/susimail/src/themes/dark/susimail.css index 0caa616a9..35e73fc77 100644 --- a/apps/susimail/src/themes/dark/susimail.css +++ b/apps/susimail/src/themes/dark/susimail.css @@ -276,6 +276,10 @@ td[colspan="9"] hr { padding: 0 10px !important; } +iframe { + background: #ccc; +} + table#mailbox { margin-bottom: 0; } diff --git a/apps/susimail/src/themes/light/susimail.css b/apps/susimail/src/themes/light/susimail.css index 4aeef8e81..b1046626b 100644 --- a/apps/susimail/src/themes/light/susimail.css +++ b/apps/susimail/src/themes/light/susimail.css @@ -124,7 +124,7 @@ tr.mailbody td { } .mailbody p.info { - margin: -32px 0 0; + margin: 20px 20px 0 !important; display: block; border: 1px solid #447; border-radius: 2px; @@ -1333,6 +1333,10 @@ tr.bottombuttons:empty { display: none; } +iframe { + background: #eee; +} + #composemail .bottombuttons { background: none !important; box-shadow: none !important;