package org.ibex.mail;
import org.ibex.crypto.*;
-import org.ibex.js.*;
import org.ibex.util.*;
import org.ibex.mail.protocol.*;
+import org.ibex.io.*;
import java.util.*;
import java.net.*;
import java.io.*;
-// soft line limit (suggested): 78 chars / hard line limit: 998 chars
+// FIXME this is important
// folded headers: can insert CRLF anywhere that whitespace appears (before the whitespace)
-// date/time parsing: see 3.3
-// FEATURE: MIME RFC2045, 2046, 2049
+// soft line limit (suggested): 78 chars / hard line limit: 998 chars
+// date/time parsing: see spec, 3.3
+
+// FIXME: messages must NEVER contain 8-bit binary data; this is a violation of IMAP
+
// FEATURE: PGP-signature-parsing
// FEATURE: mailing list header parsing
// FEATURE: delivery status notification (and the sneaky variety)
// FEATURE: threading as in http://www.jwz.org/doc/threading.html
+// FEATURE: lazy body
-public class Message extends JSReflection {
+/**
+ * [immutable] This class encapsulates a message "floating in the
+ * ether": RFC2822 data but no storage-specific flags or other
+ * metadata.
+ */
+public class Message extends org.ibex.js.JSReflection {
- public final String allHeaders; // pristine headers
- public final Hashtable headers; // hash of headers (not including resent's and traces)
- public final String body; // entire body
+ public final String allHeaders; // pristine headers
+ public final CaseInsensitiveHash headers; // hash of headers (not including resent's and traces)
+ public final String body; // entire body
+ public final int lines; // lines in the body
public final Date date;
public final Address to;
- public final Address from; // if multiple From entries, this is sender
- public final Address replyto; // if none provided, this is equal to sender
+ public final Address from; // if multiple From entries, this is sender
+ public final Address replyto; // if none provided, this is equal to sender
public final String subject;
public final String messageid;
public final Address[] cc;
public final Trace[] traces;
public final Address envelopeFrom;
- public final Address[] envelopeTo;
+ public final Address envelopeTo;
+
+ public final boolean mime; // true iff Mime-Version is 1.0
- public final Date arrival = null; // when the message first arrived at this machine
+ public final Date arrival; // when the message first arrived at this machine; IMAP "internal date message attr"
- public void dump(OutputStream os) throws IOException {
- Writer w = new OutputStreamWriter(os);
- w.write(allHeaders);
- w.write("\r\n");
- w.write(body);
- w.flush();
- }
+ public int size() { return allHeaders.length() + 2 /* CRLF */ + body.length(); }
+ public String toString() { return allHeaders + "\r\n" + body; }
- public static class StoredMessage extends Message {
- public int uid;
- public boolean deleted = false;
- public boolean read = false;
- public boolean answered = false;
- public StoredMessage(LineReader rs) throws IOException, MailException.Malformed { super(rs); }
+ public void dump(Stream s) {
+ s.setNewline("\r\n");
+ s.println("X-org.ibex.mail.headers.envelope.From: " + envelopeFrom);
+ s.println("X-org.ibex.mail.headers.envelope.To: " + envelopeTo);
+ s.println(allHeaders);
+ s.println();
+ s.println(body);
+ s.flush();
}
public class Trace {
final String returnPath;
final Element[] elements;
- public Trace(LineReader lr) throws Trace.Malformed, IOException {
- String retPath = lr.readLine();
+ public Trace(Stream stream) throws Trace.Malformed {
+ String retPath = stream.readln();
if (!retPath.startsWith("Return-Path:")) throw new Trace.Malformed("trace did not start with Return-Path header");
returnPath = retPath.substring(12).trim();
Vec el = new Vec();
while(true) {
- String s = lr.readLine();
+ String s = stream.readln();
if (s == null) break;
- if (!s.startsWith("Received:")) { lr.pushback(s); break; }
+ if (!s.startsWith("Received:")) { stream.unread(s); break; }
s = s.substring(9).trim();
el.addElement(new Element(s));
}
public class Malformed extends Message.Malformed { public Malformed(String s) { super(s); } }
}
- public static class Malformed extends MailException.Malformed { public Malformed(String s) { super(s); } }
- public Message(Address envelopeFrom, Address envelopeTo, LineReader rs) throws IOException, Malformed {
- this.envelopeFrom = envelopeFrom;
- this.envelopeTo = envelopeTo;
- this.arrival = new Date();
- this.headers = new Hashtable();
+ public static class Malformed extends Exception { public Malformed(String s) { super(s); } }
+
+ public Message(Address from, Address to, String s, Date arrival) throws Malformed {this(from,to,new Stream(s), arrival); }
+ public Message(Address from, Address to, Stream in) throws Malformed { this(from, to, in, new Date()); }
+ public Message(Address envelopeFrom, Address envelopeTo, Stream stream, Date arrival) throws Malformed {
+ this.arrival = arrival;
+ this.headers = new CaseInsensitiveHash();
+ Vec envelopeToHeader = new Vec();
String key = null;
StringBuffer all = new StringBuffer();
Date date = null;
Address to = null, from = null, replyto = null;
String subject = null, messageid = null;
Vec cc = new Vec(), bcc = new Vec(), resent = new Vec(), traces = new Vec();
- for(String s = rs.readLine(); s != null && !s.equals(""); s = rs.readLine()) {
+ int lines = 0;
+ for(String s = stream.readln(); s != null && !s.equals(""); s = stream.readln()) {
all.append(s);
+ lines++;
all.append("\r\n");
+
+ // FIXME RFC822 1,000-char limit per line
+
+
+ // FIXME only iff Mime 1.0 (?)
+ // RFC 2047
+ try { while (s.indexOf("=?") != -1) {
+ String pre = s.substring(0, s.indexOf("=?"));
+ s = s.substring(s.indexOf("=?") + 2);
+
+ // MIME charset; FIXME use this
+ String charset = s.substring(0, s.indexOf('?')).toLowerCase();
+ s = s.substring(s.indexOf('?') + 1);
+
+ String encoding = s.substring(0, s.indexOf('?')).toLowerCase();
+ s = s.substring(s.indexOf('?') + 1);
+
+ String encodedText = s.substring(0, s.indexOf("?="));
+
+ if (encoding.equals("b")) encodedText = new String(Base64.decode(encodedText));
+
+ // except that ANY char can be endoed (unlike real qp)
+ else if (encoding.equals("q")) encodedText = MIME.QuotedPrintable.decode(encodedText, true);
+ else Log.warn(this, "unknown RFC2047 encoding \""+encoding+"\"");
+
+ String post = s.substring(s.indexOf("?=") + 2);
+ s = pre + encodedText + post;
+
+ // FIXME re-encode when transmitting
+
+ } } catch (Exception e) {
+ Log.warn(this, "error trying to decode RFC2047 encoded-word: \""+s+"\"");
+ Log.warn(this, e);
+ }
+
if (s.length() == 0 || Character.isSpace(s.charAt(0))) {
if (key == null) throw new Malformed("Message began with a blank line; no headers");
- headers.put(key, headers.get(key) + s);
+ ((CaseInsensitiveHash)headers).add(key, headers.get(key) + s.trim());
continue;
}
if (s.indexOf(':') == -1) throw new Malformed("Header line does not contain colon: " + s);
key = s.substring(0, s.indexOf(':'));
- for(int i=0; i<s.length(); i++)
- if (s.charAt(i) < 33 || s.charAt(i) > 126)
- throw new Malformed("Header key contains invalid character \"" + s.charAt(i) + "\"");
- String val = s.substring(0, s.indexOf(':'));
- while(Character.isSpace(val.charAt(0))) val = val.substring(1);
+ for(int i=0; i<key.length(); i++)
+ if (key.charAt(i) < 33 || key.charAt(i) > 126)
+ throw new Malformed("Header key \""+key+"\" contains invalid character \"" + key.charAt(i) + "\"");
+ String val = s.substring(s.indexOf(':') + 1).trim();
+ while(val.length() > 0 && Character.isSpace(val.charAt(0))) val = val.substring(1);
if (key.startsWith("Resent-")) {
- if (key.startsWith("Resent-From")) resent.addElement(new Hashtable());
+ if (resent.size() == 0 || key.startsWith("Resent-From")) resent.addElement(new Hashtable());
((Hashtable)resent.lastElement()).put(key.substring(7), val);
- } else if (key.startsWith("Return-Path:")) {
- rs.pushback(s); traces.addElement(new Trace(rs));
+ } else if (key.startsWith("Return-Path")) {
+ stream.unread(s); traces.addElement(new Trace(stream));
+ } else if (key.equals("X-org.ibex.mail.headers.envelope.From")) {
+ try { if (envelopeFrom == null) envelopeFrom = new Address(val);
+ } catch (Address.Malformed a) { Log.warn(this, a); }
+ } else if (key.equals("X-org.ibex.mail.headers.envelope.To")) {
+ try {if (envelopeTo == null) envelopeTo = new Address(val);
+ } catch (Address.Malformed a) { Log.warn(this, a); }
} else {
// just append it to the previous one; valid for Comments/Keywords
if (headers.get(key) != null) val = headers.get(key) + " " + val;
- headers.put(key, val);
+ ((CaseInsensitiveHash)headers).add(key, val);
}
}
-
- this.date = headers.get("Date");
- this.to = new Address((String)headers.get("To"));
- this.from = new Address((String)headers.get("From"));
- this.replyto = new Address((String)headers.get("Reply-To"));
+
+ // FIXME what if all are null?
+ this.to = headers.get("To") == null ? envelopeTo : Address.parse((String)headers.get("To"));
+ this.from = headers.get("From") == null ? envelopeFrom : Address.parse((String)headers.get("From"));
+ this.envelopeFrom = envelopeFrom == null ? this.from : envelopeFrom;
+ this.envelopeTo = envelopeTo == null ? this.to : envelopeTo;
+ this.mime = headers.get("mime-version") != null && headers.get("mime-version").toString().trim().startsWith("1.0");
+
+ this.date = new Date(); // FIXME (Date)headers.get("Date");
+ this.replyto = headers.get("Reply-To") == null ? null : Address.parse((String)headers.get("Reply-To"));
this.subject = (String)headers.get("Subject");
this.messageid = (String)headers.get("Message-Id");
if (headers.get("Cc") != null) {
- StringTokenizer st = new StringTokenizer((String)headers.get("Cc"));
+ // FIXME: tokenize better
+ StringTokenizer st = new StringTokenizer((String)headers.get("Cc"), ",");
this.cc = new Address[st.countTokens()];
- for(int i=0; i<cc.length; i++) cc[i] = st.nextToken();
+ for(int i=0; i<this.cc.length; i++) this.cc[i] = Address.parse(st.nextToken());
} else {
this.cc = new Address[0];
}
if (headers.get("Bcc") != null) {
StringTokenizer st = new StringTokenizer((String)headers.get("Bcc"));
this.bcc = new Address[st.countTokens()];
- for(int i=0; i<bcc.length; i++) bcc[i] = st.nextToken();
+ for(int i=0; i<this.bcc.length; i++) this.bcc[i] = Address.parse(st.nextToken());
} else {
this.bcc = new Address[0];
}
traces.copyInto(this.traces = new Trace[traces.size()]);
allHeaders = all.toString();
StringBuffer body = new StringBuffer();
- for(String s = rs.readLine();; s = rs.readLine()) { if (s == null) break; else body.append(s + "\r\n"); }
+ for(String s = stream.readln();; s = stream.readln()) {
+ if (s == null) break;
+ lines++;
+ body.append(s); // FIXME: we're assuming all mail messages fit in memory
+ body.append("\r\n");
+ }
+ this.lines = lines;
this.body = body.toString();
}
+ public static class Part {
+ public final String type;
+ public final String subtype;
+ public final Hash parameters = new Hash();
+ public final Part[] subparts;
+ public final String payload;
+ public final String id;
+ public final String description;
+
+ public final boolean composite;
+ public final boolean alternative;
+
+ // FIXME charsets US-ASCII, ISO-8559-X,
+ public Part(String contentTypeHeader, String transferEncoding,
+ String id, String description, String payload) throws Malformed {
+ this.id = id;
+ this.description = description;
+
+ // NOTE: it is not valid to send a multipart message as base64 or quoted-printable; you must encode each section
+ if (transferEncoding == null) transferEncoding = "7bit";
+ transferEncoding = transferEncoding.toLowerCase();
+ if (transferEncoding.equals("quoted-printable")) {
+ this.payload = MIME.QuotedPrintable.decode(payload, false);
+ subparts = null;
+ } else if (transferEncoding.equals("base64")) {
+ this.payload = new String(Base64.decode(payload));
+ subparts = null;
+ } else if (transferEncoding.equals("7bit") || transferEncoding.equals("8bit") ||
+ transferEncoding.equals("binary")) {
+ this.payload = payload;
+
+ // multipart/mixed -- default
+ // multipart/parallel -- order of components does not matter
+ // multipart/alternative -- same data, different versions
+ // multipart/digest -- default content-type of components is message/rfc822
+
+ // message/rfc822
+ // message/partial -- not supported; see RFC 2046, section 5.2.2
+ // message/external-body -- not supported; see RFC 2046, section 5.2.3
+
+ /* FIXME
+ if (composite) {
+ // FIXME: StringTokenizer doesn't really work like this
+ String boundary = "--" + parameters.get("boundary"); // CRLF, delimiter, optional whitespace, plus CRLF
+ // preceeding CRLF is part of delimiter
+ // boundary (including --) cannot be > 70 chars
+ // delimiter after final part also has a '--' on the end
+ // first part begins with a boundary delimiter
+ StringTokenizer st = new StringTokenizer(payload, boundary);
+ Vec v = new Vec();
+ while(st.hasMoreTokens()) {
+ st.nextToken();
+ Part p = new Part();
+ v.addElement(p);
+ }
+ v.copyInto(subparts = new Part[v.size()]);
+ } else {
+ }
+ */
+ subparts = null;
+ } else {
+ Log.warn(this, "unknown Content-Transfer-Encoding \"" + transferEncoding +
+ "\"; forcing Content-Type to application/octet-stream (originally \""+contentTypeHeader+"\"");
+ contentTypeHeader = "application/octet-stream";
+ this.payload = payload;
+ subparts = null;
+ }
+
+ if (contentTypeHeader == null) {
+ type = "text";
+ subtype = "plain";
+ parameters.put("charset", "us-ascii");
+ } else {
+ contentTypeHeader = contentTypeHeader.trim();
+ if (contentTypeHeader.endsWith(")")) // remove RFC822 comments
+ contentTypeHeader = contentTypeHeader.substring(0, contentTypeHeader.lastIndexOf('('));
+ if (contentTypeHeader.indexOf('/') == -1)
+ throw new Malformed("content-type header lacks a forward slash: \"" + contentTypeHeader + "\"");
+ type = contentTypeHeader.substring(0, contentTypeHeader.indexOf('/')).toLowerCase();
+ contentTypeHeader = contentTypeHeader.substring(contentTypeHeader.indexOf('/') + 1);
+ if (contentTypeHeader.indexOf(';') == -1) {
+ subtype = contentTypeHeader.toLowerCase();
+ } else {
+ subtype = contentTypeHeader.substring(0, contentTypeHeader.indexOf(';')).toLowerCase();
+ StringTokenizer st = new StringTokenizer(contentTypeHeader.substring(contentTypeHeader.indexOf(';') + 1), ";");
+ while(st.hasMoreTokens()) {
+ String key = st.nextToken().trim();
+ if (key.indexOf('=') == -1)
+ throw new Malformed("content-type parameter lacks an equals sign: \""+key+"\"");
+ String val = key.substring(key.indexOf('=')+1).trim();
+ if (val.startsWith("\"") && val.endsWith("\"")) val = val.substring(1, val.length() - 2);
+ key = key.substring(0, key.indexOf('=')+1).toLowerCase();
+ parameters.put(key, val);
+ }
+ }
+ }
+
+ composite = type.equals("message") || type.equals("multipart");
+ alternative = composite && subtype.equals("alternative");
+
+
+ // fixme: message/rfc822
+
+ // body parts need only contain Content-Foo headers (or none at all); they need not be rfc822 compliant
+
+ }
+ }
+
// http://www.jwz.org/doc/mid.html
private static final Random random = new Random();
public static String generateFreshMessageId() {
return ret.toString();
}
- public String summary() {
- return
- " Subject: " + m.subject + "\n" +
- " EnvelopeFrom: " + m.envelopeFrom + "\n" +
- " EnvelopeTo: " + m.envelopeTo + "\n" +
- " MessageId: " + m.messageid;
+ // use null-sender for error messages (don't send errors to the null addr)
+ public Message bounce(String reason) { throw new RuntimeException("bounce not implemented"); } // FIXME!
+
+ public static class CaseInsensitiveHash extends org.ibex.js.JSReflection {
+ private Hashtable stuff = new Hashtable();
+ public Object get(Object o) { return stuff.get(((String)o).toLowerCase()); }
+ void add(Object k, Object v) { if (k instanceof String) stuff.put(((String)k).toLowerCase(), v); else stuff.put(k, v); }
}
- public Message bounce(String reason) {
- // use null-sender for error messages (don't send errors to the null addr)
- // FIXME
- throw new RuntimeException("bounce not implemented");
+ public String summary() {
+ return
+ " Subject: " + subject + "\n" +
+ " EnvelopeFrom: " + envelopeFrom + "\n" +
+ " EnvelopeTo: " + envelopeTo + "\n" +
+ " MessageId: " + messageid;
}
}