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.*;
-// FIXME MIME: RFC2045, 2046, 2049
-// NOTE: always use Win32 line endings
-// hard line limit: 998 chars
-// soft line limit (suggested): 78 chars
-// header fields: ascii 33-126 (but no colon)
-// field body: anything ASCII except CRLF
+// FIXME this is important
// folded headers: can insert CRLF anywhere that whitespace appears (before the whitespace)
-// body needs CRLF; one or the other alone is not acceptable
-// date/time parsing: see 3.3
+
+// 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
- // parsed header fields
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 Hashtable[] resent;
public final Trace[] traces;
- // envelope fields
public final Address envelopeFrom;
- public final Address[] envelopeTo;
+ public final Address envelopeTo;
- public void dump(OutputStream os) {
- Log.error(this, "not implemented");
- }
+ public final boolean mime; // true iff Mime-Version is 1.0
- public static class StoredMessage extends Message {
- public StoredMessage(/*ReadStream rs*/SMTP.LineReader rs, boolean dotTerminatedLikeSMTP) throws IOException {
- super(rs, dotTerminatedLikeSMTP); uid = -1; }
- public final int uid;
- public boolean deleted = false;
- public boolean read = false;
- public boolean answered = false;
- public String dumpStoredForm() { throw new Error("StoredMessage.dumpStoredForm() not implemented"); };
- public static StoredMessage undump(InputStream os) {
- Log.error(StoredMessage.class, "not implemented");
- return null;
- }
- }
+ public final Date arrival; // when the message first arrived at this machine; IMAP "internal date message attr"
+
+ public int size() { return allHeaders.length() + 2 /* CRLF */ + body.length(); }
+ public String toString() { return allHeaders + "\r\n" + body; }
- public static class Address extends JSReflection {
- public String coerceToString() {
- if (description == null || description.equals("")) return user +"@"+ host;
- return description + " " + "<" + user +"@"+ host + ">";
- }
- public final String user;
- public final String host;
- public final String description;
- public Address(String user, String host, String description) {
- this.user = user; this.host = host; this.description = description;
- }
- public Address(String s) {
- s = s.trim();
- String descrip = null;
- if (s.indexOf('<') != -1) {
- if (s.indexOf('>') == -1) { /* FIXME */ }
- descrip = s.substring(0, s.indexOf('<')) + s.substring(s.indexOf('>') + 1);
- s = s.substring(s.indexOf('<') + 1, s.indexOf('>'));
- }
- if (s.indexOf('@') == -1) { /* FIXME */ }
- description = descrip;
- user = s.substring(0, s.indexOf('@'));
- host = s.substring(s.indexOf('@')+1);
- }
+ 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 {
- String returnPath = null;
- Element[] elements;
+ final String returnPath;
+ final Element[] elements;
+ 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 = stream.readln();
+ if (s == null) break;
+ if (!s.startsWith("Received:")) { stream.unread(s); break; }
+ s = s.substring(9).trim();
+ el.addElement(new Element(s));
+ }
+ elements = new Element[el.size()];
+ el.copyInto(elements);
+ }
public class Element {
- // FIXME final
- String fromDomain;
- String fromIP;
- String toDomain;
- String forWhom;
- Date date;
+ String fromDomain;
+ String fromIP;
+ String toDomain;
+ String forWhom;
+ Date date;
+ public Element(String fromDomain, String fromIP, String toDomain, String forWhom, Date date) {
+ this.fromDomain=fromDomain; this.fromIP=fromIP; this.toDomain=toDomain; this.forWhom=forWhom; this.date=date; }
+ public Element(String s) throws Trace.Malformed {
+ StringTokenizer st = new StringTokenizer(s);
+ if (!st.nextToken().equals("FROM")) throw new Trace.Malformed("trace did note have a FROM element: " + s);
+ fromDomain = st.nextToken();
+ if (!st.nextToken().equals("BY")) throw new Trace.Malformed("trace did note have a BY element: " + s);
+ toDomain = st.nextToken();
+ // FIXME not done yet
+ }
}
+ public class Malformed extends Message.Malformed { public Malformed(String s) { super(s); } }
}
- // FIXME: support dotTerminatedLikeSMTP
- public Message(/*ReadStream rs*/SMTP.LineReader rs, boolean dotTerminatedLikeSMTP) throws IOException {
+ 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();
- String lastKey = null;
- replyto = null;
- subject = null;
- messageid = null;
- cc = null;
- bcc = null;
- resent = null;
- traces = null;
- envelopeFrom = null;
- envelopeTo = null;
-
- headers = new Hashtable();
- date = null; // FIXME
- to = null;
- from = null;
- for(String s = rs.readLine(); s != null && !s.equals(""); s = rs.readLine()) {
+ 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();
+ int lines = 0;
+ for(String s = stream.readln(); s != null && !s.equals(""); s = stream.readln()) {
all.append(s);
+ lines++;
all.append("\r\n");
- if (Character.isSpace(s.charAt(0))) {
- if (lastKey == null) { /* FIXME */ }
- headers.put(lastKey, headers.get(lastKey) + s);
- continue;
+
+ // 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.indexOf(':') == -1) { /* FIXME */ }
+ if (s.length() == 0 || Character.isSpace(s.charAt(0))) {
+ if (key == null) throw new Malformed("Message began with a blank line; no headers");
+ ((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(':'));
- String val = s.substring(0, s.indexOf(':') + 1);
- while(Character.isSpace(val.charAt(0))) val = val.substring(1);
-
- if (headers.get(key) != null)
- if (key.startsWith("Resent-")) {
- // FIXME: multi-resent headers
- } else if (key.startsWith("Return-Path:")) {
- // FIXME: parse traces, see RFC2821, section 4.4
- } else if (key.startsWith("Recieved:")) {
- // FIXME: parse traces, see RFC2821, section 4.4
- } else {
- // just append it to the previous one; valid for Comments/Keywords
- val = headers.get(key) + " " + val;
- }
-
- headers.put(key, val);
+ 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 (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")) {
+ 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;
+ ((CaseInsensitiveHash)headers).add(key, val);
+ }
+ }
+
+ // 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) {
+ // FIXME: tokenize better
+ StringTokenizer st = new StringTokenizer((String)headers.get("Cc"), ",");
+ this.cc = new Address[st.countTokens()];
+ 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<this.bcc.length; i++) this.bcc[i] = Address.parse(st.nextToken());
+ } else {
+ this.bcc = new Address[0];
}
+ resent.copyInto(this.resent = new Hashtable[resent.size()]);
+ traces.copyInto(this.traces = new Trace[traces.size()]);
allHeaders = all.toString();
StringBuffer body = new StringBuffer();
- for(String s = rs.readLine(); s != null && !s.equals(""); s = rs.readLine()) body.append(s);
+ 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() {
StringBuffer ret = new StringBuffer();
ret.append('<');
return ret.toString();
}
- private static final Random random = new Random();
+ // 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 String summary() {
+ return
+ " Subject: " + subject + "\n" +
+ " EnvelopeFrom: " + envelopeFrom + "\n" +
+ " EnvelopeTo: " + envelopeTo + "\n" +
+ " MessageId: " + messageid;
+ }
}