A minimal email TUI where you read with Markdown and write in Neovim. neomd.ssp.sh/docs
email markdown neovim tui
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

update render

sspaeti e1ee651c f9a482e5

+156 -11
+3
go.mod
··· 14 14 ) 15 15 16 16 require ( 17 + github.com/JohannesKaufmann/html-to-markdown v1.6.0 // indirect 18 + github.com/PuerkitoBio/goquery v1.9.2 // indirect 17 19 github.com/alecthomas/chroma/v2 v2.14.0 // indirect 20 + github.com/andybalholm/cascadia v1.3.2 // indirect 18 21 github.com/atotto/clipboard v0.1.4 // indirect 19 22 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 20 23 github.com/aymerick/douceur v0.2.0 // indirect
+43
go.sum
··· 1 1 github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= 2 2 github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 3 + github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5x+rHJnb1ssNmqpLH/k= 4 + github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/0uOcYMaibytE4BUOQS8k78yPQ= 5 + github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE= 6 + github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= 3 7 github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= 4 8 github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 5 9 github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= 6 10 github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= 7 11 github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 8 12 github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 13 + github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= 14 + github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= 9 15 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= 10 16 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 11 17 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= ··· 38 44 github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= 39 45 github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= 40 46 github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= 47 + github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 48 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 41 49 github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= 42 50 github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 43 51 github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug= ··· 52 60 github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 53 61 github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 54 62 github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 63 + github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 64 + github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 65 + github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 55 66 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 56 67 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 57 68 github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= ··· 73 84 github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 74 85 github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 75 86 github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 87 + github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 88 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 76 89 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 77 90 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 78 91 github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 79 92 github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 80 93 github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= 81 94 github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= 95 + github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= 96 + github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= 97 + github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= 98 + github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 99 + github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 100 + github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 82 101 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 83 102 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 84 103 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= ··· 89 108 github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= 90 109 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 91 110 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 111 + golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 112 + golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= 113 + golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 92 114 golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 93 115 golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 94 116 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= ··· 97 119 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 98 120 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 99 121 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 122 + golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= 123 + golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 124 + golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 125 + golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= 126 + golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 100 127 golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= 101 128 golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 102 129 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 110 137 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 111 138 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 112 139 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 140 + golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 141 + golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 142 + golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 143 + golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 144 + golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 113 145 golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 114 146 golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 115 147 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 116 148 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 117 149 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 150 + golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= 151 + golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 152 + golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 153 + golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= 154 + golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 118 155 golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 119 156 golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 120 157 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 121 158 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 122 159 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 123 160 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 161 + golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 124 162 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 163 + golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 125 164 golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 126 165 golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 127 166 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= ··· 129 168 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 130 169 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 131 170 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 171 + gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 172 + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 173 + gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 174 + gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
+102 -6
internal/imap/client.go
··· 14 14 "sync" 15 15 "time" 16 16 17 + htmlmd "github.com/JohannesKaufmann/html-to-markdown" 17 18 imap "github.com/emersion/go-imap/v2" 18 19 "github.com/emersion/go-imap/v2/imapclient" 19 20 "github.com/emersion/go-message" ··· 364 365 } 365 366 } 366 367 368 + // Prefer HTML: newsletters and modern emails have rich HTML while the 369 + // text/plain part is typically a stripped dump with raw redirect URLs. 370 + // Fall back to plain text for plain-text-only emails (e.g. direct replies). 371 + if htmlText != "" { 372 + return htmlToMarkdown(htmlText) 373 + } 367 374 if plainText != "" { 368 - return plainText 375 + return normalizePlainText(plainText) 369 376 } 370 - if htmlText != "" { 371 - return stripHTML(htmlText) 377 + return "(no body)" 378 + } 379 + 380 + // htmlToMarkdown converts an HTML email body to Markdown so glamour can render 381 + // it with proper formatting: bold, italic, links, headings, lists, and image 382 + // placeholders (![alt](url) → [Image: alt] in the terminal). 383 + func htmlToMarkdown(h string) string { 384 + // Remove <wbr> tags and join newlines inside href/src attribute values. 385 + // Newsletter services (Substack, Mailchimp) insert line breaks inside URLs 386 + // for HTML rendering; html-to-markdown preserves them, breaking link syntax. 387 + h = regexp.MustCompile(`<wbr\s*/?>|&ZeroWidthSpace;`).ReplaceAllString(h, "") 388 + // Collapse whitespace (including newlines) inside href="..." and src="..." 389 + reAttr := regexp.MustCompile(`(?s)((?:href|src)=")(.*?)(")`) 390 + h = reAttr.ReplaceAllStringFunc(h, func(m string) string { 391 + parts := reAttr.FindStringSubmatch(m) 392 + if len(parts) != 4 { 393 + return m 394 + } 395 + clean := regexp.MustCompile(`\s+`).ReplaceAllString(parts[2], "") 396 + return parts[1] + clean + parts[3] 397 + }) 398 + 399 + converter := htmlmd.NewConverter("", true, nil) 400 + result, err := converter.ConvertString(h) 401 + if err != nil { 402 + return stripHTMLFallback(h) 403 + } 404 + return cleanMarkdown(strings.TrimSpace(result)) 405 + } 406 + 407 + // cleanMarkdown post-processes html-to-markdown output to remove newsletter 408 + // noise: invisible Unicode spacers, tracking pixels, bare URL lines, and 409 + // excessive blank lines. 410 + func cleanMarkdown(s string) string { 411 + // 1. Strip invisible Unicode characters used as email preheader spacers: 412 + // U+034F COMBINING GRAPHEME JOINER, U+00AD SOFT HYPHEN, 413 + // U+200B ZERO WIDTH SPACE, U+200C/D ZWNJ/ZWJ, U+FEFF BOM 414 + reInvis := regexp.MustCompile(`[\x{034F}\x{00AD}\x{200B}\x{200C}\x{200D}\x{FEFF}]+`) 415 + s = reInvis.ReplaceAllString(s, "") 416 + 417 + // 2. Remove empty image tags (tracking pixels): ![](...) or ![ ](...) 418 + reEmptyImg := regexp.MustCompile(`!\[\s*\]\([^)]*\)`) 419 + s = reEmptyImg.ReplaceAllString(s, "") 420 + 421 + // 3. Remove empty link anchors left behind when image-only links are cleaned: 422 + // [](url) or [ ](url) 423 + reEmptyLink := regexp.MustCompile(`\[\s*\]\([^)]*\)`) 424 + s = reEmptyLink.ReplaceAllString(s, "") 425 + 426 + // 4. Remove lines that are only a bare URL (no surrounding text). 427 + // These come from <a href="url"><img/></a> after the image is stripped, 428 + // or from Substack's share/subscribe buttons whose text was an image. 429 + reBareURL := regexp.MustCompile(`(?m)^https?://\S+$`) 430 + s = reBareURL.ReplaceAllString(s, "") 431 + 432 + // 5. Remove lines that are only whitespace — including U+00A0 (&nbsp;) which 433 + // &nbsp; gets decoded to and is NOT matched by \s in Go's regexp. 434 + reWhitespaceOnly := regexp.MustCompile("(?m)^[ \t\u00A0\u202F\u2003\u2009]+$") 435 + s = reWhitespaceOnly.ReplaceAllString(s, "") 436 + 437 + // 6. Collapse 3+ consecutive blank lines to 2 438 + reExcessBlank := regexp.MustCompile(`\n{4,}`) 439 + s = reExcessBlank.ReplaceAllString(s, "\n\n\n") 440 + 441 + return strings.TrimSpace(s) 442 + } 443 + 444 + // normalizePlainText prepares a plain-text email body for glamour rendering. 445 + // Glamour treats single \n as paragraph continuation (Markdown spec), so bare 446 + // line breaks in plain-text emails collapse into run-on text. We add two 447 + // trailing spaces before each single newline, which Markdown treats as a hard 448 + // line break, preserving the original layout. 449 + func normalizePlainText(s string) string { 450 + s = strings.ReplaceAll(s, "\r\n", "\n") 451 + s = strings.ReplaceAll(s, "\r", "\n") 452 + // Add hard-break markers before single newlines (not before blank lines). 453 + var b strings.Builder 454 + lines := strings.Split(s, "\n") 455 + for i, line := range lines { 456 + b.WriteString(line) 457 + if i < len(lines)-1 { 458 + next := lines[i+1] 459 + if next == "" { 460 + // Blank line: keep as paragraph separator (no trailing spaces needed) 461 + b.WriteByte('\n') 462 + } else { 463 + // Single newline: add trailing double-space for Markdown hard break 464 + b.WriteString(" \n") 465 + } 466 + } 372 467 } 373 - return "(no body)" 468 + return strings.TrimSpace(b.String()) 374 469 } 375 470 376 - // stripHTML removes HTML tags, leaving readable plain text. 377 - func stripHTML(h string) string { 471 + // stripHTMLFallback is the last-resort plain-text extractor when the 472 + // html-to-markdown converter fails. 473 + func stripHTMLFallback(h string) string { 378 474 reBlock := regexp.MustCompile(`(?is)<(style|script)[^>]*>.*?</(style|script)>`) 379 475 h = reBlock.ReplaceAllString(h, "") 380 476 reNewline := regexp.MustCompile(`(?i)</(p|div|br|li|tr|h[1-6]|blockquote)>`)
+7 -4
internal/render/markdown.go
··· 6 6 ) 7 7 8 8 // ToANSI renders markdown as ANSI-styled terminal output for the reader view. 9 - // theme should be "dark", "light", or "auto". 10 - func ToANSI(markdown, theme string) (string, error) { 9 + // theme should be "dark", "light", or "auto". width is the terminal column 10 + // count; pass 0 to use the default (80). 11 + func ToANSI(markdown, theme string, width int) (string, error) { 11 12 if theme == "" { 12 13 theme = "dark" 13 14 } 15 + if width <= 0 { 16 + width = 80 17 + } 14 18 r, err := glamour.NewTermRenderer( 15 19 glamour.WithStylePath(theme), 16 - glamour.WithWordWrap(100), 20 + glamour.WithWordWrap(width), 17 21 ) 18 22 if err != nil { 19 - // Fall back to notty (no styling) 20 23 return glamour.Render(markdown, "notty") 21 24 } 22 25 return r.Render(markdown)
+1 -1
internal/ui/reader.go
··· 20 20 func loadEmailIntoReader(vp *viewport.Model, email *imap.Email, body, theme string, width int) error { 21 21 header := renderEmailHeader(email, width) 22 22 23 - rendered, err := render.ToANSI(body, theme) 23 + rendered, err := render.ToANSI(body, theme, width) 24 24 if err != nil { 25 25 rendered = body // fall back to raw markdown 26 26 }