2 import org.ibex.crypto.*;
3 import org.ibex.util.*;
4 import org.ibex.mail.protocol.*;
10 // FIXME this is important
11 // folded headers: can insert CRLF anywhere that whitespace appears (before the whitespace)
13 // soft line limit (suggested): 78 chars / hard line limit: 998 chars
14 // date/time parsing: see spec, 3.3
16 // FIXME: messages must NEVER contain 8-bit binary data; this is a violation of IMAP
18 // FEATURE: PGP-signature-parsing
19 // FEATURE: mailing list header parsing
20 // FEATURE: delivery status notification (and the sneaky variety)
21 // FEATURE: threading as in http://www.jwz.org/doc/threading.html
25 * [immutable] This class encapsulates a message "floating in the
26 * ether": RFC2822 data but no storage-specific flags or other
29 public class Message extends org.ibex.js.JSReflection {
31 public final String allHeaders; // pristine headers
32 public final CaseInsensitiveHash headers; // hash of headers (not including resent's and traces)
33 public final String body; // entire body
34 public final int lines; // lines in the body
36 public final Date date;
37 public final Address to;
38 public final Address from; // if multiple From entries, this is sender
39 public final Address replyto; // if none provided, this is equal to sender
40 public final String subject;
41 public final String messageid;
42 public final Address[] cc;
43 public final Address[] bcc;
44 public final Hashtable[] resent;
45 public final Trace[] traces;
47 public final Address envelopeFrom;
48 public final Address envelopeTo;
50 public final boolean mime; // true iff Mime-Version is 1.0
52 public final Date arrival; // when the message first arrived at this machine; IMAP "internal date message attr"
54 public int size() { return allHeaders.length() + 2 /* CRLF */ + body.length(); }
55 public String toString() { return allHeaders + "\r\n" + body; }
57 public void dump(Stream s) {
59 s.println("X-org.ibex.mail.headers.envelope.From: " + envelopeFrom);
60 s.println("X-org.ibex.mail.headers.envelope.To: " + envelopeTo);
61 s.println(allHeaders);
68 final String returnPath;
69 final Element[] elements;
70 public Trace(Stream stream) throws Trace.Malformed {
71 String retPath = stream.readln();
72 if (!retPath.startsWith("Return-Path:")) throw new Trace.Malformed("trace did not start with Return-Path header");
73 returnPath = retPath.substring(12).trim();
76 String s = stream.readln();
78 if (!s.startsWith("Received:")) { stream.unread(s); break; }
79 s = s.substring(9).trim();
80 el.addElement(new Element(s));
82 elements = new Element[el.size()];
83 el.copyInto(elements);
85 public class Element {
91 public Element(String fromDomain, String fromIP, String toDomain, String forWhom, Date date) {
92 this.fromDomain=fromDomain; this.fromIP=fromIP; this.toDomain=toDomain; this.forWhom=forWhom; this.date=date; }
93 public Element(String s) throws Trace.Malformed {
94 StringTokenizer st = new StringTokenizer(s);
95 if (!st.nextToken().equals("FROM")) throw new Trace.Malformed("trace did note have a FROM element: " + s);
96 fromDomain = st.nextToken();
97 if (!st.nextToken().equals("BY")) throw new Trace.Malformed("trace did note have a BY element: " + s);
98 toDomain = st.nextToken();
102 public class Malformed extends Message.Malformed { public Malformed(String s) { super(s); } }
105 public static class Malformed extends Exception { public Malformed(String s) { super(s); } }
107 public Message(Address from, Address to, String s, Date arrival) throws Malformed {this(from,to,new Stream(s), arrival); }
108 public Message(Address from, Address to, Stream in) throws Malformed { this(from, to, in, new Date()); }
109 public Message(Address envelopeFrom, Address envelopeTo, Stream stream, Date arrival) throws Malformed {
110 this.arrival = arrival;
111 this.headers = new CaseInsensitiveHash();
112 Vec envelopeToHeader = new Vec();
114 StringBuffer all = new StringBuffer();
116 Address to = null, from = null, replyto = null;
117 String subject = null, messageid = null;
118 Vec cc = new Vec(), bcc = new Vec(), resent = new Vec(), traces = new Vec();
120 for(String s = stream.readln(); s != null && !s.equals(""); s = stream.readln()) {
125 // FIXME RFC822 1,000-char limit per line
128 // FIXME only iff Mime 1.0 (?)
130 try { while (s.indexOf("=?") != -1) {
131 String pre = s.substring(0, s.indexOf("=?"));
132 s = s.substring(s.indexOf("=?") + 2);
134 // MIME charset; FIXME use this
135 String charset = s.substring(0, s.indexOf('?')).toLowerCase();
136 s = s.substring(s.indexOf('?') + 1);
138 String encoding = s.substring(0, s.indexOf('?')).toLowerCase();
139 s = s.substring(s.indexOf('?') + 1);
141 String encodedText = s.substring(0, s.indexOf("?="));
143 if (encoding.equals("b")) encodedText = new String(Base64.decode(encodedText));
145 // except that ANY char can be endoed (unlike real qp)
146 else if (encoding.equals("q")) encodedText = MIME.QuotedPrintable.decode(encodedText, true);
147 else Log.warn(this, "unknown RFC2047 encoding \""+encoding+"\"");
149 String post = s.substring(s.indexOf("?=") + 2);
150 s = pre + encodedText + post;
152 // FIXME re-encode when transmitting
154 } } catch (Exception e) {
155 Log.warn(this, "error trying to decode RFC2047 encoded-word: \""+s+"\"");
159 if (s.length() == 0 || Character.isSpace(s.charAt(0))) {
160 if (key == null) throw new Malformed("Message began with a blank line; no headers");
161 ((CaseInsensitiveHash)headers).add(key, headers.get(key) + s.trim());
164 if (s.indexOf(':') == -1) throw new Malformed("Header line does not contain colon: " + s);
165 key = s.substring(0, s.indexOf(':'));
166 for(int i=0; i<key.length(); i++)
167 if (key.charAt(i) < 33 || key.charAt(i) > 126)
168 throw new Malformed("Header key \""+key+"\" contains invalid character \"" + key.charAt(i) + "\"");
169 String val = s.substring(s.indexOf(':') + 1).trim();
170 while(val.length() > 0 && Character.isSpace(val.charAt(0))) val = val.substring(1);
171 if (key.startsWith("Resent-")) {
172 if (resent.size() == 0 || key.startsWith("Resent-From")) resent.addElement(new Hashtable());
173 ((Hashtable)resent.lastElement()).put(key.substring(7), val);
174 } else if (key.startsWith("Return-Path")) {
175 stream.unread(s); traces.addElement(new Trace(stream));
176 } else if (key.equals("X-org.ibex.mail.headers.envelope.From")) {
177 try { if (envelopeFrom == null) envelopeFrom = new Address(val);
178 } catch (Address.Malformed a) { Log.warn(this, a); }
179 } else if (key.equals("X-org.ibex.mail.headers.envelope.To")) {
180 try {if (envelopeTo == null) envelopeTo = new Address(val);
181 } catch (Address.Malformed a) { Log.warn(this, a); }
183 // just append it to the previous one; valid for Comments/Keywords
184 if (headers.get(key) != null) val = headers.get(key) + " " + val;
185 ((CaseInsensitiveHash)headers).add(key, val);
189 // FIXME what if all are null?
190 this.to = headers.get("To") == null ? envelopeTo : Address.parse((String)headers.get("To"));
191 this.from = headers.get("From") == null ? envelopeFrom : Address.parse((String)headers.get("From"));
192 this.envelopeFrom = envelopeFrom == null ? this.from : envelopeFrom;
193 this.envelopeTo = envelopeTo == null ? this.to : envelopeTo;
194 this.mime = headers.get("mime-version") != null && headers.get("mime-version").toString().trim().startsWith("1.0");
196 this.date = new Date(); // FIXME (Date)headers.get("Date");
197 this.replyto = headers.get("Reply-To") == null ? null : Address.parse((String)headers.get("Reply-To"));
198 this.subject = (String)headers.get("Subject");
199 this.messageid = (String)headers.get("Message-Id");
200 if (headers.get("Cc") != null) {
201 // FIXME: tokenize better
202 StringTokenizer st = new StringTokenizer((String)headers.get("Cc"), ",");
203 this.cc = new Address[st.countTokens()];
204 for(int i=0; i<this.cc.length; i++) this.cc[i] = Address.parse(st.nextToken());
206 this.cc = new Address[0];
208 if (headers.get("Bcc") != null) {
209 StringTokenizer st = new StringTokenizer((String)headers.get("Bcc"));
210 this.bcc = new Address[st.countTokens()];
211 for(int i=0; i<this.bcc.length; i++) this.bcc[i] = Address.parse(st.nextToken());
213 this.bcc = new Address[0];
215 resent.copyInto(this.resent = new Hashtable[resent.size()]);
216 traces.copyInto(this.traces = new Trace[traces.size()]);
217 allHeaders = all.toString();
218 StringBuffer body = new StringBuffer();
219 for(String s = stream.readln();; s = stream.readln()) {
220 if (s == null) break;
222 body.append(s); // FIXME: we're assuming all mail messages fit in memory
226 this.body = body.toString();
229 public static class Part {
230 public final String type;
231 public final String subtype;
232 public final Hash parameters = new Hash();
233 public final Part[] subparts;
234 public final String payload;
235 public final String id;
236 public final String description;
238 public final boolean composite;
239 public final boolean alternative;
241 // FIXME charsets US-ASCII, ISO-8559-X,
242 public Part(String contentTypeHeader, String transferEncoding,
243 String id, String description, String payload) throws Malformed {
245 this.description = description;
247 // NOTE: it is not valid to send a multipart message as base64 or quoted-printable; you must encode each section
248 if (transferEncoding == null) transferEncoding = "7bit";
249 transferEncoding = transferEncoding.toLowerCase();
250 if (transferEncoding.equals("quoted-printable")) {
251 this.payload = MIME.QuotedPrintable.decode(payload, false);
253 } else if (transferEncoding.equals("base64")) {
254 this.payload = new String(Base64.decode(payload));
256 } else if (transferEncoding.equals("7bit") || transferEncoding.equals("8bit") ||
257 transferEncoding.equals("binary")) {
258 this.payload = payload;
260 // multipart/mixed -- default
261 // multipart/parallel -- order of components does not matter
262 // multipart/alternative -- same data, different versions
263 // multipart/digest -- default content-type of components is message/rfc822
266 // message/partial -- not supported; see RFC 2046, section 5.2.2
267 // message/external-body -- not supported; see RFC 2046, section 5.2.3
271 // FIXME: StringTokenizer doesn't really work like this
272 String boundary = "--" + parameters.get("boundary"); // CRLF, delimiter, optional whitespace, plus CRLF
273 // preceeding CRLF is part of delimiter
274 // boundary (including --) cannot be > 70 chars
275 // delimiter after final part also has a '--' on the end
276 // first part begins with a boundary delimiter
277 StringTokenizer st = new StringTokenizer(payload, boundary);
279 while(st.hasMoreTokens()) {
284 v.copyInto(subparts = new Part[v.size()]);
290 Log.warn(this, "unknown Content-Transfer-Encoding \"" + transferEncoding +
291 "\"; forcing Content-Type to application/octet-stream (originally \""+contentTypeHeader+"\"");
292 contentTypeHeader = "application/octet-stream";
293 this.payload = payload;
297 if (contentTypeHeader == null) {
300 parameters.put("charset", "us-ascii");
302 contentTypeHeader = contentTypeHeader.trim();
303 if (contentTypeHeader.endsWith(")")) // remove RFC822 comments
304 contentTypeHeader = contentTypeHeader.substring(0, contentTypeHeader.lastIndexOf('('));
305 if (contentTypeHeader.indexOf('/') == -1)
306 throw new Malformed("content-type header lacks a forward slash: \"" + contentTypeHeader + "\"");
307 type = contentTypeHeader.substring(0, contentTypeHeader.indexOf('/')).toLowerCase();
308 contentTypeHeader = contentTypeHeader.substring(contentTypeHeader.indexOf('/') + 1);
309 if (contentTypeHeader.indexOf(';') == -1) {
310 subtype = contentTypeHeader.toLowerCase();
312 subtype = contentTypeHeader.substring(0, contentTypeHeader.indexOf(';')).toLowerCase();
313 StringTokenizer st = new StringTokenizer(contentTypeHeader.substring(contentTypeHeader.indexOf(';') + 1), ";");
314 while(st.hasMoreTokens()) {
315 String key = st.nextToken().trim();
316 if (key.indexOf('=') == -1)
317 throw new Malformed("content-type parameter lacks an equals sign: \""+key+"\"");
318 String val = key.substring(key.indexOf('=')+1).trim();
319 if (val.startsWith("\"") && val.endsWith("\"")) val = val.substring(1, val.length() - 2);
320 key = key.substring(0, key.indexOf('=')+1).toLowerCase();
321 parameters.put(key, val);
326 composite = type.equals("message") || type.equals("multipart");
327 alternative = composite && subtype.equals("alternative");
330 // fixme: message/rfc822
332 // body parts need only contain Content-Foo headers (or none at all); they need not be rfc822 compliant
337 // http://www.jwz.org/doc/mid.html
338 private static final Random random = new Random();
339 public static String generateFreshMessageId() {
340 StringBuffer ret = new StringBuffer();
342 ret.append(Base36.encode(System.currentTimeMillis()));
344 ret.append(Base36.encode(random.nextLong()));
346 try { ret.append(InetAddress.getLocalHost().getHostName()); } catch (UnknownHostException e) { /* DELIBERATE */ }
348 return ret.toString();
351 // use null-sender for error messages (don't send errors to the null addr)
352 public Message bounce(String reason) { throw new RuntimeException("bounce not implemented"); } // FIXME!
354 public static class CaseInsensitiveHash extends org.ibex.js.JSReflection {
355 private Hashtable stuff = new Hashtable();
356 public Object get(Object o) { return stuff.get(((String)o).toLowerCase()); }
357 void add(Object k, Object v) { if (k instanceof String) stuff.put(((String)k).toLowerCase(), v); else stuff.put(k, v); }
360 public String summary() {
362 " Subject: " + subject + "\n" +
363 " EnvelopeFrom: " + envelopeFrom + "\n" +
364 " EnvelopeTo: " + envelopeTo + "\n" +
365 " MessageId: " + messageid;