~myrrc/myrrc.dev

906322ddc3ec60702f4296d667a04f44cd584133 — Mikhail Kot 2 months ago 6db7637 master
nfmd article, plain css
7 files changed, 194 insertions(+), 323 deletions(-)

M config.toml
A content/2024-08-20-nfmd.md
D sass/style.sass
A static/nfmd-0.3.deb
A static/style.css
M templates/base.html
M templates/page.html
M config.toml => config.toml +0 -1
@@ 2,7 2,6 @@ base_url = "https://myrrc.dev"
title = "Mikhail Kot"
author = "Mikhail Kot"
description = "Mikhail Kot's blog"
compile_sass = true
minify_html = true
generate_feed = true
feed_filename = "rss.xml"

A content/2024-08-20-nfmd.md => content/2024-08-20-nfmd.md +118 -0
@@ 0,0 1,118 @@
+++
title = "NFMD: A news feed mailer daemon"
+++

NFMD is a program which grabs news feeds from a specified file and sends daily updates via e-mail.
<!-- more -->
It parses RSS/Atom feeds natively, and for other sources you can pass a service that converts
data to RSS i.e. [RSS hub](https://docs.rsshub.app/) instances.

Links:
- [nfmd-0.3.deb](/nfmd-0.3.deb) for Debian 12 Bookworm
- [sources](https://git.sr.ht/~myrrc/nfmd)

## Usage

- Get your favourite feeds in a config text file:
```
18:05 email@foogle.com
[header]
<style>#video { display: none; }</style>
[schemes]
https://t.me https://rsshub.app/telegram/channel
[urls]
# This page provides RSS natively
https://myrrc.dev/rss.xml # This is an inline comment
# This page doesn't so we'll query rsshub.app
https://t.me/red_spades
```

- Install deb package or compile from source and copy binary to a server
- Run `nfmd -c *config* set`

## Why
I follow around 100 Telegram channels and also other sources like YouTube, Reddit, as well as many
small websites. At some point I decided I want everything in a single place. Although most
people I know use Telegram for that purpose, messenger style
doesn't seem reasonable due to continuous distraction. Even if you mute notifications and archive
chats, you still have items to click through. This is solved in Telegram mobile with
swipe up gesture, taking you to next channel or dialog, however, on desktop there is no such
functionality. Another idea is to separate messaging and news reading as for me these are
two different activities.

Therefore another solution was searched. The easy way was to convert everything to RSS. There
are many clever apps like [ratt](https://git.sr.ht/~ghost08/ratt) but the need to
write manual rules was a bit frustrating, so I discovered [RSS hub](https://docs.rsshub.app/).
A simple and elegant one - pass a link, get RSS. If you don't want to rely on public instances,
host your own.

Next bike-shedding step was to find an RSS reader. Most of my computer time except for the browser
is spent in the terminal. Terminal readers like [photon](https://git.sr.ht/~ghost08/photon/) may be
awesome, and I tried using them, but there still are downsides:
- You need to have the reader up and running in order to fetch new data.
- Rss readers don't usually have a good way to aggregate data per day.
- In rare cases when you need to read from mobile, you need another app.

The final decision was to use e-mail. It's simple, it handles unread items, you can perform
fetching and filtering on the server in case your computer is turned off.

## How
The initial plan included writing a daemon which would wake up at specified time, fetch
sources from a TOML file in parallel, filter them to get last ones only, convert to
HTML and send an email to me.
I chose Rust, picked up some libraries and made a prototype. It was... big, around 20MB
total. Playing with musl builds, removing libunwind, no luck.
Such a simple program definitely shouldn't be that big.

So I reconsidered the functionality:
- Removed all authentication. I run a Postfix/Dovecot e-mail server so program
  can just send to localhost.
- Removed complex wakeup rules. I realised I really wanted news only one time a day
- Removed tokio and parallelism. One consideration was the complexity - even if serial
  execution is way slower[^1], I need the program to be executed one time a day only. 
  Second - multiple concurrent requests in a short period can lead to being 
  blocked by source servers.
  Third - my server has only one core, so many threads wouldn't bring much benefit.
- Removed TOML in favor of plain text. With a list of URLs, any markup is excessive.
- Replaced manual demonization with cron. In most systems cron or anacron is available,
  and you can use it for 1) reliable wakeups, and 2) restarts if program crashed.
  Also, a long-running program is something that may cause pain if there's a memory leak
  or some other bug.

After refactoring, the following dependencies were left:
- data fetching via [curl](https://crates.io/crates/curl) bindings.
- email sending via [lettre](https://crates.io/crates/lettre)
- data parsing via [rss](https://crates.io/crates/rss) and
  [atom](https://crates.io/crates/atom_syndication)

Then I read curl can send SMTP requests, however, the Rust crate didn't have this,
so I decided to switch to C[^2].

## Bells and whistles

The next prototype was way faster and smaller, around 14kb, but still didn't feel ideally
comfortable. I added missing functionality:
- Comments after URLs for tagging various sources
- Prefix replacement so that you could add `https://t.me/tokacomics` and not
 `https://rsshub.rss.tips/telegram/channel/tokacomics`.
- E-mail styling via inline CSS: customizable, my styles specifically were width
  formatting mostly:
```
a { text-decoration: none; }
video { display:none; }
body,img { width: 600px; }
body {
    padding-left:25%;
    padding-right:25%;
}
```

## Packaging

Debian packages as they're 1) easy to build via pbuilder 2) easy to install on target server 3) 
(relatively) easy to write.

[^1]: Not necessarily true as such programs are usually io-bound

[^2]: Why not C++? Most easily discovered libraries for my task are C ones, and the code would be
 a mix of two languages which is bad for readability.

D sass/style.sass => sass/style.sass +0 -315
@@ 1,315 0,0 @@
@charset "utf-8"
$size:   16px !default
$weight: 400 !default
$small-font-size:  $size * 0.875 !default
$base-line-height: 1.5 !default
$spacing: 30px !default
$table-text-align: left !default
$content: 800px !default
$lm-brand-color: #828282 !default
$lm-brand-color-light: lighten($lm-brand-color, 40%) !default
$lm-brand-color-dark: darken($lm-brand-color, 25%) !default
$lm-site-title-color: $lm-brand-color-dark !default
$lm-text-color: #111111 !default
$lm-background-color: #fdfdfd !default
$lm-code-background-color: #eeeeff !default
$lm-link: #2a7ae2 !default
$lm-link-visited: darken($lm-link, 15%) !default
$lm-link-hover: $lm-text-color !default
$lm-border-color-01: $lm-brand-color-light !default
$lm-border-color-02: lighten($lm-brand-color, 35%) !default
$lm-border-color-03: $lm-brand-color-dark !default
$lm-table-text-color: lighten($lm-text-color, 18%) !default
$lm-table-zebra-color: lighten($lm-brand-color, 46%) !default
$lm-table-header-bg-color: lighten($lm-brand-color, 43%) !default
$lm-table-header-border: lighten($lm-brand-color, 37%) !default
$lm-table-border-color: $lm-border-color-01 !default
$dm-brand-color: #999999 !default
$dm-brand-color-light: lighten($dm-brand-color, 5%) !default
$dm-brand-color-dark: darken($dm-brand-color, 35%) !default
$dm-site-title-color: $dm-brand-color-light !default
$dm-text-color: #bbbbbb !default
$dm-background-color: #181818 !default
$dm-code-background-color: #212121 !default
$dm-link: #79b8ff !default
$dm-link-visited: $dm-link !default
$dm-link-hover: $dm-text-color !default
$dm-border-color-01: $dm-brand-color-dark !default
$dm-border-color-02: $dm-brand-color-light !default
$dm-border-color-03: $dm-brand-color !default
$dm-table-text-color: $dm-text-color !default
$dm-table-zebra-color: lighten($dm-background-color, 4%) !default
$dm-table-header-bg-color: lighten($dm-background-color, 10%) !default
$dm-table-header-border: lighten($dm-background-color, 21%) !default
$dm-table-border-color: $dm-border-color-01 !default

:root
  --v: #{$lm-brand-color}
  --light: #{$lm-brand-color-light}
  --dark: #{$lm-brand-color-dark}
  --title: #{$lm-site-title-color}
  --text: #{$lm-text-color}
  --bg: #{$lm-background-color}
  --code: #{$lm-code-background-color}
  --link: #{$lm-link}
  --linkv: #{$lm-link-visited}
  --linkh: #{$lm-link-hover}
  --b1: #{$lm-border-color-01}
  --b2: #{$lm-border-color-02}
  --b3: #{$lm-border-color-03}
  --tbl: #{$lm-table-text-color}
  --tblz: #{$lm-table-zebra-color}
  --tbl-hdr-bg: #{$lm-table-header-bg-color}
  --tbl-hdr-border: #{$lm-table-header-border}
  --tbl-border: #{$lm-table-border-color}

@media (prefers-color-scheme: dark)
  :root
    --v: #{$dm-brand-color}
    --light: #{$dm-brand-color-light}
    --dark: #{$dm-brand-color-dark}
    --title: #{$dm-site-title-color}
    --text: #{$dm-text-color}
    --bg: #{$dm-background-color}
    --code: #{$dm-code-background-color}
    --link: #{$dm-link}
    --linkv: #{$dm-link-visited}
    --linkh: #{$dm-link-hover}
    --b1: #{$dm-border-color-01}
    --b2: #{$dm-border-color-02}
    --b3: #{$dm-border-color-03}
    --tbl: #{$dm-table-text-color}
    --tblz: #{$dm-table-zebra-color}
    --tbl-hdr-bg: #{$dm-table-header-bg-color}
    --tbl-hdr-border: #{$dm-table-header-border}
    --tbl-border: #{$dm-table-border-color}

$brand-color: var(--v)
$brand-color-light: var(--light)
$brand-color-dark: var(--dark)
$site-title-color: var(--title)
$text-color: var(--text)
$background-color: var(--bg)
$code-background-color: var(--code)
$link: var(--link)
$link-visited: var(--linkv)
$link-hover: var(--linkh)
$border-color-01: var(--b1)
$border-color-02: var(--b2)
$border-color-03: var(--b3)
$table-text-color: var(--tbl)
$table-zebra-color: var(--tblz)
$table-header-bg-color: var(--tbl-hdr-bg)
$table-header-border: var(--tbl-hdr-border)
$table-border-color: var(--tbl-border)

html
  font-size: $size

body, h1, h2, h3, h4, h5, h6, p, blockquote, pre, hr, dl, dd, ol, ul, figure
  margin: 0
  padding: 0

body
  font: $weight #{$size}/#{$base-line-height} sans-serif
  color: $text-color
  background-color: $background-color
  -webkit-text-size-adjust: 100%
  font-feature-settings: "kern" 1
  font-kerning: normal
  display: flex
  min-height: 100vh
  flex-direction: column
  overflow-wrap: break-word

h1, h2, h3, h4, h5, h6, p, blockquote, pre, ul, ol, dl, figure, details, %vertical-rhythm
  margin-bottom: $spacing / 2

hr
  margin-top: $spacing
  margin-bottom: $spacing

main
  display: block

img
  max-width: 100%
  vertical-align: middle

figure > img
  display: block

figcaption
  font-size: $small-font-size

ul, ol
  margin-left: $spacing

li > ul, li > ol
  margin-bottom: 0

h1, h2, h3, h4, h5, h6
  font-weight: $weight

a
  color: $link
  text-decoration: none

  &:visited
    color: $link-visited
  &:hover
    color: $link-hover
    text-decoration: underline

blockquote
  border-left: 4px solid $border-color-03
  padding-left: $spacing / 2

  > :last-child
    margin-bottom: 0

pre, code
  font-family: monospace
  font-size: 0.9375em
  border: 1px solid $border-color-01
  border-radius: 3px
  background-color: $code-background-color

code
  padding: 1px 5px

pre
  padding: 8px 12px
  overflow-x: auto

  > code
    border: 0
    padding-right: 0
    padding-left: 0

.highlight
  border-radius: 3px
  background: $code-background-color
  @extend %vertical-rhythm

.wrapper
  max-width: $content - $spacing
  margin-right: auto
  margin-left: auto
  padding-right: $spacing / 2
  padding-left: $spacing / 2
  @extend %clearfix

  @media screen and (min-width: $content)
    max-width: $content - $spacing * 2
    padding-right: $spacing
    padding-left: $spacing

%clearfix:after
  content: ""
  display: table
  clear: both

table
  margin-bottom: $spacing
  width: 100%
  text-align: $table-text-align
  color: $table-text-color
  border-collapse: collapse
  border: 1px solid $table-border-color

  tr:nth-child(even)
      background-color: $table-zebra-color
  th, td
    padding: ($spacing / 3) ($spacing / 2)
  th
    background-color: $table-header-bg-color
    border: 1px solid $table-header-border
  td
    border: 1px solid $table-border-color

  @media screen and (max-width: $content)
    display: block
    overflow-x: auto
    -webkit-overflow-scrolling: touch
    -ms-overflow-style: -ms-autohiding-scrollbar

.site-header
  min-height: $spacing * 1.865
  line-height: $base-line-height * $size * 2.25
  position: relative

.site-title
  font-size: 1.625rem
  font-weight: 300
  letter-spacing: -1px
  margin-bottom: 0
  float: left

  &, &:visited
    color: $site-title-color

.popcat
  margin-left: $spacing / 5
  margin-top: -$spacing / 5
  border-radius: 15%

.page-content
  padding: 0
  flex: 1 0 auto

.page-heading
  font-size: 2rem

.post-link
  display: block
  font-size: 1.5rem

.post-header
  margin-bottom: $spacing / 2

.post-header h1, .post-content h1
  font-size: 2.625rem
  letter-spacing: -1px
  line-height: 1.15

  @media screen and (min-width: $content)
    font-size: 2.625rem

.post-content
  margin-bottom: $spacing

  h1, h2, h3, h4, h5, h6
    margin-top: $spacing
  h2
    font-size: 1.75rem

    @media screen and (min-width: $content)
      font-size: 2rem
  h3
    font-size: 1.375rem

    @media screen and (min-width: $content)
      font-size: 1.625rem
  h4
    font-size: 1.25rem
  h5
    font-size: 1.125rem
  h6
    font-size: 1.0625rem

.quote
  text-align: right

footer, .section-cut
  text-align: center

footer
  padding-bottom: 10px

.lang
  float: right
.row
  display: flex
.col
  flex: 50%

A static/nfmd-0.3.deb => static/nfmd-0.3.deb +0 -0
A static/style.css => static/style.css +71 -0
@@ 0,0 1,71 @@
:root{
--title: #424242; --text: #111; --bg: #fdfdfd; --code: #eef;
--link: #2a7ae2; --linkv: #1756a9; --linkh: #111;
--b1: #e8e8e8; --b2: #424242;
--tbl: #3f3f3f; --tblz: #f7f7f7; --th-bg: #f0f0f0;
--th-border: #e0e0e0; --tbl-border: #e8e8e8
}
@media (prefers-color-scheme: dark){:root{
--title: #a6a6a6; --text: #bbb; --bg: #181818; --code: #212121;
--link: #79b8ff; --linkv: #79b8ff; --linkh: #bbb;
--b1: #404040; --b2: #999;
--tbl: #bbb; --tblz: #222; --th-bg: #323232;
--th-border: #4e4e4e; --tbl-border: #404040
}}
html{font-size:16px}
body,h1,h2,h3,h4,p,blockquote,pre,hr,dl,dd,ol,ul,figure{margin:0;padding:0}
body{
 color:var(--text); background-color:var(--bg);
 font:400 16px/1.5 sans-serif;font-feature-settings:"kern" 1;font-kerning:normal;
 display:flex;min-height:100vh;flex-direction:column;overflow-wrap:break-word
}
h1,h2,h3,h4,p,blockquote,pre,ul,ol,dl,figure,details{margin-bottom:15px}
hr{margin-top:30px;margin-bottom:30px}
main{display:block}
img{max-width:100%;vertical-align:middle}
figure>img{display:block}
figcaption{font-size:14px}
ul,ol{margin-left:30px}
li>ul,li>ol{margin-bottom:0}
h1,h2,h3,h4{font-weight:400}
a{color:var(--link);text-decoration:none}
a:visited{color:var(--linkv)}
a:hover{color:var(--linkh);text-decoration:underline}
blockquote{border-left:4px solid var(--b2);padding-left:15px}
blockquote>:last-child{margin-bottom:0}
pre,code{font-family:monospace;font-size:.9375em;border:1px solid var(--b1);border-radius:3px;background-color:var(--code)}
code{padding:1px 5px}
pre{padding:8px 12px;overflow-x:auto}
pre>code{border:0;padding-right:0;padding-left:0}
.wrapper{max-width:740px;margin-right:auto;margin-left:auto;padding-right:30px;padding-left:30px}
.wrapper:after{content:"";display:table;clear:both}
table{margin-bottom:30px;width:100%;text-align:left;color:var(--tbl);border-collapse:collapse;border:1px solid var(--tbl-border)}
table tr:nth-child(even){background-color:var(--tblz)}
table th,table td{padding:10px 15px}
table th{background-color:var(--th-bg);border:1px solid var(--th-border)}
table td{border:1px solid var(--tbl-border)}
@media screen and (max-width: 800px){table{display:block;overflow-x:auto}}
.site-header{min-height:55.95px;line-height:54px;position:relative}
.site-title{font-size:1.625rem;font-weight:300;letter-spacing:-1px;margin-bottom:0;float:left}
.site-title,.site-title:visited{color:var(--title)}
.popcat{margin-left:6px;margin-top:-6px;border-radius:15%}
.page-content{padding:0;flex:1 0 auto}
.page-heading{font-size:2rem}
.post-link{display:block;font-size:1.5rem}
.post-header{margin-bottom:15px}
.post-header h1,.post h1{font-size:2.625rem;letter-spacing:-1px;line-height:1.15}
.post{margin-bottom:30px}
.post h1,.post h2,.post h3,.post h4{margin-top:30px}
.post h2{font-size:1.75rem}
.post h3{font-size:1.375rem}
.post h4{font-size:1.25rem}
@media screen and (min-width: 800px){
 .post h2{font-size:2rem}
 .post h3{font-size:1.625rem}
}
.quote{text-align:right}
footer,.section-cut{text-align:center}
footer{padding-bottom:10px}
.lang{float:right}
.row{display:flex}
.col{flex:50%}

M templates/base.html => templates/base.html +4 -4
@@ 1,13 1,13 @@
<!DOCTYPE html>
<html lang="en">
<html>
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <meta name="description" content="{{ config.description }}">
        <meta name="referrer" content="no-referrer-when-downgrade">
        <link rel="stylesheet" href="{{ get_url(path="style.css", trailing_slash=false) | safe }}">
        <link rel="icon" href="{{ get_url(path="favicon.ico") | safe }}">
        <link rel="stylesheet" href="/style.css">
        <link rel="icon" href="/favicon.ico">
        <link rel="alternate" type="application/rss+xml" title="{{ config.title }}" href="{{ get_url(path=config.feed_filename) | safe }}">
        {% block head %} {% endblock %}
    </head>


@@ 15,7 15,7 @@
        <header class="site-header">
            <div class="wrapper">
                <a class="site-title" href="{{ config.base_url }}"> {{ config.title }} </a>
                <img class="popcat" src="{{ get_url(path="popcat.gif") }}" alt="popcat"/>
                <img class="popcat" src="/popcat.gif" alt="popcat"/>
                {% block lang %} {% endblock %}
            </div>
        </header>

M templates/page.html => templates/page.html +1 -3
@@ 31,7 31,5 @@
    </ol>
    {% endif %}
</header>
<main>
    <div class="post-content"> {{ page.content | safe }} </div>
</main>
<main class="post"> {{ page.content | safe }} </main>
{% endblock %}