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.*;
* ether": RFC2822 data but no storage-specific flags or other
* metadata.
*/
-public class Message extends JSReflection {
+public class Message extends org.ibex.js.JSReflection {
public final String allHeaders; // pristine headers
public final CaseInsensitiveHash headers; // hash of headers (not including resent's and traces)
public final Address envelopeFrom;
public final Address envelopeTo;
+ public final boolean mime; // true iff Mime-Version is 1.0
+
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("X-org.ibex.mail.headers.envelope.From: " + envelopeFrom + "\r\n");
- w.write("X-org.ibex.mail.headers.envelope.To: " + envelopeTo + "\r\n");
- 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 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, String s, Date arrival)
- { this(envelopeFrom, envelopeTo, new LineReader(new StringReader(s)), arrival); }
- public Message(Address envelopeFrom, Address envelopeTo, LineReader rs) { this(envelopeFrom, envelopeTo, rs, null); }
- public Message(Address envelopeFrom, Address envelopeTo, LineReader rs, Date arrival) {
- try {
- this.arrival = arrival == null ? new Date() : 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()) {
- all.append(s);
- all.append("\r\n");
- 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);
- 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<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());
- ((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.equals("X-org.ibex.mail.headers.envelope.From")) {
- if (envelopeFrom == null) envelopeFrom = new Address(val);
- } else if (key.equals("X-org.ibex.mail.headers.envelope.To")) {
- if (envelopeTo == null) envelopeTo = new Address(val);
- } 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);
- }
+ 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();
+ 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);
}
- // FIXME what if all are null?
- this.to = headers.get("To") == null ? envelopeTo : new Address((String)headers.get("To"));
- this.from = headers.get("From") == null ? envelopeFrom : new Address((String)headers.get("From"));
- this.envelopeFrom = envelopeFrom == null ? this.from : envelopeFrom;
- this.envelopeTo = envelopeTo == null ? this.to : envelopeTo;
-
- this.date = new Date(); // FIXME (Date)headers.get("Date");
- this.replyto = headers.get("Reply-To") == null ? null : new Address((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] = new Address(st.nextToken());
+ 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(':'));
+ 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 {
- this.cc = new Address[0];
+ // 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 = 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 (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] = new Address(st.nextToken());
+
+ if (contentTypeHeader == null) {
+ type = "text";
+ subtype = "plain";
+ parameters.put("charset", "us-ascii");
} else {
- this.bcc = new Address[0];
+ 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);
+ }
+ }
}
- 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 = rs.readLine()) {
- 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();
- } catch (IOException e) { throw new MailException.IOException(e); }
- }
+ 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 rfc822() { return allHeaders + "\r\n" + body; }
- public int rfc822size() { return allHeaders.length() + 2 /* CRLF */ + body.length(); } // FIXME: double check this
-
- public String summary() {
- return
- " Subject: " + subject + "\n" +
- " EnvelopeFrom: " + envelopeFrom + "\n" +
- " EnvelopeTo: " + envelopeTo + "\n" +
- " MessageId: " + 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!
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;
+ }
}