작성자 | SHA1 | 메시지 | 날짜 |
---|---|---|---|
j3s | b5fcf10c44 | Repoint repository references to new location | 3 년 전 |
Drew DeVault | 51d762ac5f | Implement mailbox subscriptions | 3 년 전 |
Drew DeVault | 8cc742f45d | Fix issues with to/from headers | 3 년 전 |
Drew DeVault | 61cdb93e48 |
s/email/text/ in To & From fields
type="email" does not validate RFC 2822 address lists |
3 년 전 |
Drew DeVault | 797b426ec6 |
Add "Full name" option to settings
This provides the name portion of your From header in the compose view. |
3 년 전 |
Drew DeVault | 51498a2dc3 | JS enhancements to encourage bottom-posting | 3 년 전 |
Drew DeVault | a1e8bcc561 | Implement message signature setting | 3 년 전 |
Drew DeVault | a5d2af2c4e |
Remove "this email was deleted by another client"
I was on the fence about adding this in the first place. The state of an email being \Deleted but still in this inbox is unusual, and unlikely to occur unless the user is already somewhat knowledgable about IMAP and utilizing power-user-level tooling which could cause the situation to arise. Alps does not target that kind of user, so this can be hidden. |
3 년 전 |
Drew DeVault | cb37df882e | Add notices on action completion | 3 년 전 |
Drew DeVault | 405c18d213 | Convert HTML to plaintext for forwarding & replies | 3 년 전 |
Drew DeVault | 2fb78cad97 | Fix name of HTML tab in HTML-only emails | 3 년 전 |
Drew DeVault | 2430473dbc |
Comment out subscription buttons
This probably doesn't make sense to prioritize on the last day of development in this billing phase. |
3 년 전 |
Drew DeVault | 8771ddeb2d | Add favicons for alps theme | 3 년 전 |
Drew DeVault | 1992880454 | Add theme-specific error page | 3 년 전 |
Drew DeVault | 5087e4b327 | Improve "attachments exceed max size" error message | 3 년 전 |
Drew DeVault | 297afc5ce6 | Limit total size of unsent attachments | 3 년 전 |
@@ -2,7 +2,7 @@ image: alpine/edge | |||
packages: | |||
- go | |||
sources: | |||
- https://git.sr.ht/~emersion/alps | |||
- https://git.sr.ht/~migadu/alps | |||
tasks: | |||
- build: | | |||
cd alps | |||
@@ -1,7 +1,7 @@ | |||
# [alps] | |||
[![GoDoc](https://godoc.org/git.sr.ht/~emersion/alps?status.svg)](https://godoc.org/git.sr.ht/~emersion/alps) | |||
[![builds.sr.ht status](https://builds.sr.ht/~emersion/alps/commits.svg)](https://builds.sr.ht/~emersion/alps/commits?) | |||
[![GoDoc](https://godoc.org/git.sr.ht/~migadu/alps?status.svg)](https://godoc.org/git.sr.ht/~migadu/alps) | |||
[![builds.sr.ht status](https://builds.sr.ht/~migadu/alps/commits.svg)](https://builds.sr.ht/~migadu/alps/commits?) | |||
A simple and extensible webmail. | |||
@@ -29,8 +29,8 @@ Send patches on the [mailing list], report bugs on the [issue tracker]. | |||
MIT | |||
[alps]: https://sr.ht/~emersion/alps | |||
[alps]: https://sr.ht/~migadu/alps | |||
[RFC 6186]: https://tools.ietf.org/html/rfc6186 | |||
[Go plugin helpers]: https://godoc.org/git.sr.ht/~emersion/alps#GoPlugin | |||
[mailing list]: https://lists.sr.ht/~emersion/alps-dev | |||
[issue tracker]: https://todo.sr.ht/~emersion/alps | |||
[Go plugin helpers]: https://godoc.org/git.sr.ht/~migadu/alps#GoPlugin | |||
[mailing list]: https://lists.sr.ht/~migadu/alps-dev | |||
[issue tracker]: https://todo.sr.ht/~migadu/alps |
@@ -9,18 +9,18 @@ import ( | |||
"syscall" | |||
"time" | |||
"git.sr.ht/~emersion/alps" | |||
"git.sr.ht/~migadu/alps" | |||
"github.com/fernet/fernet-go" | |||
"github.com/labstack/echo/v4" | |||
"github.com/labstack/echo/v4/middleware" | |||
"github.com/labstack/gommon/log" | |||
_ "git.sr.ht/~emersion/alps/plugins/base" | |||
_ "git.sr.ht/~emersion/alps/plugins/caldav" | |||
_ "git.sr.ht/~emersion/alps/plugins/carddav" | |||
_ "git.sr.ht/~emersion/alps/plugins/lua" | |||
_ "git.sr.ht/~emersion/alps/plugins/viewhtml" | |||
_ "git.sr.ht/~emersion/alps/plugins/viewtext" | |||
_ "git.sr.ht/~migadu/alps/plugins/base" | |||
_ "git.sr.ht/~migadu/alps/plugins/caldav" | |||
_ "git.sr.ht/~migadu/alps/plugins/carddav" | |||
_ "git.sr.ht/~migadu/alps/plugins/lua" | |||
_ "git.sr.ht/~migadu/alps/plugins/viewhtml" | |||
_ "git.sr.ht/~migadu/alps/plugins/viewtext" | |||
) | |||
func main() { | |||
@@ -70,9 +70,6 @@ func main() { | |||
e.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ | |||
Format: "${time_rfc3339} method=${method}, uri=${uri}, status=${status}\n", | |||
})) | |||
} | |||
if options.Debug { | |||
e.Logger.SetLevel(log.DEBUG) | |||
} | |||
@@ -7,8 +7,8 @@ import ( | |||
"fmt" | |||
"net/http" | |||
"git.sr.ht/~emersion/alps" | |||
alpsbase "git.sr.ht/~emersion/alps/plugins/base" | |||
"git.sr.ht/~migadu/alps" | |||
alpsbase "git.sr.ht/~migadu/alps/plugins/base" | |||
) | |||
func init() { | |||
@@ -1,4 +1,4 @@ | |||
module git.sr.ht/~emersion/alps | |||
module git.sr.ht/~migadu/alps | |||
go 1.13 | |||
@@ -12,7 +12,7 @@ require ( | |||
github.com/emersion/go-imap-metadata v0.0.0-20200128185110-9d939d2a0915 | |||
github.com/emersion/go-imap-move v0.0.0-20190710073258-6e5a51a5b342 | |||
github.com/emersion/go-imap-specialuse v0.0.0-20161227184202-ba031ced6a62 | |||
github.com/emersion/go-message v0.11.2 | |||
github.com/emersion/go-message v0.13.1-0.20201112194930-f77964fe28bd | |||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 | |||
github.com/emersion/go-smtp v0.13.0 | |||
github.com/emersion/go-vcard v0.0.0-20200508080525-dd3110a24ec2 | |||
@@ -23,9 +23,12 @@ require ( | |||
github.com/labstack/echo/v4 v4.1.16 | |||
github.com/labstack/gommon v0.3.0 | |||
github.com/microcosm-cc/bluemonday v1.0.2 | |||
github.com/olekukonko/tablewriter v0.0.4 // indirect | |||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect | |||
github.com/yuin/gopher-lua v0.0.0-20191220021717-ab39c6098bdb | |||
gitlab.com/golang-commonmark/linkify v0.0.0-20200225224916-64bca66f6ad3 | |||
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37 // indirect | |||
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f | |||
jaytaylor.com/html2text v0.0.0-20200412013138-3577fbdbcff7 | |||
layeh.com/gopher-luar v1.0.7 | |||
) |
@@ -42,6 +42,8 @@ github.com/emersion/go-message v0.11.1 h1:0C/S4JIXDTSfXB1vpqdimAYyK4+79fgEAMQ0dS | |||
github.com/emersion/go-message v0.11.1/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY= | |||
github.com/emersion/go-message v0.11.2 h1:oxO9SQ+3wgBAQRdk07eqfkCJ26Tl8ZHF7CcpGVoE00o= | |||
github.com/emersion/go-message v0.11.2/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY= | |||
github.com/emersion/go-message v0.13.1-0.20201112194930-f77964fe28bd h1:6CXxdoOzAyQForkd2U/JNceVyNpmg92alCU2R+4dwIY= | |||
github.com/emersion/go-message v0.13.1-0.20201112194930-f77964fe28bd/go.mod h1:SXSs/8KamlsyxjpHL1Q3yf5Jrv7QG5icuvPK1SMcnzw= | |||
github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b h1:uhWtEWBHgop1rqEk2klKaxPAkVDCXexai6hSuRQ7Nvs= | |||
github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k= | |||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= | |||
@@ -50,6 +52,8 @@ github.com/emersion/go-smtp v0.13.0 h1:aC3Kc21TdfvXnuJXCQXuhnDXUldhc12qME/S7Y3Y9 | |||
github.com/emersion/go-smtp v0.13.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= | |||
github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe h1:40SWqY0zE3qCi6ZrtTf5OUdNm5lDnGnjRSq9GgmeTrg= | |||
github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= | |||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY= | |||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= | |||
github.com/emersion/go-vcard v0.0.0-20191221110513-5f81fa0d3cc7 h1:SE+tcd+0kn0cT4MqTo66gmkjqWHF1Z+Yha5/rhLs/H8= | |||
github.com/emersion/go-vcard v0.0.0-20191221110513-5f81fa0d3cc7/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= | |||
github.com/emersion/go-vcard v0.0.0-20200508080525-dd3110a24ec2 h1:g1RgqggIPPkEBubnOxAbIglxjMsP0aHPO2ryigBHu2s= | |||
@@ -96,6 +100,8 @@ github.com/labstack/gommon v0.3.0 h1:JEeO0bvc78PKdyHxloTKiF8BD5iGrH8T6MSeGvSgob0 | |||
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= | |||
github.com/martinlindhe/base36 v1.0.0 h1:eYsumTah144C0A8P1T/AVSUk5ZoLnhfYFM3OGQxB52A= | |||
github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8= | |||
github.com/martinlindhe/base36 v1.1.0 h1:cIwvvwYse/0+1CkUPYH5ZvVIYG3JrILmQEIbLuar02Y= | |||
github.com/martinlindhe/base36 v1.1.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8= | |||
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= | |||
github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE= | |||
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= | |||
@@ -103,6 +109,8 @@ github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hd | |||
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= | |||
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= | |||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= | |||
github.com/mattn/go-runewidth v0.0.7 h1:Ei8KR0497xHyKJPAv59M1dkC+rOZCMBJ+t3fZ+twI54= | |||
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= | |||
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= | |||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= | |||
github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s= | |||
@@ -112,6 +120,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ | |||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= | |||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= | |||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= | |||
github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8= | |||
github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA= | |||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= | |||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= | |||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | |||
@@ -133,6 +143,8 @@ github.com/prometheus/procfs v0.1.3 h1:F0+tqvhOksq22sc6iCHF5WGlWjdwj92p0udFh1VFB | |||
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= | |||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= | |||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= | |||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= | |||
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= | |||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | |||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | |||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= | |||
@@ -186,6 +198,8 @@ golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7w | |||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= | |||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= | |||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= | |||
golang.org/x/text v0.3.4-0.20201021145329-22f1617af38e h1:0kyKOEC0chG7FKmnf/1uNwvDLc3NtNTRip2rXAN9nwI= | |||
golang.org/x/text v0.3.4-0.20201021145329-22f1617af38e/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= | |||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= | |||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= | |||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= | |||
@@ -205,5 +219,7 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= | |||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= | |||
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= | |||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | |||
jaytaylor.com/html2text v0.0.0-20200412013138-3577fbdbcff7 h1:mub0MmFLOn8XLikZOAhgLD1kXJq8jgftSrrv7m00xFo= | |||
jaytaylor.com/html2text v0.0.0-20200412013138-3577fbdbcff7/go.mod h1:OxvTsCwKosqQ1q7B+8FwXqg4rKZ/UG9dUW+g/VL2xH4= | |||
layeh.com/gopher-luar v1.0.7 h1:53iv6CCkRs5wyofZ+qVXcyAYQOIG52s6pt4xkqZdq7k= | |||
layeh.com/gopher-luar v1.0.7/go.mod h1:TPnIVCZ2RJBndm7ohXyaqfhzjlZ+OA2SZR/YwL8tECk= |
@@ -1,7 +1,7 @@ | |||
package alpsbase | |||
import ( | |||
"git.sr.ht/~emersion/alps" | |||
"git.sr.ht/~migadu/alps" | |||
) | |||
func init() { | |||
@@ -12,14 +12,15 @@ import ( | |||
"strconv" | |||
"strings" | |||
"git.sr.ht/~emersion/alps" | |||
"git.sr.ht/~migadu/alps" | |||
"github.com/emersion/go-imap" | |||
imapmove "github.com/emersion/go-imap-move" | |||
imapclient "github.com/emersion/go-imap/client" | |||
"github.com/emersion/go-message" | |||
"github.com/emersion/go-message/mail" | |||
"github.com/emersion/go-smtp" | |||
"github.com/labstack/echo/v4" | |||
"jaytaylor.com/html2text" | |||
imapclient "github.com/emersion/go-imap/client" | |||
imapmove "github.com/emersion/go-imap-move" | |||
) | |||
func registerRoutes(p *alps.GoPlugin) { | |||
@@ -79,6 +80,7 @@ type IMAPBaseRenderData struct { | |||
Mailboxes []MailboxInfo | |||
Mailbox *MailboxStatus | |||
Inbox *MailboxStatus | |||
Subscriptions map[string]*MailboxStatus | |||
} | |||
type MailboxRenderData struct { | |||
@@ -88,17 +90,22 @@ type MailboxRenderData struct { | |||
Query string | |||
} | |||
type MailboxDetails struct { | |||
Info *MailboxInfo | |||
Status *MailboxStatus | |||
} | |||
// Organizes mailboxes into common/uncommon categories | |||
type CategorizedMailboxes struct { | |||
Common struct { | |||
Inbox *MailboxInfo | |||
Drafts *MailboxInfo | |||
Sent *MailboxInfo | |||
Junk *MailboxInfo | |||
Trash *MailboxInfo | |||
Archive *MailboxInfo | |||
} | |||
Additional []*MailboxInfo | |||
Inbox MailboxDetails | |||
Drafts MailboxDetails | |||
Sent MailboxDetails | |||
Junk MailboxDetails | |||
Trash MailboxDetails | |||
Archive MailboxDetails | |||
} | |||
Additional []MailboxDetails | |||
} | |||
func newIMAPBaseRenderData(ctx *alps.Context, | |||
@@ -109,6 +116,12 @@ func newIMAPBaseRenderData(ctx *alps.Context, | |||
return nil, echo.NewHTTPError(http.StatusBadRequest, err) | |||
} | |||
settings, err := loadSettings(ctx.Session.Store()) | |||
if err != nil { | |||
return nil, fmt.Errorf("failed to load settings: %v", err) | |||
} | |||
subscriptions := make(map[string]*MailboxStatus) | |||
var mailboxes []MailboxInfo | |||
var active, inbox *MailboxStatus | |||
err = ctx.Session.DoIMAP(func(c *imapclient.Client) error { | |||
@@ -116,11 +129,13 @@ func newIMAPBaseRenderData(ctx *alps.Context, | |||
if mailboxes, err = listMailboxes(c); err != nil { | |||
return err | |||
} | |||
if mboxName != "" { | |||
if active, err = getMailboxStatus(c, mboxName); err != nil { | |||
return err | |||
return echo.NewHTTPError(http.StatusNotFound, err) | |||
} | |||
} | |||
if mboxName == "INBOX" { | |||
inbox = active | |||
} else { | |||
@@ -128,6 +143,14 @@ func newIMAPBaseRenderData(ctx *alps.Context, | |||
return err | |||
} | |||
} | |||
for _, sub := range settings.Subscriptions { | |||
if status, err := getMailboxStatus(c, sub); err != nil { | |||
return err | |||
} else { | |||
subscriptions[sub] = status | |||
} | |||
} | |||
return nil | |||
}) | |||
if err != nil { | |||
@@ -135,7 +158,7 @@ func newIMAPBaseRenderData(ctx *alps.Context, | |||
} | |||
var categorized CategorizedMailboxes | |||
mmap := map[string]**MailboxInfo{ | |||
mmap := map[string]*MailboxDetails{ | |||
"INBOX": &categorized.Common.Inbox, | |||
"Drafts": &categorized.Common.Drafts, | |||
"Sent": &categorized.Common.Sent, | |||
@@ -156,11 +179,16 @@ func newIMAPBaseRenderData(ctx *alps.Context, | |||
mailboxes[i].Total = int(inbox.Messages) | |||
} | |||
status, _ := subscriptions[mailboxes[i].Name] | |||
if ptr, ok := mmap[mailboxes[i].Name]; ok { | |||
*ptr = &mailboxes[i] | |||
ptr.Info = &mailboxes[i] | |||
ptr.Status = status | |||
} else { | |||
categorized.Additional = append( | |||
categorized.Additional, &mailboxes[i]) | |||
categorized.Additional = append(categorized.Additional, | |||
MailboxDetails{ | |||
Info: &mailboxes[i], | |||
Status: status, | |||
}) | |||
} | |||
} | |||
@@ -170,6 +198,7 @@ func newIMAPBaseRenderData(ctx *alps.Context, | |||
Mailboxes: mailboxes, | |||
Inbox: inbox, | |||
Mailbox: active, | |||
Subscriptions: subscriptions, | |||
}, nil | |||
} | |||
@@ -305,6 +334,7 @@ func handleDeleteMailbox(ctx *alps.Context) error { | |||
ctx.Session.DoIMAP(func(c *imapclient.Client) error { | |||
return c.Delete(mbox.Name) | |||
}) | |||
ctx.Session.PutNotice("Mailbox deleted.") | |||
return ctx.Redirect(http.StatusFound, "/mailbox/INBOX") | |||
} | |||
@@ -518,6 +548,7 @@ func submitCompose(ctx *alps.Context, msg *OutgoingMessage, options *composeOpti | |||
return fmt.Errorf("failed to save message to Sent mailbox: %v", err) | |||
} | |||
ctx.Session.PutNotice("Message sent.") | |||
return ctx.Redirect(http.StatusFound, "/mailbox/INBOX") | |||
} | |||
@@ -528,7 +559,19 @@ func handleCompose(ctx *alps.Context, msg *OutgoingMessage, options *composeOpti | |||
} | |||
if msg.From == "" && strings.ContainsRune(ctx.Session.Username(), '@') { | |||
msg.From = ctx.Session.Username() | |||
settings, err := loadSettings(ctx.Session.Store()) | |||
if err != nil { | |||
return err | |||
} | |||
if settings.From != "" { | |||
addr := mail.Address{ | |||
Name: settings.From, | |||
Address: ctx.Session.Username(), | |||
} | |||
msg.From = addr.String() | |||
} else { | |||
msg.From = ctx.Session.Username() | |||
} | |||
} | |||
if ctx.Request().Method == http.MethodPost { | |||
@@ -650,6 +693,7 @@ func handleCompose(ctx *alps.Context, msg *OutgoingMessage, options *composeOpti | |||
if err != nil { | |||
return fmt.Errorf("failed to save message to Draft mailbox: %v", err) | |||
} | |||
ctx.Session.PutNotice("Message saved as draft.") | |||
return ctx.Redirect(http.StatusFound, fmt.Sprintf( | |||
"/message/%s/%d/edit?part=1", drafts.Name, uid)) | |||
} else { | |||
@@ -664,14 +708,26 @@ func handleCompose(ctx *alps.Context, msg *OutgoingMessage, options *composeOpti | |||
} | |||
func handleComposeNew(ctx *alps.Context) error { | |||
text := ctx.QueryParam("body") | |||
settings, err := loadSettings(ctx.Session.Store()) | |||
if err != nil { | |||
return nil | |||
} | |||
if text == "" && settings.Signature != "" { | |||
text = "\n\n\n-- \n" + settings.Signature | |||
} | |||
// These are common mailto URL query parameters | |||
// TODO: cc, bcc | |||
var hdr mail.Header | |||
hdr.GenerateMessageID() | |||
mid, _ := hdr.MessageID() | |||
return handleCompose(ctx, &OutgoingMessage{ | |||
To: strings.Split(ctx.QueryParam("to"), ","), | |||
Subject: ctx.QueryParam("subject"), | |||
Text: ctx.QueryParam("body"), | |||
MessageID: mail.GenerateMessageID(), | |||
MessageID: "<" + mid + ">", | |||
InReplyTo: ctx.QueryParam("in-reply-to"), | |||
Text: text, | |||
}, &composeOptions{}) | |||
} | |||
@@ -692,7 +748,13 @@ func handleComposeAttachment(ctx *alps.Context) error { | |||
var uuids []string | |||
for _, fh := range form.File["attachments"] { | |||
uuid, err := ctx.Session.PutAttachment(fh, form) | |||
if err != nil { | |||
if err == alps.ErrAttachmentCacheSize { | |||
form.RemoveAll() | |||
return ctx.JSON(http.StatusBadRequest, map[string]string{ | |||
"error": "Your attachments exceed the maximum file size. Remove some and try again.", | |||
}) | |||
} else if err != nil { | |||
form.RemoveAll() | |||
ctx.Logger().Printf("PutAttachment: %v\n", err) | |||
return ctx.JSON(http.StatusBadRequest, map[string]string{ | |||
"error": "failed to store attachment", | |||
@@ -753,18 +815,29 @@ func handleReply(ctx *alps.Context) error { | |||
return fmt.Errorf("failed to parse part Content-Type: %v", err) | |||
} | |||
if !strings.EqualFold(mimeType, "text/plain") { | |||
err := fmt.Errorf("cannot reply to %q part", mimeType) | |||
if mimeType == "text/plain" { | |||
msg.Text, err = quote(part.Body) | |||
if err != nil { | |||
return err | |||
} | |||
} else if mimeType == "text/html" { | |||
text, err := html2text.FromReader(part.Body, html2text.Options{}) | |||
if err != nil { | |||
return err | |||
} | |||
msg.Text, err = quote(strings.NewReader(text)) | |||
if err != nil { | |||
return nil | |||
} | |||
} else { | |||
err := fmt.Errorf("cannot forward %q part", mimeType) | |||
return echo.NewHTTPError(http.StatusBadRequest, err) | |||
} | |||
// TODO: strip HTML tags if text/html | |||
msg.Text, err = quote(part.Body) | |||
if err != nil { | |||
return err | |||
} | |||
msg.MessageID = mail.GenerateMessageID() | |||
var hdr mail.Header | |||
hdr.GenerateMessageID() | |||
mid, _ := hdr.MessageID() | |||
msg.MessageID = "<" + mid + ">" | |||
msg.InReplyTo = inReplyTo.Envelope.MessageId | |||
// TODO: populate From from known user addresses and inReplyTo.Envelope.To | |||
replyTo := inReplyTo.Envelope.ReplyTo | |||
@@ -813,17 +886,25 @@ func handleForward(ctx *alps.Context) error { | |||
return fmt.Errorf("failed to parse part Content-Type: %v", err) | |||
} | |||
if !strings.EqualFold(mimeType, "text/plain") { | |||
if mimeType == "text/plain" { | |||
msg.Text, err = quote(part.Body) | |||
if err != nil { | |||
return err | |||
} | |||
} else if mimeType == "text/html" { | |||
msg.Text, err = html2text.FromReader(part.Body, html2text.Options{}) | |||
if err != nil { | |||
return err | |||
} | |||
} else { | |||
err := fmt.Errorf("cannot forward %q part", mimeType) | |||
return echo.NewHTTPError(http.StatusBadRequest, err) | |||
} | |||
msg.Text, err = quote(part.Body) | |||
if err != nil { | |||
return err | |||
} | |||
msg.MessageID = mail.GenerateMessageID() | |||
var hdr mail.Header | |||
hdr.GenerateMessageID() | |||
mid, _ := hdr.MessageID() | |||
msg.MessageID = "<" + mid + ">" | |||
msg.Subject = source.Envelope.Subject | |||
if !strings.HasPrefix(strings.ToLower(msg.Subject), "fwd:") && | |||
!strings.HasPrefix(strings.ToLower(msg.Subject), "fw:") { | |||
@@ -961,6 +1042,7 @@ func handleMove(ctx *alps.Context) error { | |||
return err | |||
} | |||
ctx.Session.PutNotice("Message(s) moved.") | |||
if path := formOrQueryParam(ctx, "next"); path != "" { | |||
return ctx.Redirect(http.StatusFound, path) | |||
} | |||
@@ -1012,6 +1094,7 @@ func handleDelete(ctx *alps.Context) error { | |||
return err | |||
} | |||
ctx.Session.PutNotice("Message(s) deleted.") | |||
if path := formOrQueryParam(ctx, "next"); path != "" { | |||
return ctx.Redirect(http.StatusFound, path) | |||
} | |||
@@ -1099,6 +1182,9 @@ const maxMessagesPerPage = 100 | |||
type Settings struct { | |||
MessagesPerPage int | |||
Signature string | |||
From string | |||
Subscriptions []string | |||
} | |||
func loadSettings(s alps.Store) (*Settings, error) { | |||
@@ -1118,12 +1204,31 @@ func (s *Settings) check() error { | |||
if s.MessagesPerPage <= 0 || s.MessagesPerPage > maxMessagesPerPage { | |||
return fmt.Errorf("messages per page out of bounds: %v", s.MessagesPerPage) | |||
} | |||
if len(s.Signature) > 2048 { | |||
return fmt.Errorf("Signature must be 2048 characters or fewer") | |||
} | |||
if len(s.From) > 512 { | |||
return fmt.Errorf("Full name must be 512 characters or fewer") | |||
} | |||
return nil | |||
} | |||
type SettingsRenderData struct { | |||
alps.BaseRenderData | |||
Settings *Settings | |||
Mailboxes []MailboxInfo | |||
Settings *Settings | |||
Subscriptions Subscriptions | |||
} | |||
type Subscriptions []string | |||
func (s Subscriptions) Has(sub string) bool { | |||
for _, cand := range s { | |||
if cand == sub { | |||
return true | |||
} | |||
} | |||
return false | |||
} | |||
func handleSettings(ctx *alps.Context) error { | |||
@@ -1132,11 +1237,28 @@ func handleSettings(ctx *alps.Context) error { | |||
return fmt.Errorf("failed to load settings: %v", err) | |||
} | |||
var mailboxes []MailboxInfo | |||
err = ctx.Session.DoIMAP(func(c *imapclient.Client) error { | |||
mailboxes, err = listMailboxes(c) | |||
return err | |||
}) | |||
if err != nil { | |||
return err | |||
} | |||
if ctx.Request().Method == http.MethodPost { | |||
settings.MessagesPerPage, err = strconv.Atoi(ctx.FormValue("messages_per_page")) | |||
if err != nil { | |||
return echo.NewHTTPError(http.StatusBadRequest, "invalid messages per page: %v", err) | |||
} | |||
settings.Signature = ctx.FormValue("signature") | |||
settings.From = ctx.FormValue("from") | |||
params, err := ctx.FormParams() | |||
if err != nil { | |||
return err | |||
} | |||
settings.Subscriptions = params["subscriptions"] | |||
if err := settings.check(); err != nil { | |||
return echo.NewHTTPError(http.StatusBadRequest, err) | |||
@@ -1151,5 +1273,7 @@ func handleSettings(ctx *alps.Context) error { | |||
return ctx.Render(http.StatusOK, "settings.html", &SettingsRenderData{ | |||
BaseRenderData: *alps.NewBaseRenderData(ctx), | |||
Settings: settings, | |||
Mailboxes: mailboxes, | |||
Subscriptions: Subscriptions(settings.Subscriptions), | |||
}) | |||
} |
@@ -26,6 +26,7 @@ func quote(r io.Reader) (string, error) { | |||
if err := scanner.Err(); err != nil { | |||
return "", fmt.Errorf("quote: failed to read original message: %s", err) | |||
} | |||
builder.WriteString("\n") | |||
return builder.String(), nil | |||
} | |||
@@ -122,11 +123,19 @@ func writeAttachment(mw *mail.Writer, att Attachment) error { | |||
} | |||
func (msg *OutgoingMessage) WriteTo(w io.Writer) error { | |||
from := []*mail.Address{{"", msg.From}} | |||
fromAddr, err := mail.ParseAddress(msg.From) | |||
if err != nil { | |||
return err | |||
} | |||
from := []*mail.Address{fromAddr} | |||
to := make([]*mail.Address, len(msg.To)) | |||
for i, addr := range msg.To { | |||
to[i] = &mail.Address{"", addr} | |||
for i, rcpt := range msg.To { | |||
addr, err := mail.ParseAddress(rcpt) | |||
if err != nil { | |||
return err | |||
} | |||
to[i] = addr | |||
} | |||
var h mail.Header | |||
@@ -181,13 +190,15 @@ func (msg *OutgoingMessage) WriteTo(w io.Writer) error { | |||
} | |||
func sendMessage(c *smtp.Client, msg *OutgoingMessage) error { | |||
if err := c.Mail(msg.From, nil); err != nil { | |||
addr, _ := mail.ParseAddress(msg.From) | |||
if err := c.Mail(addr.Address, nil); err != nil { | |||
return fmt.Errorf("MAIL FROM failed: %v", err) | |||
} | |||
for _, to := range msg.To { | |||
if err := c.Rcpt(to); err != nil { | |||
return fmt.Errorf("RCPT TO failed: %v", err) | |||
addr, _ := mail.ParseAddress(to) | |||
if err := c.Rcpt(addr.Address); err != nil { | |||
return fmt.Errorf("RCPT TO failed: %v (%s)", err, addr.Address) | |||
} | |||
} | |||
@@ -3,7 +3,7 @@ package alpsbase | |||
import ( | |||
"fmt" | |||
"git.sr.ht/~emersion/alps" | |||
"git.sr.ht/~migadu/alps" | |||
"github.com/emersion/go-message" | |||
) | |||
@@ -5,7 +5,7 @@ import ( | |||
"net/http" | |||
"net/url" | |||
"git.sr.ht/~emersion/alps" | |||
"git.sr.ht/~migadu/alps" | |||
"github.com/emersion/go-webdav/caldav" | |||
) | |||
@@ -5,7 +5,7 @@ import ( | |||
"net/http" | |||
"net/url" | |||
"git.sr.ht/~emersion/alps" | |||
"git.sr.ht/~migadu/alps" | |||
) | |||
const ( | |||
@@ -8,7 +8,7 @@ import ( | |||
"strings" | |||
"time" | |||
"git.sr.ht/~emersion/alps" | |||
"git.sr.ht/~migadu/alps" | |||
"github.com/emersion/go-ical" | |||
"github.com/emersion/go-webdav/caldav" | |||
"github.com/google/uuid" | |||
@@ -5,7 +5,7 @@ import ( | |||
"net/http" | |||
"net/url" | |||
"git.sr.ht/~emersion/alps" | |||
"git.sr.ht/~migadu/alps" | |||
"github.com/emersion/go-webdav/carddav" | |||
) | |||
@@ -5,8 +5,8 @@ import ( | |||
"net/http" | |||
"net/url" | |||
"git.sr.ht/~emersion/alps" | |||
alpsbase "git.sr.ht/~emersion/alps/plugins/base" | |||
"git.sr.ht/~migadu/alps" | |||
alpsbase "git.sr.ht/~migadu/alps/plugins/base" | |||
"github.com/emersion/go-vcard" | |||
"github.com/emersion/go-webdav/carddav" | |||
) | |||
@@ -7,7 +7,7 @@ import ( | |||
"path" | |||
"strings" | |||
"git.sr.ht/~emersion/alps" | |||
"git.sr.ht/~migadu/alps" | |||
"github.com/emersion/go-vcard" | |||
"github.com/emersion/go-webdav/carddav" | |||
"github.com/google/uuid" | |||
@@ -5,7 +5,7 @@ import ( | |||
"html/template" | |||
"path/filepath" | |||
"git.sr.ht/~emersion/alps" | |||
"git.sr.ht/~migadu/alps" | |||
"github.com/labstack/echo/v4" | |||
"github.com/yuin/gopher-lua" | |||
"layeh.com/gopher-luar" | |||
@@ -1,7 +1,7 @@ | |||
package alpslua | |||
import ( | |||
"git.sr.ht/~emersion/alps" | |||
"git.sr.ht/~migadu/alps" | |||
) | |||
func init() { | |||
@@ -8,8 +8,8 @@ import ( | |||
"strconv" | |||
"strings" | |||
"git.sr.ht/~emersion/alps" | |||
alpsbase "git.sr.ht/~emersion/alps/plugins/base" | |||
"git.sr.ht/~migadu/alps" | |||
alpsbase "git.sr.ht/~migadu/alps/plugins/base" | |||
"github.com/labstack/echo/v4" | |||
) | |||
@@ -7,7 +7,7 @@ import ( | |||
"regexp" | |||
"strings" | |||
alpsbase "git.sr.ht/~emersion/alps/plugins/base" | |||
alpsbase "git.sr.ht/~migadu/alps/plugins/base" | |||
"github.com/aymerick/douceur/css" | |||
cssparser "github.com/chris-ramon/douceur/parser" | |||
"github.com/microcosm-cc/bluemonday" | |||
@@ -7,8 +7,8 @@ import ( | |||
"io/ioutil" | |||
"strings" | |||
"git.sr.ht/~emersion/alps" | |||
alpsbase "git.sr.ht/~emersion/alps/plugins/base" | |||
"git.sr.ht/~migadu/alps" | |||
alpsbase "git.sr.ht/~migadu/alps/plugins/base" | |||
"github.com/emersion/go-message" | |||
) | |||
@@ -1,7 +1,7 @@ | |||
package alpsviewtext | |||
import ( | |||
"git.sr.ht/~emersion/alps" | |||
"git.sr.ht/~migadu/alps" | |||
) | |||
func init() { | |||
@@ -7,8 +7,8 @@ import ( | |||
"net/url" | |||
"strings" | |||
"git.sr.ht/~emersion/alps" | |||
alpsbase "git.sr.ht/~emersion/alps/plugins/base" | |||
"git.sr.ht/~migadu/alps" | |||
alpsbase "git.sr.ht/~migadu/alps/plugins/base" | |||
"github.com/emersion/go-message" | |||
"gitlab.com/golang-commonmark/linkify" | |||
) | |||
@@ -28,6 +28,8 @@ type GlobalRenderData struct { | |||
HavePlugin func(name string) bool | |||
Notice string | |||
// additional plugin-specific data | |||
Extra map[string]interface{} | |||
} | |||
@@ -70,14 +72,19 @@ type RenderData interface { | |||
// BaseRenderData: *alps.NewBaseRenderData(ctx), | |||
// // other fields... | |||
// } | |||
func NewBaseRenderData(ctx *Context) *BaseRenderData { | |||
func NewBaseRenderData(ectx echo.Context) *BaseRenderData { | |||
ctx, isactx := ectx.(*Context) | |||
global := GlobalRenderData{ | |||
Extra: make(map[string]interface{}), | |||
Path: strings.Split(ctx.Request().URL.Path, "/")[1:], | |||
Path: strings.Split(ectx.Request().URL.Path, "/")[1:], | |||
Title: "Webmail", | |||
URL: ctx.Request().URL, | |||
URL: ectx.Request().URL, | |||
HavePlugin: func(name string) bool { | |||
if !isactx { | |||
return false | |||
} | |||
for _, plugin := range ctx.Server.plugins { | |||
if plugin.Name() == name { | |||
return true | |||
@@ -87,9 +94,10 @@ func NewBaseRenderData(ctx *Context) *BaseRenderData { | |||
}, | |||
} | |||
if ctx.Session != nil { | |||
if isactx && ctx.Session != nil { | |||
global.LoggedIn = true | |||
global.Username = ctx.Session.username | |||
global.Notice = ctx.Session.PopNotice() | |||
} | |||
return &BaseRenderData{ | |||
@@ -382,15 +382,31 @@ func New(e *echo.Echo, options *Options) (*Server, error) { | |||
return nil, err | |||
} | |||
e.HTTPErrorHandler = func(err error, c echo.Context) { | |||
e.HTTPErrorHandler = func(err error, ctx echo.Context) { | |||
code := http.StatusInternalServerError | |||
if he, ok := err.(*echo.HTTPError); ok { | |||
code = he.Code | |||
} else { | |||
c.Logger().Error(err) | |||
} | |||
// TODO: hide internal errors | |||
c.String(code, err.Error()) | |||
type ErrorRenderData struct { | |||
BaseRenderData | |||
Code int | |||
Err error | |||
Status string | |||
} | |||
rdata := ErrorRenderData{ | |||
BaseRenderData: *NewBaseRenderData(ctx), | |||
Err: err, | |||
Code: code, | |||
Status: http.StatusText(code), | |||
} | |||
if err := ctx.Render(code, "error.html", &rdata); err != nil { | |||
ctx.Logger().Error(fmt.Errorf( | |||
"Error occured rendering error page: %w. How meta.", err)) | |||
} | |||
ctx.Logger().Error(err) | |||
} | |||
e.Pre(func(next echo.HandlerFunc) echo.HandlerFunc { | |||
@@ -426,7 +442,7 @@ func New(e *echo.Echo, options *Options) (*Server, error) { | |||
} | |||
ctx.Session, err = ctx.Server.Sessions.get(cookie.Value) | |||
if err == errSessionExpired { | |||
if err == ErrSessionExpired { | |||
ctx.SetSession(nil) | |||
return handleUnauthenticated(next, ctx) | |||
} else if err != nil { | |||
@@ -20,6 +20,7 @@ import ( | |||
// TODO: make this configurable | |||
const sessionDuration = 30 * time.Minute | |||
const maxAttachmentSize = 32 << 20 // 32 MiB | |||
func generateToken() (string, error) { | |||
b := make([]byte, 32) | |||
@@ -30,7 +31,10 @@ func generateToken() (string, error) { | |||
return base64.URLEncoding.EncodeToString(b), nil | |||
} | |||
var errSessionExpired = errors.New("session expired") | |||
var ( | |||
ErrSessionExpired = errors.New("session expired") | |||
ErrAttachmentCacheSize = errors.New("Attachments on session exceed maximum file size") | |||
) | |||
// AuthError wraps an authentication error. | |||
type AuthError struct { | |||
@@ -53,6 +57,7 @@ type Session struct { | |||
pings chan struct{} | |||
timer *time.Timer | |||
store Store | |||
notice string | |||
imapLocker sync.Mutex | |||
imapConn *imapclient.Client // protected by locker, can be nil | |||
@@ -145,15 +150,17 @@ func (s *Session) Close() { | |||
// Puts an attachment and returns a generated UUID | |||
func (s *Session) PutAttachment(in *multipart.FileHeader, | |||
form *multipart.Form) (string, error) { | |||
// TODO: Prevent users from uploading too many attachments, or too large | |||
// | |||
// Probably just set a cap on the maximum combined size of all files in the | |||
// user's session | |||
// | |||
// TODO: Figure out what to do if the user abandons the compose window | |||
// after adding some attachments | |||
id := uuid.New() | |||
s.attachmentsLocker.Lock() | |||
var size int64 | |||
for _, a := range s.attachments { | |||
size += a.File.Size | |||
} | |||
if size + in.Size > maxAttachmentSize { | |||
return "", ErrAttachmentCacheSize | |||
} | |||
s.attachments[id.String()] = &Attachment{ | |||
File: in, | |||
Form: form, | |||
@@ -177,6 +184,16 @@ func (s *Session) PopAttachment(uuid string) *Attachment { | |||
return a | |||
} | |||
func (s *Session) PutNotice(n string) { | |||
s.notice = n | |||
} | |||
func (s *Session) PopNotice() string { | |||
n := s.notice | |||
s.notice = "" | |||
return n | |||
} | |||
// Store returns a store suitable for storing persistent user data. | |||
func (s *Session) Store() Store { | |||
return s.store | |||
@@ -241,7 +258,7 @@ func (sm *SessionManager) get(token string) (*Session, error) { | |||
session, ok := sm.sessions[token] | |||
if !ok { | |||
return nil, errSessionExpired | |||
return nil, ErrSessionExpired | |||
} | |||
return session, nil | |||
} | |||
@@ -1,3 +1,11 @@ | |||
const textarea = document.querySelector("textarea.body"); | |||
if (window.location.pathname.endsWith("/reply")) { | |||
// Auto-focus body and scroll to bottom | |||
textarea.focus(); | |||
textarea.setSelectionRange(textarea.value.length, textarea.value.length); | |||
textarea.scrollTop = textarea.scrollHeight; | |||
} | |||
const sendButton = document.getElementById("send-button"), | |||
saveButton = document.getElementById("save-button"); | |||
@@ -88,7 +88,8 @@ input[type="file"], | |||
input[type="number"], | |||
input[type="date"], | |||
input[type="time"], | |||
textarea { | |||
textarea, | |||
select { | |||
margin: 0; | |||
border: none; | |||
border: 1px solid #e0e0e0; | |||
@@ -118,13 +119,31 @@ header nav div { float: right; } | |||
header nav div > a{ margin-left: 1rem; } | |||
header a.active { font-weight: bold; color: black; text-decoration: none; } | |||
header .notice { | |||
color: #0c5460; | |||
background-color: #d1ecf1; | |||
border: 1px solid #bee5eb; | |||
padding: 0.5rem; | |||
text-align: center; | |||
} | |||
footer { text-align: right; } | |||
.actions { padding: 0.5rem; } | |||
.container { flex: 1 auto; display: flex; flex-direction: column; flex-wrap: nowrap; min-width: 0; } | |||
.container { | |||
flex: 1 auto; | |||
display: flex; | |||
flex-direction: column; | |||
flex-wrap: nowrap; | |||
min-width: 0; | |||
} | |||
.container.error { | |||
max-width: 800px; | |||
margin: 0 auto; | |||
padding: 1rem 0; | |||
} | |||
aside { flex: 0 0 180px; } | |||
@@ -139,7 +158,7 @@ aside ul { | |||
aside li { | |||
width: 100%; | |||
display: flex; | |||
padding: 0.4rem 0 0.4rem 0.5rem; | |||
padding: 0.4rem 0.5rem; | |||
} | |||
aside li a { | |||
@@ -635,7 +654,9 @@ main table tfoot { | |||
} | |||
.action-group label, | |||
.action-group input { | |||
.action-group input, | |||
.action-group textarea, | |||
.action-group select { | |||
display: block; | |||
width: 100%; | |||
} | |||
@@ -646,6 +667,10 @@ main table tfoot { | |||
float: left; | |||
} | |||
.action-group select { | |||
height: 10rem; | |||
} | |||
.actions-message, | |||
.actions-contacts { | |||
display: flex; | |||
@@ -14,7 +14,7 @@ | |||
<div class="headers no-js"> | |||
<label>From</label> | |||
<input type="email" name="from" id="from" value="{{.Message.From}}" /> | |||
<input type="text" name="from" id="from" value="{{.Message.From}}" /> | |||
<label>To</label> | |||
{{ $to := .Message.ToString }} | |||
@@ -22,12 +22,10 @@ | |||
{{ $to = "" }} | |||
{{ end }} | |||
<input | |||
type="email" | |||
type="text" | |||
name="to" | |||
id="to" | |||
value="{{$to}}" | |||
multiple | |||
list="emails" | |||
{{ if not $to }} autofocus{{ end }} | |||
/> | |||
@@ -0,0 +1,14 @@ | |||
{{template "head.html" .}} | |||
<div class="page-wrap"> | |||
<div class="container error"> | |||
<h1>{{.Code}}: {{.Status}}</h1> | |||
<p> | |||
An error occured. You can try | |||
<a href="/">returning to your inbox</a>, | |||
or contact support. | |||
</p> | |||
</div> | |||
</div> | |||
{{template "foot.html"}} |
@@ -10,5 +10,10 @@ | |||
<title>{{.GlobalData.Title}}</title> | |||
<link rel="stylesheet" href="/themes/alps/assets/style.css"> | |||
<link rel="stylesheet" href="/themes/alps/assets/print.css" media="print"> | |||
<link rel="icon" type="image/png" href="/themes/alps/assets/favicon-196x196.png" sizes="196x196" /> | |||
<link rel="icon" type="image/png" href="/themes/alps/assets/favicon-96x96.png" sizes="96x96" /> | |||
<link rel="icon" type="image/png" href="/themes/alps/assets/favicon-32x32.png" sizes="32x32" /> | |||
<link rel="icon" type="image/png" href="/themes/alps/assets/favicon-16x16.png" sizes="16x16" /> | |||
<link rel="icon" type="image/png" href="/themes/alps/assets/favicon-128.png" sizes="128x128" /> | |||
</head> | |||
<body> |
@@ -21,7 +21,7 @@ | |||
{{ $classes = printf "%s %s" $classes "message-list-deleted" }} | |||
{{ end }} | |||
{{ if not (.HasFlag "\\Deleted") }} | |||
{{ if and (not (.HasFlag "\\Deleted")) .Envelope }} | |||
<div class="message-list-checkbox {{$classes}}"> | |||
<input type="checkbox" name="uids" value="{{.Uid}}" form="messages-form"> | |||
</div> | |||
@@ -70,16 +70,6 @@ | |||
<div class="message-list-date {{$classes}}"> | |||
{{ .Envelope.Date | humantime }} | |||
</div> | |||
{{ else }} | |||
<div class="message-list-checkbox {{$classes}}"> | |||
<input type="checkbox" form="messages-form" disabled readonly> | |||
</div> | |||
<div class="message-list-sender {{$classes}}"></div> | |||
<div class="message-list-flags {{$classes}}"></div> | |||
<div class="message-list-subject {{$classes}}"> | |||
<em>(this email was deleted by another client)</em> | |||
</div> | |||
<div class="message-list-date {{$classes}}"></div> | |||
{{ end }} | |||
{{ end }} | |||
@@ -190,7 +190,13 @@ | |||
{{ if eq $text.PathString .Part.PathString }} | |||
class="active" | |||
{{ end }} | |||
>Plain text</a> | |||
> | |||
{{ if eq $text.MIMEType "text/html" }} | |||
HTML | |||
{{ else }} | |||
Plain text | |||
{{ end }} | |||
</a> | |||
{{ if and $html $text }} | |||
{{ if ne $html.PathString $text.PathString }} | |||
<a | |||
@@ -22,10 +22,18 @@ | |||
{{ end }} | |||
>Contacts</a> | |||
{{ end }} | |||
{{ if .GlobalData.LoggedIn }} | |||
<div> | |||
<span>{{ .GlobalData.Username }}</span> | |||
<a href="/settings">Settings</a> | |||
<a href="/logout">Sign Out</a> | |||
</div> | |||
{{ end }} | |||
</nav> | |||
{{ if .GlobalData.Notice }} | |||
<div class="notice"> | |||
{{ .GlobalData.Notice }} | |||
<a href="{{.GlobalData.URL.String}}">Dismiss</a> | |||
</div> | |||
{{ end }} | |||
</header> |
@@ -3,13 +3,53 @@ | |||
<div class="page-wrap"> | |||
<aside> | |||
<a href="/mailbox/INBOX">Back to inbox</a> | |||
<ul> | |||
<li> | |||
<a href="/mailbox/INBOX">« Back to inbox</a> | |||
</li> | |||
</ul> | |||
</aside> | |||
<div class="container"> | |||
<main class="settings"> | |||
<form method="post"> | |||
<div class="action-group"> | |||
<label for="from">Full name</label> | |||
<input | |||
type="text" | |||
name="from" | |||
id="from" | |||
value="{{.Settings.From}}" | |||
/> | |||
</div> | |||
<div class="action-group"> | |||
<label for="signature">Message signature</label> | |||
<textarea | |||
name="signature" | |||
id="signature" | |||
rows="5" | |||
>{{.Settings.Signature}}</textarea> | |||
</div> | |||
<div class="action-group"> | |||
<label for="subscriptions">Subscribed folders</label> | |||
<select name="subscriptions" id="subscriptions" multiple> | |||
{{ $subs := .Subscriptions }} | |||
{{ range .Mailboxes }} | |||
{{ if and (ne .Name "INBOX") (not (.HasAttr "\\Noselect")) }} | |||
<option | |||
value="{{.Name}}" | |||
{{ if $subs.Has .Name }} | |||
selected | |||
{{ end }} | |||
>{{.Name}}</option> | |||
{{ end }} | |||
{{ end }} | |||
</select> | |||
</div> | |||
<div class="action-group"> | |||
<label for="messages_per_page">Messages per page</label> | |||
<input | |||
type="number" | |||
@@ -1,42 +1,28 @@ | |||
{{ define "mbox-link" }} | |||
{{ if not (.HasAttr "\\Noselect") }} | |||
<li {{ if .Active }}class="active"{{ end }}> | |||
<a href="{{.URL}}"> | |||
{{- if eq .Name "INBOX" -}} | |||
{{ if not (.Info.HasAttr "\\Noselect") }} | |||
<li {{ if .Info.Active }}class="active"{{ end }}> | |||
<a href="{{.Info.URL}}"> | |||
{{- if eq .Info.Name "INBOX" -}} | |||
Inbox | |||
{{- else -}} | |||
{{ .Name }} | |||
{{ .Info.Name }} | |||
{{- end -}} | |||
{{- if .HasAttr "\\HasChildren" }}/{{ end }} | |||
{{- if .Info.HasAttr "\\HasChildren" }}/{{ end }} | |||
</a> | |||
<!-- TODO: Rig up this form --> | |||
<button | |||
type="submit" | |||
name="subscribe" | |||
value="{{.Name}}" | |||
form="subscribe-form" | |||
{{ if eq .Name "INBOX" }} | |||
title="Unsubscribe" | |||
{{ else }} | |||
title="Subscribe" | |||
{{ end }} | |||
> | |||
{{ if eq .Name "INBOX" }} | |||
◉ | |||
{{ else }} | |||
○ | |||
{{ if .Status }} | |||
{{ if .Status.Unseen }} | |||
<span class="unseen">({{.Status.Unseen}})</span> | |||
{{ end }} | |||
{{ end }} | |||
</button> | |||
</li> | |||
{{ else }} | |||
<li class="noselect"> | |||
{{.Name}}{{- if .HasAttr "\\HasChildren" }}/{{ end }} | |||
{{.Info.Name}}{{- if .Info.HasAttr "\\HasChildren" }}/{{ end }} | |||
</li> | |||
{{ end }} | |||
{{ end }} | |||
{{ define "aside" }} | |||
<form id="subscribe-form" method="POST"></form> | |||
<aside> | |||
<ul> | |||
<!-- the logo image, dimensions 200x32 may be present or not --> | |||