Whitelist.java: never send a challenge in response to a List-Id or List-Post
[org.ibex.mail.git] / src / org / ibex / mail / Whitelist.java
1 package org.ibex.mail;
2
3 import org.ibex.io.*;
4 import org.ibex.net.*;
5 import org.ibex.mail.protocol.*;
6 import org.ibex.util.*;
7 import org.ibex.net.*;
8 import java.sql.*;
9 import java.net.*;
10 import java.io.*;
11 import java.util.*;
12 import java.sql.Timestamp;
13 import java.sql.Connection;
14
15 public class Whitelist extends SqliteDB {
16
17     public Whitelist(String filename) throws SQLException {
18         super(filename);
19         SqliteTable whitelist = getTable("whitelist", "(email)");
20         whitelist.createIndex("email");
21         SqliteTable pending   = getTable("pending",   "(spamid,email,message,date)");
22         pending.reap("date");
23         pending.createIndex("spamid");
24         pending.createIndex("email");
25     }
26
27     public boolean handleRequest(org.ibex.net.Connection c) {
28         try {
29             Socket sock = c.getSocket();
30             BufferedReader br = new BufferedReader(new InputStreamReader(sock.getInputStream()));
31             String s = br.readLine();
32             String url = s.substring(s.indexOf(' ')+1);
33             url = url.substring(0, url.indexOf(' '));
34             while(s!=null && !s.equals(""))
35                 s = br.readLine();
36             PrintWriter pw = new PrintWriter(new OutputStreamWriter(sock.getOutputStream()));
37             if (!url.startsWith("/whitelist/")) {
38                 pw.print("HTTP/1.0 404 Not Found\r\n");
39                 pw.print("Content-Type: text/plain\r\n");
40                 pw.print("\r\n");
41                 pw.println("you are lost.");
42             } else {
43                 url = url.substring("/whitelist/".length());
44                 url = URLDecoder.decode(url);
45                 if (url.endsWith(".txt")) url = url.substring(0, url.length()-4);
46                 pw.print("HTTP/1.0 200 OK\r\n");
47                 pw.print("Content-Type: text/plain\r\n");
48                 pw.print("\r\n");
49                 try {
50                     SMTP.whitelist.response(url);
51                     pw.println("Thanks!  You've been added to my list of non-spammers and your message");
52                     pw.println("has been moved to my inbox.");
53                     pw.println("email id " + url);
54                     pw.println("");
55                 } catch (Exception e) {
56                     e.printStackTrace(pw);
57                 }
58             }
59             pw.flush();
60             sock.close();
61         } catch (Exception e) { throw new RuntimeException(e); }
62         return true;
63     }
64
65     public synchronized boolean isWhitelisted(Address a) {
66         try {
67             if (a==null) return false;
68             PreparedStatement check = conn.prepareStatement("select * from 'whitelist' where email=?");
69             check.setString(1, a.toString(false).toLowerCase());
70             ResultSet rs = check.executeQuery();
71             return !rs.isAfterLast();
72         } catch (SQLException e) { throw new RuntimeException(e); }
73     }
74
75     public synchronized void addWhitelist(Address a) {
76         try {
77             PreparedStatement add    = conn.prepareStatement("insert or replace into 'whitelist' values(?)");
78             add.setString(1, a.toString(false).toLowerCase());
79             add.executeUpdate();
80         } catch (SQLException e) { throw new RuntimeException(e); }
81     }
82
83     public synchronized void response(String messageid) throws IOException, MailException {
84         try {
85             PreparedStatement query = conn.prepareStatement("select email,message from pending where spamid=?");
86             query.setString(1, messageid);
87             ResultSet rs = query.executeQuery();
88             if (!rs.next())
89                 throw new RuntimeException("could not find messageid \""+messageid+"\"");
90             HashSet<Message> hsm = new HashSet<Message>();
91             synchronized(this) {
92                 do {
93                     addWhitelist(Address.parse(rs.getString(1)));
94                     Message m = Message.newMessage(new Fountain.StringFountain(rs.getString(2)));
95                     Address a = m.headers.get("reply-to")==null ? null : Address.parse(m.headers.get("reply-to"));
96                     if (a!=null) addWhitelist(a);
97                     a = m.from;
98                     if (a!=null) addWhitelist(a);
99                     a = m.envelopeFrom;
100                     if (a!=null) addWhitelist(a);
101                     hsm.add(m);
102                     if (m.cc != null) for(Address aa : m.cc) {
103                         if (aa!= null) addWhitelist(aa);
104                     }
105                 } while (rs.next());
106             }
107             for(Message m : hsm)
108                 Target.root.accept(m);
109         } catch (SQLException e) { throw new RuntimeException(e); }
110     }
111
112     public void challenge(Message m) {
113         try {
114             // FIXME: don't challenge emails with binaries in them;
115             // reject them outright and have the sender send an
116             // initial message w/o a binary.
117
118             // FIXME: use Auto here!!!
119             // The challenge should refer to the message-id of the mail being challenged. 
120
121             // FIXME: watch outgoing MessageID's: if something comes
122             // back with an In-Reply-To mentioning a MessageID from
123             // the last few days, auto-whitelist them.
124
125             // FIXME: important that "From" on the challenge matches
126             // RCPT TO on the original message.
127
128             Log.warn(Whitelist.class, "challenging message: " + m.summary());
129
130             Address to = m.headers.get("reply-to")==null ? null : Address.parse(m.headers.get("reply-to"));
131             if (to==null) to = m.from;
132             if (to==null) to = m.envelopeFrom;
133
134             if (m.envelopeTo==null || m.envelopeTo.equals("null") || m.envelopeTo.equals("")) {
135                 Log.warn(this, "message is missing a to/replyto/envelopeto header; cannot accept");
136                 return;
137             }
138             if (m.headers.get("Auto-Submitted") != null &&
139                 m.headers.get("Auto-Submitted").toLowerCase().indexOf("auto-replied")!=-1) {
140                 Log.warn(this, "refusing to send a challenge to a message "+
141                          "with Auto-Submitted=\""+m.headers.get("Auto-Submitted")+"\"");
142                 return;
143             }
144             if (m.headers.get("List-Id") != null || m.headers.get("List-Post") != null) {
145                 Log.warn(this, "refusing to send a challenge to a message with a List-Id or List-Post header");
146                 return;
147             }
148
149             Address from = Address.parse("adam@megacz.com");
150
151             String messageid = "x" + m.messageid.substring(1);
152             messageid = messageid.substring(0, messageid.length() - 1);
153             messageid = messageid.replace('%','_');
154             Log.warn(Whitelist.class, "got challenge for: " + messageid);
155
156             String url = "http://www.megacz.com:8025/whitelist/"+URLEncoder.encode(messageid)+".txt";
157             String message =
158                 "Return-Path: <>"                                     + "\r\n" +
159                 "Envelope-To: "           + to                        + "\r\n" +
160                 "X-Originally-Received: " + m.headers.get("received") + "\r\n" +
161                 "To: "                    + to                        + "\r\n" +
162                 "From: "                  + from                      + "\r\n" +
163                 "Subject: Re: "           + m.subject                 + "\r\n" +
164                 "Message-ID:"             + Message.generateFreshMessageId() + "\r\n" +
165                 "\r\n" +
166                 "Hi, I've never sent a message to you before, so my spam filter trapped\n"        +
167                 "your email.  If you're really a human being and not an evil spammer,\n"          +
168                 "please click the link below or paste it into a web browser; doing so will\n"     +
169                 "add you to my list of non-spammers (so you won't get this email in the future)\n"+
170                 "and it will move your message from my spam folder to my incoming mail folder.\n" +
171                 "\n"                                                                              +
172                 "Thanks!\n"                                                                       +
173                 "\n"                                                                              +
174                 "  - Adam\n"                                                                      +
175                 "\n"                                                                              +
176                 url+"\n" +
177                 "\n"                                                                              +
178                 "\n"                                                                              +
179                 "About this message:\n" +
180                 "\n"                                                                              +
181                 "NOTE: SPAMCOP DOES NOT CONSIDER THIS TO BE SPAM; see this:\n"+
182                 "\n"+
183                 "         http://www.spamcop.net/fom-serve/cache/369.html\n"+
184                 "\n"+
185                 "      and examine the \"x-originally-received\" header on this message \n"+
186                 "      for the required \"chain of custody\" information.\n"+
187                 "\n"+
188                 "      Only one of these challenge messages is ever generated in response to \n"+
189                 "      a given inbound SMTP connection; it cannot be used to amplify spam    \n"+
190                 "      attacks, and in fact actually retards them while also stripping the   \n"+
191                 "      advertisement they were meant to convey.\n"+
192                 "\n"+
193                 "      Only one delivery attempt for this challenge is ever made, and it is made\n"+
194                 "      DURING the SMTP delivery of the message being challenged (that is, \n"+
195                 "      between C:DATA and S:250); the deliverer of the possibly-spam message\n"+
196                 "      must remain SMTP connected to my server during the entire process or else\n"+
197                 "      the delivery will immediately abort.  These challenge messages are NEVER,\n"+
198                 "      EVER queued for multiple delivery attempts\n"+
199                 "      \n"+
200                 "      For more information, please see:\n"+
201                 "      \n"+
202                 "      http://www.templetons.com/brad/spam/crgood.html\n";
203
204             Message challenge = Message.newMessage(new Fountain.StringFountain(message));
205
206             boolean send = false;
207             synchronized(this) {
208                 PreparedStatement query = conn.prepareStatement("select email from pending where email=?");
209                 query.setString(1, to.toString(false));
210                 ResultSet rs = query.executeQuery();
211                 if (rs.next()) {
212                     Log.warn(this, "already challenged " + to.toString(false) + "; not challenging again.");
213                 } else {
214                     send = true;
215                 }
216             }
217
218             if (send)
219                 if (!SMTP.Outgoing.attempt(challenge))
220                     throw new RuntimeException("attempted to send challenge but could not: " + m.summary());
221
222             synchronized(this) {
223                 PreparedStatement add = conn.prepareStatement("insert into pending values(?,?,?,?)");
224                 add.setString(1, messageid);
225                 add.setString(2, to.toString(false));
226                 add.setString(3, SqliteDB.streamToString(m.getStream()));
227                 add.setTimestamp(4, new Timestamp(System.currentTimeMillis()));
228                 add.executeUpdate();
229             }
230         } catch (Exception e) { throw new RuntimeException(e); }
231     }
232
233 }
234