bogus
[org.ibex.mail.git] / src / org / ibex / mail / Message.java
1 package org.ibex.mail;
2 import org.ibex.crypto.*;
3 import org.ibex.util.*;
4 import org.ibex.mail.protocol.*;
5 import org.ibex.io.*;
6 import java.util.*;
7 import java.net.*;
8 import java.io.*;
9
10 // FIXME this is important
11 // folded headers: can insert CRLF anywhere that whitespace appears (before the whitespace)
12
13 // soft line limit (suggested): 78 chars /  hard line limit: 998 chars
14 // date/time parsing: see spec, 3.3
15
16 // FIXME: messages must NEVER contain 8-bit binary data; this is a violation of IMAP
17
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
22 // FEATURE: lazy body
23
24 /** 
25  *  [immutable] This class encapsulates a message "floating in the
26  *  ether": RFC2822 data but no storage-specific flags or other
27  *  metadata.
28  */
29 public class Message extends org.ibex.js.JSReflection {
30
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
35
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;
46
47     public final Address envelopeFrom;
48     public final Address envelopeTo;
49
50     public final boolean mime;                // true iff Mime-Version is 1.0
51
52     public final Date arrival;         // when the message first arrived at this machine; IMAP "internal date message attr"
53     
54     public int size() { return allHeaders.length() + 2 /* CRLF */ + body.length(); }
55     public String toString() { return allHeaders + "\r\n" + body; }
56
57     public void dump(Stream s) {
58         s.setNewline("\r\n");
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);
62         s.println();
63         s.println(body);
64         s.flush();
65     }
66
67     public class Trace {
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();
74             Vec el = new Vec();
75             while(true) {
76                 String s = stream.readln();
77                 if (s == null) break;
78                 if (!s.startsWith("Received:")) { stream.unread(s); break; }
79                 s = s.substring(9).trim();
80                 el.addElement(new Element(s));
81             }
82             elements = new Element[el.size()];
83             el.copyInto(elements);
84         }
85         public class Element {
86              String fromDomain;
87              String fromIP;
88              String toDomain;
89              String forWhom;
90              Date date;
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();
99                 // FIXME not done yet
100             }
101         }
102         public class Malformed extends Message.Malformed { public Malformed(String s) { super(s); } }
103     }
104
105     public static class Malformed extends Exception { public Malformed(String s) { super(s); } }
106
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();
113         String key = null;
114         StringBuffer all = new StringBuffer();
115         Date date = null;
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();
119         int lines = 0;
120         for(String s = stream.readln(); s != null && !s.equals(""); s = stream.readln()) {
121             all.append(s);
122             lines++;
123             all.append("\r\n");
124             
125             // FIXME RFC822 1,000-char limit per line
126
127
128             // FIXME only iff Mime 1.0 (?)
129             // RFC 2047
130             try { while (s.indexOf("=?") != -1) {
131                 String pre = s.substring(0, s.indexOf("=?"));
132                 s = s.substring(s.indexOf("=?") + 2);
133
134                 // MIME charset; FIXME use this
135                 String charset = s.substring(0, s.indexOf('?')).toLowerCase();
136                 s = s.substring(s.indexOf('?') + 1);
137
138                 String encoding = s.substring(0, s.indexOf('?')).toLowerCase();
139                 s = s.substring(s.indexOf('?') + 1);
140
141                 String encodedText = s.substring(0, s.indexOf("?="));
142
143                 if (encoding.equals("b"))      encodedText = new String(Base64.decode(encodedText));
144
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+"\"");
148
149                 String post = s.substring(s.indexOf("?=") + 2);
150                 s = pre + encodedText + post;
151
152                 // FIXME re-encode when transmitting
153
154             } } catch (Exception e) {
155                 Log.warn(this, "error trying to decode RFC2047 encoded-word: \""+s+"\"");
156                 Log.warn(this, e);
157             }
158
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());
162                 continue;
163             }
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); }
182             } else {
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);
186             }            
187         }
188         
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");
195         
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());
205         } else {
206             this.cc = new Address[0];
207         }
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());
212         } else {
213             this.bcc = new Address[0];
214         }
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;
221             lines++;
222             body.append(s);       // FIXME: we're assuming all mail messages fit in memory
223             body.append("\r\n");
224         }
225         this.lines = lines;
226         this.body = body.toString();
227     }
228
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;
237
238         public final boolean composite;
239         public final boolean alternative;
240
241         // FIXME charsets  US-ASCII, ISO-8559-X, 
242         public Part(String contentTypeHeader, String transferEncoding,
243                     String id, String description, String payload) throws Malformed {
244             this.id = id;
245             this.description = description;
246
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);
252                 subparts = null;
253             } else if (transferEncoding.equals("base64")) {
254                 this.payload = new String(Base64.decode(payload));
255                 subparts = null;
256             } else if (transferEncoding.equals("7bit") || transferEncoding.equals("8bit") ||
257                        transferEncoding.equals("binary")) {
258                 this.payload = payload;
259
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
264
265                 // 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
268                 
269                 /* FIXME
270                 if (composite) {
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);
278                     Vec v = new Vec();
279                     while(st.hasMoreTokens()) {
280                         st.nextToken();
281                         Part p = new Part();
282                         v.addElement(p);
283                     }
284                     v.copyInto(subparts = new Part[v.size()]);
285                 } else {
286                 }
287                 */
288                 subparts = null;
289             } else {
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;
294                 subparts = null;
295             }
296
297             if (contentTypeHeader == null) {
298                 type = "text";
299                 subtype = "plain";
300                 parameters.put("charset", "us-ascii");
301             } else {
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();
311                 } else {
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);
322                     }
323                 }
324             }
325
326             composite = type.equals("message") || type.equals("multipart");
327             alternative = composite && subtype.equals("alternative");
328
329
330             // fixme: message/rfc822
331
332             // body parts need only contain Content-Foo headers (or none at all); they need not be rfc822 compliant
333
334         }
335     }
336     
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();
341         ret.append('<');
342         ret.append(Base36.encode(System.currentTimeMillis()));
343         ret.append('.');
344         ret.append(Base36.encode(random.nextLong()));
345         ret.append('.');
346         try { ret.append(InetAddress.getLocalHost().getHostName()); } catch (UnknownHostException e) { /* DELIBERATE */ }
347         ret.append('>');
348         return ret.toString();
349     }
350
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!
353  
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); }
358     }
359
360     public String summary() {
361         return
362             "          Subject: " + subject + "\n" +
363             "     EnvelopeFrom: " + envelopeFrom + "\n" +
364             "       EnvelopeTo: " + envelopeTo + "\n" +
365             "        MessageId: " + messageid;
366     }
367 }