From b43ebd24867f2a09610f77d0da6bc16a825b557a Mon Sep 17 00:00:00 2001 From: zzz Date: Tue, 22 Apr 2014 11:18:56 +0000 Subject: [PATCH] * SusiMail: - Add persistent cache - Remove ID sorter - Mail size getter/setter - Set mail size when setting body - Only send CAPA once - Tagged string tweaks --- .../src/src/i2p/susi/webmail/Mail.java | 13 +- .../src/src/i2p/susi/webmail/MailCache.java | 46 ++- .../i2p/susi/webmail/PersistentMailCache.java | 280 ++++++++++++++++++ .../src/src/i2p/susi/webmail/WebMail.java | 56 ++-- .../i2p/susi/webmail/pop3/POP3MailBox.java | 24 +- history.txt | 14 + .../src/net/i2p/router/RouterVersion.java | 2 +- 7 files changed, 390 insertions(+), 45 deletions(-) create mode 100644 apps/susimail/src/src/i2p/susi/webmail/PersistentMailCache.java diff --git a/apps/susimail/src/src/i2p/susi/webmail/Mail.java b/apps/susimail/src/src/i2p/susi/webmail/Mail.java index 264c59329e..84983e8732 100644 --- a/apps/susimail/src/src/i2p/susi/webmail/Mail.java +++ b/apps/susimail/src/src/i2p/susi/webmail/Mail.java @@ -54,7 +54,7 @@ class Mail { private static final String unknown = "unknown"; - public int id, size; + private int size; public String sender, reply, subject, dateString, formattedSender, formattedSubject, formattedDate, // US Locale, UTC @@ -108,6 +108,7 @@ class Mail { if (header == null) setHeader(rb); body = rb; + size = rb.length; try { part = new MailPart(rb); } catch (DecodingException de) { @@ -127,6 +128,16 @@ class Mail { return part != null; } + public int getSize() { + return size; + } + + public void setSize(int size) { + if (body != null) + return; + this.size = size; + } + /** * * @param address E-mail address to be validated diff --git a/apps/susimail/src/src/i2p/susi/webmail/MailCache.java b/apps/susimail/src/src/i2p/susi/webmail/MailCache.java index c8ef791a44..5ac834b691 100644 --- a/apps/susimail/src/src/i2p/susi/webmail/MailCache.java +++ b/apps/susimail/src/src/i2p/susi/webmail/MailCache.java @@ -23,10 +23,12 @@ */ package i2p.susi.webmail; +import i2p.susi.debug.Debug; import i2p.susi.util.ReadBuffer; import i2p.susi.webmail.pop3.POP3MailBox; import i2p.susi.webmail.pop3.POP3MailBox.FetchRequest; +import java.io.IOException; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -43,6 +45,7 @@ class MailCache { private final POP3MailBox mailbox; private final Hashtable mails; + private final PersistentMailCache disk; /** Includes header, headers are generally 1KB to 1.5 KB, * and bodies will compress well. @@ -52,9 +55,18 @@ class MailCache { /** * @param mailbox non-null */ - MailCache( POP3MailBox mailbox ) { + MailCache(POP3MailBox mailbox, + String host, int port, String user, String pass) { this.mailbox = mailbox; mails = new Hashtable(); + PersistentMailCache pmc = null; + try { + pmc = new PersistentMailCache(host, port, user, pass); + // TODO pmc.getMails() + } catch (IOException ioe) { + Debug.debug(Debug.ERROR, "Error creating disk cache: " + ioe); + } + disk = pmc; } /** @@ -80,11 +92,11 @@ class MailCache { } if( mail == null ) { mail = newMail; - mail.size = mailbox.getSize( uidl ); + mail.setSize(mailbox.getSize(uidl)); } if (mail.markForDeletion) return null; - if( mail.size <= FETCH_ALL_SIZE) + if(mail.getSize() <= FETCH_ALL_SIZE) headerOnly = false; if( headerOnly ) { @@ -95,6 +107,11 @@ class MailCache { mail.setBody(mailbox.getBody(uidl)); } } + if (disk != null) { + if (disk.saveMail(mail) && mail.hasBody()) { + // TODO delete on server + } + } return mail; } @@ -126,20 +143,32 @@ class MailCache { } if( mail == null ) { mail = newMail; - mail.size = mailbox.getSize( uidl ); + mail.setSize(mailbox.getSize(uidl)); } if (mail.markForDeletion) continue; mr.setMail(mail); - if( mail.size <= FETCH_ALL_SIZE) + if(mail.getSize() <= FETCH_ALL_SIZE) headerOnly = false; if( headerOnly ) { if(!mail.hasHeader()) { + if (disk != null) { + if (disk.getMail(mail, true)) { + Debug.debug(Debug.DEBUG, "Loaded header from disk cache: " + uidl); + continue; // found on disk, woo + } + } POP3Request pr = new POP3Request(mr, mail, true); fetches.add(pr); } } else { if(!mail.hasBody()) { + if (disk != null) { + if (disk.getMail(mail, false)) { + Debug.debug(Debug.DEBUG, "Loaded body from disk cache: " + uidl); + continue; // found on disk, woo + } + } POP3Request pr = new POP3Request(mr, mail, false); fetches.add(pr); } @@ -162,6 +191,11 @@ class MailCache { } else { mail.setBody(rb); } + if (disk != null) { + if (disk.saveMail(mail) && mail.hasBody()) { + // TODO delete on server + } + } } } } @@ -188,6 +222,8 @@ class MailCache { public void delete(Collection uidls) { List toDelete = new ArrayList(uidls.size()); for (String uidl : uidls) { + if (disk != null) + disk.deleteMail(uidl); Mail mail = mails.get(uidl); if (mail == null) continue; diff --git a/apps/susimail/src/src/i2p/susi/webmail/PersistentMailCache.java b/apps/susimail/src/src/i2p/susi/webmail/PersistentMailCache.java new file mode 100644 index 0000000000..8f9c63df9c --- /dev/null +++ b/apps/susimail/src/src/i2p/susi/webmail/PersistentMailCache.java @@ -0,0 +1,280 @@ +package i2p.susi.webmail; + +import i2p.susi.debug.Debug; +import i2p.susi.webmail.Messages; +import i2p.susi.util.ReadBuffer; + +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Hashtable; +import java.util.List; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +import net.i2p.I2PAppContext; +import net.i2p.data.Base64; +import net.i2p.data.DataHelper; +import net.i2p.util.PasswordManager; +import net.i2p.util.SecureDirectory; +import net.i2p.util.SecureFile; +import net.i2p.util.SecureFileOutputStream; + + +/** + * Manage the on-disk cache. + * + * This is a custom format with subdirectories, gzipped files, + * and the encoded UIDL in the file name. + * We store either the headers or the full message. + * No, it is not Maildir format but we could add Maildir-style + * status suffixes (e.g. ":2.SR") later. + * + * Exporting to a Maildir format would be just ungzipping + * each file to a flat directory. + * + * TODO draft and sent folders, cached server caps and config. + * + * @since 0.9.14 + */ +class PersistentMailCache { + + private final File _cacheDir; + + private static final String DIR_SUSI = "susimail"; + private static final String DIR_CACHE = "cache"; + private static final String CACHE_PREFIX = "cache-"; + private static final String DIR_FOLDER = "cur"; // MailDir-like + private static final String DIR_PREFIX = "s"; + private static final String FILE_PREFIX = "mail-"; + private static final String HDR_SUFFIX = ".hdr.txt.gz"; + private static final String FULL_SUFFIX = ".full.txt.gz"; + private static final String B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-~"; + + /** + * Use the params to generate a unique directory name. + * @param pass ignored + */ + public PersistentMailCache(String host, int port, String user, String pass) throws IOException { + _cacheDir = makeCacheDirs(host, port, user, pass); + } + + /** + * Fetch all mails from disk. + * + * @return An e-mail or null + */ + public Collection getMails() { + List rv = new ArrayList(); + for (int j = 0; j < B64.length(); j++) { + File subdir = new File(_cacheDir, DIR_PREFIX + B64.charAt(j)); + File[] files = subdir.listFiles(); + if (files == null) + continue; + for (int i = 0; i < files.length; i++) { + File f = files[i]; + if (!f.isFile()) + continue; + Mail mail = load(f); + if (mail != null) + rv.add(mail); + } + } + return rv; + } + + /** + * Fetch any needed data from disk. + * + * @return success + */ + public boolean getMail(Mail mail, boolean headerOnly) { + File f = getFullFile(mail.uidl); + if (f.exists()) { + ReadBuffer rb = read(f); + if (rb != null) { + mail.setBody(rb); + return true; + } + } + f = getHeaderFile(mail.uidl); + if (f.exists()) { + ReadBuffer rb = read(f); + if (rb != null) { + mail.setHeader(rb); + return true; + } + } + return false; + } + + /** + * Save data to disk. + * + * @return success + */ + public boolean saveMail(Mail mail) { + ReadBuffer rb = mail.getBody(); + if (rb != null) { + File f = getFullFile(mail.uidl); + if (f.exists()) + return true; // already there, all good + boolean rv = write(rb, f); + if (rv) + getHeaderFile(mail.uidl).delete(); + return rv; + } + rb = mail.getHeader(); + if (rb != null) { + File f = getHeaderFile(mail.uidl); + if (f.exists()) + return true; // already there, all good + boolean rv = write(rb, f); + return rv; + } + return false; + } + + /** + * + * Delete data from disk. + */ + public void deleteMail(Mail mail) { + deleteMail(mail.uidl); + } + + /** + * + * Delete data from disk. + */ + public void deleteMail(String uidl) { + getFullFile(uidl).delete(); + getHeaderFile(uidl).delete(); + } + + /** + * ~/.i2p/susimail/cache/cache-xxxxx/cur/s[a-z]/mail-xxxxx.full.txt.gz + * folder1 is the base. + */ + private static File makeCacheDirs(String host, int port, String user, String pass) throws IOException { + File f = new SecureDirectory(I2PAppContext.getGlobalContext().getConfigDir(), DIR_SUSI); + if (!f.exists() && !f.mkdir()) + throw new IOException("Cannot create " + f); + f = new SecureDirectory(f, DIR_CACHE); + if (!f.exists() && !f.mkdir()) + throw new IOException("Cannot create " + f); + f = new SecureDirectory(f, CACHE_PREFIX + Base64.encode(user + host + port)); + if (!f.exists() && !f.mkdir()) + throw new IOException("Cannot create " + f); + File base = new SecureDirectory(f, DIR_FOLDER); + if (!base.exists() && !base.mkdir()) + throw new IOException("Cannot create " + base); + for (int i = 0; i < B64.length(); i++) { + f = new SecureDirectory(base, DIR_PREFIX + B64.charAt(i)); + if (!f.exists() && !f.mkdir()) + throw new IOException("Cannot create " + f); + } + return base; + } + + private File getHeaderFile(String uidl) { + return getFile(uidl, HDR_SUFFIX); + } + + private File getFullFile(String uidl) { + return getFile(uidl, FULL_SUFFIX); + } + + private File getFile(String uidl, String suffix) { + byte[] raw = DataHelper.getASCII(uidl); + byte[] md5 = PasswordManager.md5Sum(raw); + String db64 = Base64.encode(md5); + File dir = new File(_cacheDir, DIR_PREFIX + db64.charAt(0)); + String b64 = Base64.encode(uidl); + return new SecureFile(dir, FILE_PREFIX + b64 + suffix); + } + + /** + * Save data to disk. + * + * @return success + */ + private static boolean write(ReadBuffer rb, File f) { + OutputStream out = null; + try { + out = new BufferedOutputStream(new GZIPOutputStream(new SecureFileOutputStream(f))); + out.write(rb.content, rb.offset, rb.length); + return true; + } catch (IOException ioe) { + Debug.debug(Debug.ERROR, "Error writing: " + f + ": " + ioe); + return false; + } finally { + if (out != null) + try { out.close(); } catch (IOException ioe) {} + } + } + + /** + * @return null on failure + */ + private static ReadBuffer read(File f) { + InputStream in = null; + try { + long len = f.length(); + if (len > 16 * 1024 * 1024) { + throw new IOException("too big"); + } + in = new GZIPInputStream(new BufferedInputStream(new FileInputStream(f))); + ByteArrayOutputStream out = new ByteArrayOutputStream((int) len); + int read = 0; + byte buf[] = new byte[4*1024]; + while ( (read = in.read(buf)) != -1) { + out.write(buf, 0, read); + } + ReadBuffer rb = new ReadBuffer(out.toByteArray(), 0, out.size()); + return rb; + } catch (IOException ioe) { + Debug.debug(Debug.ERROR, "Error reading: " + f + ": " + ioe); + return null; + } finally { + if (in != null) + try { in.close(); } catch (IOException ioe) {} + } + } + + /** + * @return null on failure + */ + private static Mail load(File f) { + String name = f.getName(); + String uidl; + boolean headerOnly; + if (name.endsWith(FULL_SUFFIX)) { + uidl= Base64.decodeToString(name.substring(FILE_PREFIX.length(), name.length() - FULL_SUFFIX.length())); + headerOnly = false; + } else if (name.endsWith(HDR_SUFFIX)) { + uidl= Base64.decodeToString(name.substring(FILE_PREFIX.length(), name.length() - HDR_SUFFIX.length())); + headerOnly = true; + } else { + return null; + } + if (uidl == null) + return null; + ReadBuffer rb = read(f); + if (rb == null) + return null; + Mail mail = new Mail(uidl); + if (headerOnly) + mail.setHeader(rb); + else + mail.setBody(rb); + return mail; + } +} diff --git a/apps/susimail/src/src/i2p/susi/webmail/WebMail.java b/apps/susimail/src/src/i2p/susi/webmail/WebMail.java index ae76d8aa2f..79b5c759fb 100644 --- a/apps/susimail/src/src/i2p/susi/webmail/WebMail.java +++ b/apps/susimail/src/src/i2p/susi/webmail/WebMail.java @@ -172,6 +172,7 @@ public class WebMail extends HttpServlet private static final String CONFIG_COMPOSER_ROWS = "composer.rows"; private static final String CONFIG_BCC_TO_SELF = "composer.bcc.to.self"; + private static final String CONFIG_LEAVE_ON_SERVER = "pop3.leave.on.server"; private static final String CONFIG_DEBUG = "debug"; private static final String RC_PROP_THEME = "routerconsole.theme"; @@ -194,21 +195,15 @@ public class WebMail extends HttpServlet * * @author susi */ +/**** private static class IDSorter implements Comparator { private final MailCache mailCache; - /** - * Set MailCache object, where to get Mails from - * @param mailCache - */ public IDSorter( MailCache mailCache ) { this.mailCache = mailCache; } - /* (non-Javadoc) - * @see java.util.Comparator#compare(java.lang.Object, java.lang.Object) - */ public int compare(String arg0, String arg1) { Mail a = mailCache.getMail( arg0, MailCache.FETCH_HEADER ); Mail b = mailCache.getMail( arg1, MailCache.FETCH_HEADER ); @@ -219,6 +214,7 @@ public class WebMail extends HttpServlet return a.id - b.id; } } +****/ /** * sorts Mail objects by sender field @@ -338,7 +334,7 @@ public class WebMail extends HttpServlet return (b == null) ? 0 : 1; if (b == null) return -1; - return a.size - b.size; + return a.getSize() - b.getSize(); } } @@ -425,7 +421,7 @@ public class WebMail extends HttpServlet */ private static String sortHeader( String name, String label, String imgPath ) { - return label + " \"^\"\"v\""; } @@ -556,6 +552,7 @@ public class WebMail extends HttpServlet } if( prepareAttachment ) { if( html ) { + // TODO can we at least show images safely? out.println( "

" ); out.println( "" + _("Download attachment {0}", ident) + "" + @@ -671,9 +668,10 @@ public class WebMail extends HttpServlet sessionObject.host = host; sessionObject.smtpPort = smtpPortNo; sessionObject.state = STATE_LIST; - MailCache mc = new MailCache(mailbox); + MailCache mc = new MailCache(mailbox, host, pop3PortNo, user, pass); sessionObject.mailCache = mc; sessionObject.folder = new Folder(); + // TODO get through cache so we have the disk-only ones too String[] uidls = mailbox.getUIDLs(); sessionObject.folder.setElements(uidls); if (uidls.length > 0) { @@ -686,7 +684,7 @@ public class WebMail extends HttpServlet mc.getMail(reqs); } - sessionObject.folder.addSorter( SORT_ID, new IDSorter( sessionObject.mailCache ) ); + //sessionObject.folder.addSorter( SORT_ID, new IDSorter( sessionObject.mailCache ) ); sessionObject.folder.addSorter( SORT_SENDER, new SenderSorter( sessionObject.mailCache ) ); sessionObject.folder.addSorter( SORT_SUBJECT, new SubjectSorter( sessionObject.mailCache ) ); sessionObject.folder.addSorter( SORT_DATE, new DateSorter( sessionObject.mailCache ) ); @@ -1013,6 +1011,7 @@ public class WebMail extends HttpServlet } if( buttonPressed( request, REFRESH ) ) { sessionObject.mailbox.refresh(); + // TODO get through cache so we have the disk-only ones too String[] uidls = sessionObject.mailbox.getUIDLs(); if (uidls != null) sessionObject.folder.setElements(uidls); @@ -1436,6 +1435,7 @@ public class WebMail extends HttpServlet * update folder content */ if( sessionObject.state != STATE_AUTH ) { + // TODO get through cache so we have the disk-only ones too String[] uidls = sessionObject.mailbox.getUIDLs(); if (uidls != null) sessionObject.folder.setElements(uidls); @@ -1485,7 +1485,7 @@ public class WebMail extends HttpServlet ); } out.println( "\n\n" + - "

\"Susimail\"
 

\n" + + "

\"Susimail\"

\n" + "
" ); if( sessionObject.error != null && sessionObject.error.length() > 0 ) { @@ -1756,15 +1756,15 @@ public class WebMail extends HttpServlet out.println( "\n" + "\n" + - "\n" + - "\n" + - "\n" + - "\n" + + "\n" + + "\n" + + "\n" + + "\n" + "\n" + - "\n" + + "\n" + "\n" + - "" + + "" + // TODO disable/hide in JS if no file selected ""); @@ -1772,7 +1772,7 @@ public class WebMail extends HttpServlet boolean wroteHeader = false; for( Attachment attachment : sessionObject.attachments ) { if( !wroteHeader ) { - out.println(""); + out.println(""); wroteHeader = true; } else { out.println(""); @@ -1858,7 +1858,7 @@ public class WebMail extends HttpServlet out.println("

" + _("From:") + "
" + _("To:") + "
" + _("Cc:") + "
" + _("Bcc:") + "
" + _("From") + ":
" + _("To") + ":
" + _("Cc") + ":
" + _("Bcc") + ":
" + _("Bcc to self") + ":
" + _("Subject:") + "
" + _("Subject") + ":
" + "

" + _("Add Attachment:") + "
" + _("Add Attachment") + ":
 " + button(NEW_UPLOAD, _("Add another attachment")) + "
" + _("Attachments:") + "
" + _("Attachments") + ":
 
\n" + "\n" + - thSpacer + "" + + thSpacer + "" + thSpacer + "" + thSpacer + "" ); + DataHelper.formatSize2(mail.getSize()) + "B" ); bg = 1 - bg; i++; } @@ -1907,7 +1907,7 @@ public class WebMail extends HttpServlet button( CLEAR, _("Clear") ) + "
"); out.println( - _("Page Size:") + " " + + _("Page Size") + ": " + button( SETPAGESIZE, _("Set") ) ); } } @@ -1944,12 +1944,12 @@ public class WebMail extends HttpServlet if( mail != null ) { out.println( "

" + sortHeader( SORT_SENDER, _("Sender"), sessionObject.imgPath ) + "" + sortHeader( SORT_SENDER, _("From"), sessionObject.imgPath ) + "" + sortHeader( SORT_SUBJECT, _("Subject"), sessionObject.imgPath ) + "" + sortHeader( SORT_DATE, _("Date"), sessionObject.imgPath ) + //sortHeader( SORT_ID, "", sessionObject.imgPath ) + @@ -1893,7 +1893,7 @@ public class WebMail extends HttpServlet link + mail.shortSender + " " + link + mail.shortSubject + " " + // don't let date get split across lines mail.localFormattedDate.replace(" ", " ") + " " + - DataHelper.formatSize2(mail.size) + "B
\n" + "\n" + - "\n" + - "\n" + - "\n" + + "\n" + + "\n" + + "\n" + "" ); if( mail.hasPart()) { showPart( out, mail.getPart(), 0, SHOW_HTML ); diff --git a/apps/susimail/src/src/i2p/susi/webmail/pop3/POP3MailBox.java b/apps/susimail/src/src/i2p/susi/webmail/pop3/POP3MailBox.java index 16710a2115..bbfbffb0e5 100644 --- a/apps/susimail/src/src/i2p/susi/webmail/pop3/POP3MailBox.java +++ b/apps/susimail/src/src/i2p/susi/webmail/pop3/POP3MailBox.java @@ -52,6 +52,7 @@ public class POP3MailBox { private int mails; private boolean connected; + private boolean gotCAPA; private boolean supportsPipelining; private boolean supportsTOP; private boolean supportsUIDL; @@ -278,6 +279,7 @@ public class POP3MailBox { } if (srs.isEmpty()) return; + // TODO don't quit now, just set timer to quit later SendRecv sr = new SendRecv("QUIT", Mode.A1); srs.add(sr); try { @@ -490,7 +492,7 @@ public class POP3MailBox { private void connect() { Debug.debug(Debug.DEBUG, "connect()"); if (Debug.getLevel() == Debug.DEBUG) - (new Exception()).printStackTrace(); + (new Exception("I did it")).printStackTrace(); clear(); @@ -565,16 +567,17 @@ public class POP3MailBox { * @since 0.9.13 */ private boolean doHandshake() throws IOException { - // can we always pipeline this ? - supportsPipelining = false; - supportsUIDL = false; - supportsTOP = false; List cmds = new ArrayList(2); cmds.add(new SendRecv(null, Mode.A1)); - SendRecv capa = new SendRecv("CAPA", Mode.LS); - cmds.add(capa); + SendRecv capa = null; + if (gotCAPA) { + Debug.debug(Debug.DEBUG, "Skipping CAPA"); + } else { + capa = new SendRecv("CAPA", Mode.LS); + cmds.add(capa); + } boolean rv = sendCmds(cmds); - if (rv) { + if (rv && capa != null) { if (capa.ls != null) { for (String cap : capa.ls) { String t = cap.trim(); @@ -586,10 +589,11 @@ public class POP3MailBox { supportsTOP = true; } } - } - Debug.debug(Debug.DEBUG, "POP3 server caps: pipelining? " + supportsPipelining + + gotCAPA = true; + Debug.debug(Debug.DEBUG, "POP3 server caps: pipelining? " + supportsPipelining + " UIDL? " + supportsUIDL + " TOP? " + supportsTOP); + } return rv; } diff --git a/history.txt b/history.txt index d61da9dca0..d3ff2ef5e5 100644 --- a/history.txt +++ b/history.txt @@ -1,3 +1,17 @@ +2014-04-22 zzz + * SusiMail: + - Add persistent cache + +2014-04-21 zzz + * SusiMail: + - Pipeline all deletes and quit + - Don't reconnect after delete and quit + - Verify connected before each POP3 operation + - Don't clear messages if a reconnection fails + - Use locale-based sorting for strings + - Increase limit for full fetch again + - Increase default page size back again + 2014-04-21 dg * findbugs: mostly stream closure fixes in router, apps, core diff --git a/router/java/src/net/i2p/router/RouterVersion.java b/router/java/src/net/i2p/router/RouterVersion.java index fecba78d69..a6204817e3 100644 --- a/router/java/src/net/i2p/router/RouterVersion.java +++ b/router/java/src/net/i2p/router/RouterVersion.java @@ -18,7 +18,7 @@ public class RouterVersion { /** deprecated */ public final static String ID = "Monotone"; public final static String VERSION = CoreVersion.VERSION; - public final static long BUILD = 7; + public final static long BUILD = 8; /** for example "-test" */ public final static String EXTRA = "";

" + _("From:") + - "" + quoteHTML( mail.sender ) + "
" + _("Subject:") + - "" + quoteHTML( mail.formattedSubject ) + "
" + _("Date:") + - "" + mail.quotedDate + "
" + _("From") + + ":" + quoteHTML( mail.sender ) + "
" + _("Subject") + + ":" + quoteHTML( mail.formattedSubject ) + "
" + _("Date") + + ":" + mail.quotedDate + "