diff --git a/cmd/xwebmail/xwebmail.go b/cmd/xwebmail/xwebmail.go index c579304..3494f5d 100644 --- a/cmd/xwebmail/xwebmail.go +++ b/cmd/xwebmail/xwebmail.go @@ -5,9 +5,9 @@ import ( "net/http" "github.com/golang/glog" - "github.com/gorilla/mux" "xinu.tv/email/db" + "xinu.tv/email/handlers" ) var addr = flag.String("addr", ":8080", "address:port to listen on") @@ -21,10 +21,6 @@ func main() { glog.Fatal(err) } - h := &handler{c: c} - - r := mux.NewRouter() - r.HandleFunc("/raw/{hash}", h.OriginalHandler) - r.HandleFunc("/l/{label}", h.LabelHandler) - glog.Fatal(http.ListenAndServe(*addr, r)) + h := handlers.Handlers(c) + glog.Fatal(http.ListenAndServe(*addr, h)) } diff --git a/db/util.go b/db/util.go index 6417620..5747ae5 100644 --- a/db/util.go +++ b/db/util.go @@ -42,7 +42,7 @@ type Original struct { } func (c *Conn) Originals(oCh chan<- Original, errc chan<- error, donec <-chan struct{}) { - query := ` + const query = ` SELECT uid, hash, @@ -224,7 +224,7 @@ VALUES return nil } -var MagicAllLabel = "[all]" +const MagicAllLabel = "[all]" type Paginator struct { Label string @@ -232,11 +232,84 @@ type Paginator struct { Count int } +type Address mail.Address + +type MessageHeader struct { + Hash string + From *Address + To []*Address + Cc []*Address + Subject string + Date time.Time + Seen bool + // TODO + Summary, ListId string +} + // Index returns ranges of metadata for messages as defined by paginator. -func (c *Conn) Index(p *Paginator) error { +func (c *Conn) Index(p *Paginator) ([]*MessageHeader, error) { + const query = ` +SELECT + hash, name, value +FROM + search_header +WHERE + name IN ('Date', 'Subject', 'To', 'From', 'Cc') +ORDER BY + hash +LIMIT + 100 +-- TODO add offset based on Paginator +; +` + var res []*MessageHeader if p.Label == MagicAllLabel { // Paginate all messages. } + // Else, filter by label. - return nil + rows, err := c.Query(query) + if err != nil { + return res, err + } + var mh *MessageHeader + for rows.Next() { + var hash, name, value string + if err := rows.Scan(&hash, &name, &value); err != nil { + return res, err + } + if mh == nil { + // First Row + mh = new(MessageHeader) + mh.Hash = hash + } + + if hash != mh.Hash { + res = append(res, mh) + mh = new(MessageHeader) + mh.Hash = hash + } + switch name { + case "Date": + t, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return res, err + } + mh.Date = t + case "Subject": + mh.Subject = value + case "To": + mh.To = append(mh.To, &Address{Address: value}) + case "From": + mh.From = &Address{Address: value} + case "Cc": + mh.Cc = append(mh.Cc, &Address{Address: value}) + } + } + // Intentionally don't handle the last MessageHeader, it is likely + // incomplete if LIMIT in the query cuts off a message midstream. + if err := rows.Err(); err != nil { + return res, err + } + return res, nil } diff --git a/handlers/handlers.go b/handlers/handlers.go index 590f6d8..9170087 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -2,6 +2,7 @@ package handlers import ( "database/sql" + "encoding/json" "flag" "net/http" "path/filepath" @@ -53,7 +54,17 @@ func (h *handler) LabelHandler(w http.ResponseWriter, r *http.Request) { return } glog.Infoln("Listing:", p) - if err := h.c.Index(p); err != nil { + res, err := h.c.Index(p) + if err != nil { + glog.Errorln("LabelHandler:", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + err = enc.Encode(res) + if err != nil { glog.Errorln("LabelHandler:", err) http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/static/css/layouts/email.css b/static/css/layouts/email.css index ae1b190..befc41a 100644 --- a/static/css/layouts/email.css +++ b/static/css/layouts/email.css @@ -2,11 +2,23 @@ * -- BASE STYLES -- * Most of these are inherited from Base, but I want to change a few. */ -body { - color: #333; +html { + height: 100%; } +#content.loading { + background-image: url('/img/email-icon.jpg'); + background-position: center center; + background-repeat: no-repeat; + background-size: contain; + height: 100%; + width: 100%; +} +body { + color: #333; + height: 100%; +} a { text-decoration: none; @@ -122,6 +134,7 @@ a { color: rgb(75, 113, 151); } +.email-label, .email-label-personal, .email-label-work, .email-label-travel { @@ -131,6 +144,9 @@ a { margin-right: 0.5em; border-radius: 3px; } +.email-label { + background: #888; +} .email-label-personal { background: #ffc94c; } @@ -188,7 +204,7 @@ a { margin: 0; font-weight: normal; } - .email-content-subtitle span { + .email-content-subtitle span.date { color: #999; } .email-content-controls { @@ -228,7 +244,7 @@ a { } #nav { margin-left:-500px; /* "left col (nav + list)" width */ - width: 220px; + width:150px; height: 100%; } @@ -244,7 +260,7 @@ a { } #list { - margin-left: -280px; + margin-left: -350px; width: 100%; height: 33%; border-bottom: 1px solid #ddd; @@ -255,7 +271,7 @@ a { top: 33%; right: 0; bottom: 0; - left: 220px; + left: 150px; overflow: auto; width: auto; /* so that it's not 100% */ } @@ -271,8 +287,8 @@ a { /* This will take up the entire height, and be a little thinner */ #list { - margin-left: -280px; - width: 280px; + margin-left: -350px; + width:350px; height: 100%; border-right: 1px solid #ddd; } diff --git a/static/img/email-icon.jpg b/static/img/email-icon.jpg new file mode 100644 index 0000000..77b12a4 Binary files /dev/null and b/static/img/email-icon.jpg differ diff --git a/static/index.html b/static/index.html index 47eda7c..1a0bfae 100755 --- a/static/index.html +++ b/static/index.html @@ -14,166 +14,13 @@ -
- - -
- - - - - - - - - - - - - -
- -
- -
-
+
- + + diff --git a/static/js/app.js b/static/js/app.js index ff6272b..9327ad7 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,15 +1,39 @@ /** @jsx React.DOM */ var App = React.createClass({ + getInitialState: function() { + return { + // TODO make this change by clicking on folder view. + source: "/l/[all]", + folderContent: [], + currentMessage: null + }; + }, + + componentWillMount: function() { + $.get(this.state.source, function(result) { + this.setState({folderContent: result}); + this.setMessage(result[0]); + }.bind(this)); + }, + + setMessage: function(msg) { + this.setState({currentMessage: msg}); + }, + render: function() { - return React.DOM.div({className: ''}); + if (this.state.currentMessage == null) { + return (
Loading...
); + } + return ( +
+ + {/* TODO make '[all]' be set by clicking folders. */} + + +
+); } }); -React.renderComponent( - React.DOM.div({id: 'layout', className: 'content pure-g'}, - , - // TODO make '[all]' be set by clicking folders. - ), - $('body').get(0) -); +React.renderComponent(, document.body); diff --git a/static/js/folder.js b/static/js/folder.js index 6f95062..159c442 100644 --- a/static/js/folder.js +++ b/static/js/folder.js @@ -1,64 +1,47 @@ /** @jsx React.DOM */ -/* - -*/ - -var FolderView = React.createClass({ - getInitialState: function() { - return { - list: {} - }; - }, - - componentDidMount: function() { - $.get(this.props.source, function(result) { - console.log('FolderView $.get', result); - this.setState({list: result}); - }.bind(this)); - }, - - render: function() { - // TODO: - // - fill this out with data - // - make unread conditional - // - remove profile pic - // - drop message excerpt - // - trim 'Re:' from messages and group/thread them. - return ( -
-
-
- Tilo Mitra's avatar -
- -
-
Tilo Mitra
-

Hello from Toronto

-

- Hey, I just wanted to check in with you from Toronto. I got here earlier today. -

-
+ render: function() { + var m = this.props.message; + return ( +
+
+
{m.From}
+

{m.Subject}

+

{m.Summary}

); - } + } }); -React.renderComponent( - // TODO make '[all]' be set by clicking folders. - , - $('#list').get(0) -); +var FolderView = React.createClass({ + propTypes: { + folderContent: React.PropTypes.array.isRequired, + handleMessage: React.PropTypes.func.isRequired + }, + + // Highlight types: email-item-{selected,unread} and none + render: function() { + // TODO: + // - fill this out with data + // - make unread conditional + // - remove profile pic + // - drop message excerpt + // - trim 'Re:' from messages and group/thread them. + var messages = this.props.folderContent, + view = this; + return ( +
+{messages.map(function(m) { + return +})} +
+ ); + } +}); diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..a625429 --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,42 @@ +/** @jsx React.DOM */ + +var MainView = React.createClass({ + getInitialState: function() { + return { + body: "" + }; + }, + + render: function() { + $.get("/raw/" + this.props.message.Hash, function(data) { + var body = data.substring(data.indexOf('\n\n')+2); + this.setState({body: body}); + }.bind(this)); + + // TODO: + // - trim 'Re:' from messages and group/thread them. + var m = this.props.message; + return ( +
+
+
+
+

{m.Subject}

+

+ From {m.From} at {m.Date.toLocaleString()} +

+
+ +
+ + + Original +
+
+ +
{this.state.body}
+
+
+); + } +}); diff --git a/static/js/nav.js b/static/js/nav.js new file mode 100644 index 0000000..e2ac7dd --- /dev/null +++ b/static/js/nav.js @@ -0,0 +1,36 @@ +/** @jsx React.DOM */ + +var NavView = React.createClass({ + getInitialState: function() { + return { + unreadCount: {} + }; + }, + + componentDidMount: function() { + $.get(this.props.source, function(result) { + console.log('NavView $.get', result); + this.setState({unreadCount: result}); + }.bind(this)); + }, + + render: function() { + var keys = Object.keys(this.state.unreadCount).sort(); + var lis = []; + for (var i in keys) { + var key = keys[i]; + var value = this.state.unreadCount[key]; + lis.push(
  • {key}
  • ); + } + return ( + +); + } +}); diff --git a/static/js/unread.js b/static/js/unread.js deleted file mode 100644 index 7f3a3f2..0000000 --- a/static/js/unread.js +++ /dev/null @@ -1,42 +0,0 @@ -/** @jsx React.DOM */ - -// TODO rename this nav. - -var UnreadCount = React.createClass({ - getInitialState: function() { - return { - unreadCount: {} - }; - }, - - componentDidMount: function() { - $.get(this.props.source, function(result) { - console.log('UnreadCount $.get', result); - this.setState({unreadCount: result}); - }.bind(this)); - }, - - render: function() { - var keys = Object.keys(this.state.unreadCount).sort(); - console.log('unread keys', keys); - var lis = []; - for (var i in keys) { - var key = keys[i]; - var value = this.state.unreadCount[key]; - var dom = React.DOM.li({key: key}, React.DOM.a({href: '#'}, - React.DOM.span({className: 'email-count'}, '(', value, ')'), - ' ', key - )); - lis.push(dom); - } - console.log('Found', lis); - // TODO rewrite as JSX template? How to handle repreating fields? - return React.DOM.div({id: 'nav', className: 'pure-u'}, - React.DOM.div({className: 'nav-inner'}, - React.DOM.div({ - id: 'unread-list', - className: 'pure-menu pure-menu-open' - }, React.DOM.ul({}, - lis)))); - } -});