Basti's Scratchpad on the Internet

Yet Another Disposable E-Mail Web Application in Clojure

This post walks you through the process of creating a disposable e-mail web application in Clojure. I've moved my site to a new provider in Germany which gave me a box with 32 GB of memory, while thinking what to do with all that memory I thought why not a disposable email service, I can keep everything in memory since accounts will be active for a short amount of time and be done in an afternoon this was before I actually made a google search and found out that there a lot of them in production. I scrapped the idea, following is the meat of the code if anyone is interested.

Fractal Fern Explained

(def inboxes (ref {}))

(defn rand-addr []
  (apply str (shuffle 
	      (concat (take 2 (repeatedly #(rand-int 10)))
		      (take 2 (repeatedly #(char (+ 97 (rand-int 26)))))
		      (take 2 (repeatedly #(char (+ 65 (rand-int 26)))))))))

(defn new-inbox []
  (let [addr (str (rand-addr) "@nakkaya.com")]
    (dosync (alter inboxes assoc addr {:created (System/currentTimeMillis)
				       :messages []}))
    addr))

All mailboxes are kept in a reference, we create a new mailbox by taking 6 random alphanumeric characters shuffling them and adding them to list of mailboxes along with its creation time.

(defn inbox-expired? [addr]
  (let [created ((@inboxes addr) :created)
	now (System/currentTimeMillis)]
    (if (> (- now created)  (* 15 60 1000))
      true
      false)))

(defn delete-inbox [mailbox]
  (dosync (alter inboxes dissoc mailbox)))

(defn watch-inboxes []
  (future
    (while true
      (doseq [mailbox (keys @inboxes)]
	(when (inbox-expired? mailbox)
	  (delete-inbox mailbox)))
      (Thread/sleep 100))))

Periodically using a future, we go over the list of active mailboxes check if any of them expired. A mailbox expires 15 minutes after its creation.

(defn inbox-active? [addr]
  (contains? @inboxes addr))

(defn process-message [from to message]
  (let [message-list ((@inboxes to) :messages)]
    (dosync (alter inboxes assoc-in
		   [to :messages]
		   (conj message-list {:from from
				       :to to
				       :time (System/currentTimeMillis)
				       :subject (.getSubject message)
				       :content (.getContent message)})))))

(defn message-listener []
  (proxy [SimpleMessageListener] []
    (accept [from to]
      (inbox-active? to))

    (deliver [from to data]
      (process-message from to
		       (javax.mail.internet.MimeMessage.
			(javax.mail.Session/getDefaultInstance
			 (java.util.Properties.)) data)))))

(def smtp-server (org.subethamail.smtp.server.SMTPServer.
		  (org.subethamail.smtp.helper.SimpleMessageListenerAdapter.
		   (message-listener))))

For receiving mail we rely on SubEtha SMTP which is a Java library that allows your application to receive SMTP mail. SimpleMessageListener will call accept when a mail arrives if the mail destined to an active inbox it will return true which causes deliver to be called giving us a InputStream to the message, we then convert it to a MimeMessage extract the parts we are interested in and add it to the list of messages for the mailbox.

(defroutes app-routes
  (GET "/" {session :session}
       (if (and (contains? session :mailbox)
		(inbox-active? (:mailbox session)))
	 (template (show-inbox (:mailbox session)))
	 (assoc (redirect "/") :session {:mailbox (new-inbox)})))

  (GET "/view/:mailbox/:idx" [mailbox idx]
       (template (view-message mailbox idx)))

  (POST "/reply" [from to subject reply]
	(reply-message from to subject reply)
	(redirect "/"))

  (route/not-found "<h1>Page not found</h1>"))

(def app (-> app-routes
	     wrap-params
	     (wrap-session {:cookie-name "mail-session"
			    :store (cookie-store)})))

When the user navigates to the top domain, we check if the user's session has a mailbox associated with it or the associated mailbox is still active. If it is we show the content of the mailbox else we create a new mailbox set the users cookie and redirect the user back to top domain.

(defn template [content]
  (html [:html {:lang "en"}
	 [:head
	  [:link
	   {:href "http://twitter.github.com/bootstrap/assets/css/bootstrap.css"
	    :rel "stylesheet"}]]
	 [:body
	  [:div {:class "container"}
	   content]]]))

(defn show-inbox [mailbox]
  (html [:br]
	[:table {:class "table table-striped table-bordered table-condensed"}
	 [:thead
	  [:tr
	   [:th {:span "3"} (str "Inbox for " mailbox)]]
	  [:tr
	   [:th "Subject"]
	   [:th "From"]
	   [:th "Time"]]]

	 (map-indexed (fn [idx {from :from time :time subject :subject}]
			[:tr
			 [:td [:a {:href (str "/view/" mailbox "/" idx)}
			       subject]]
			 [:td from]
			 [:td (let [time (java.util.Date. time)]
				(str (.getHours time) ":" (.getMinutes time)))]])
		      (:messages (@inboxes mailbox)))]))

(defn view-message [mailbox idx]
  (if-let [mailbox (:messages (@inboxes mailbox))]
    (try
      (let [message (mailbox (read-string idx))]
	(html
	 [:h3 [:a {:href "/"} "Back to Inbox"]]
	 [:h3 "From: " (:from message)]
	 [:h3 "Subject: " (:subject message)]
	 [:p (:content message)]

	 [:form {:action "/reply" :method "post" :class "xxx"}
	  [:textarea {:name "reply" :rows "10" :cols "100"}]
	  [:br]
	  [:input {:type "hidden" :name "subject" :value (:subject message)}]
	  [:input {:type "hidden" :name "from" :value (:from message)}]
	  [:input {:type "hidden" :name "to" :value (:to message)}]
	  [:input {:type "submit" :value "Reply" :class "btn"}]]))
      (catch Exception e
	"<h1>Message does not exist!<h1>"))
    "<h1>Mailbox does not exist!<h1>"))

(defn reply-message [from to subject reply]
  (send-message {:from to
		 :to from
		 :subject subject
		 :body reply}))

Above snippet generates the HTML for viewing the list of messages in a users mailbox, view the message and reply to it.

(defn -main [& args]
  (watch-inboxes)
  (.setPort smtp-server 2525)
  (.start smtp-server)
  (run-jetty #'app {:join? false :port 8080}))

Finally, start it all up. In order to export sources from this document get the original from my github repository and run org-babel-tangle or manually copy/paste snippets in the correct order.

Other posts
comments powered by Disqus