# Diff of /2024/01/29/feedletter-tutorial/index.html

## Revision acb84c08c1bbcc83036b9cad70f6a3c5d60865c4 => current

Line | acb84c08c1bbcc83036b9cad70f6a3c5d60865c4 | current
--- | --- | ---
1 | <!DOCTYPE html> | <!DOCTYPE html>
2 | <html> | <html>
3 | <head> | <head>
⋮ | ⋮ | ⋮
40 | <!-- end icons / favicons --> | <!-- end icons / favicons -->
41 |  |
42 | <link rel="alternate" type="application/rss+xml" title="tech.interfluidity.com updates" href="../../../../feed/index.rss"> | <link rel="alternate" type="application/rss+xml" title="tech.interfluidity.com updates" href="../../../../feed/index.rss">
43 |  | <link rel="alternate" type="application/x-all-item-rss+xml" title="tech.interfluidity.com - all items" href="https://tech.interfluidity.com/all-item-feed/index.rss">
44 | <link rel="alternate" type="application/rss+xml" title="interfluidity - all blogs" href="https://www.interfluidity.com/unify-rss/all-blogs.rss"> | <link rel="alternate" type="application/rss+xml" title="interfluidity - all blogs" href="https://www.interfluidity.com/unify-rss/all-blogs.rss">
45 | <link rel="alternate" type="application/rss+xml" title="interfluidity - all blogs and microblogs" href="https://www.interfluidity.com/unify-rss/all-blogs-and-microblogs.rss"> | <link rel="alternate" type="application/rss+xml" title="interfluidity - all blogs and microblogs" href="https://www.interfluidity.com/unify-rss/all-blogs-and-microblogs.rss">
46 |  | <link rel="alternate" type="application/x-single-item-rss+xml" title="Feedletter tutorial" href="index.rss">
47 | <link rel="stylesheet" href="../../../../css/style.css"> | <link rel="stylesheet" href="../../../../css/style.css">
48 | <link rel="stylesheet" href="../../../../css/highlightjs/steve-night-owl.css"><!-- theme for highlight.js --> | <link rel="stylesheet" href="../../../../css/highlightjs/steve-night-owl.css"><!-- theme for highlight.js -->
49 | <script src="../../../../js/highlight/highlight.min.js"></script> | <script src="../../../../js/highlight/highlight.min.js"></script>
⋮ | ⋮ | ⋮
84 | <h1><a href="index.html">Feedletter tutorial</a></h1> | <h1><a href="index.html">Feedletter tutorial</a></h1>
85 | <hr class="below-title"> | <hr class="below-title">
86 | </div> | </div>
87 |  | <div class="update-prepend rss-description-exclude">
88 |  | <em> ➣ This post was meaningfully revised at 2026-06-11 @ 12:30 PM EDT. The previous revision is <a href="index-oldcommit-acb84c08c1bbcc83036b9cad70f6a3c5d60865c4.html">here</a>, diff <a href="index-diff-acb84c08c1bbcc83036b9cad70f6a3c5d60865c4-to-current.html">here</a>. (See <a href="index.html#update-history">update history</a>.) </em>
89 |  | <hr>
90 |  | </div>
91 | <div class="entry-body"> | <div class="entry-body">
92 | <div class="flexmark markdown"> | <div class="flexmark markdown">
93 | <p>I've been working for some time on a service to turn RSS feeds into e-mail newsletters, which I've called <a href="https://github.com/swaldman/feedletter"><em>feedletter</em></a>.</p> | <p>I've been working for some time on a service to turn RSS feeds into e-mail newsletters, which I've called <a href="https://github.com/swaldman/feedletter"><em>feedletter</em></a>.</p>
⋮ | ⋮ | ⋮
120 | <li><a href="#conclusion">Conclusion</a></li> | <li><a href="#conclusion">Conclusion</a></li>
121 | </ol> | </ol>
122 | <h2><a href="#1-set-up-a-server-with-a-dns-name" id="1-set-up-a-server-with-a-dns-name" name="1-set-up-a-server-with-a-dns-name" class="anchorlink"></a>1. Set up a server with a DNS name</h2> | <h2><a href="#1-set-up-a-server-with-a-dns-name" id="1-set-up-a-server-with-a-dns-name" name="1-set-up-a-server-with-a-dns-name" class="anchorlink"></a>1. Set up a server with a DNS name</h2>
123 | <p>We launch a "droplet" from <a href="https://www.digitalocean.com/">Digital Ocean</a>. You can use whatever Linux flavor you like. We'll pick the latest Ubuntu.</p><img alt="Screenshot of Digital Ocean droplet setup" src="droplet-setup.png" style="width: 100%;"> | <p>We launch a "droplet" from <a href="https://www.digitalocean.com/">Digital Ocean</a>. You can use whatever Linux flavor you like. We'll pick the latest Ubuntu.</p>
124 | <p>And we go ahead and give it a name.</p><img alt="Screenshot of FastMail DNS setup" src="dns-setup.png" style="width: 100%;"> | <img alt="Screenshot of Digital Ocean droplet setup" src="droplet-setup.png" style="width: 100%;">
125 |  | <p>And we go ahead and give it a name.</p>
126 |  | <img alt="Screenshot of FastMail DNS setup" src="dns-setup.png" style="width: 100%;">
127 | <h2><a href="#2-download-dependencies" id="2-download-dependencies" name="2-download-dependencies" class="anchorlink"></a>2. Download dependencies</h2> | <h2><a href="#2-download-dependencies" id="2-download-dependencies" name="2-download-dependencies" class="anchorlink"></a>2. Download dependencies</h2>
128 | <p>We login as root to our new droplet (however we've configured that), and download a bunch of stuff we'll need:</p> | <p>We login as root to our new droplet (however we've configured that), and download a bunch of stuff we'll need:</p>
129 | <pre><code class="language-plaintext"># apt install postgresql | <pre><code class="language-plaintext"># apt install postgresql
⋮ | ⋮ | ⋮
391 | $ cd feedletter-local | $ cd feedletter-local
392 | $ ./feedletter db-init | $ ./feedletter db-init
393 | </code></pre> | </code></pre>
394 |  | <div class="note">
395 |  | <p>At this point, if you are running feedletter >=0.2.5 it's also a good idea to run the following command:</p>
396 |  | <pre><code class="language-plaintext">$ ./feedletter generate-starter-untemplates
397 |  | </code></pre>
398 |  | <p>That will make it simpler, going forward, to modify the look and feel of e-mails that you generate — newsletters, confirmation requests, etc.</p>
399 |  | <p>If you don't run this command, everything will still be fine, but you'll need to do a bit more work if you want to change the style of your e-mails.</p>
400 |  | <p>Be sure to perform this step <em>before <a href="#12-define-subscribables-to-feeds">defining any subscribables</a></em>. Any subscribables you define before generating the starter templates will use compiled-in untemplates, and you'll have to define new untemplates and update your subscriptions to use them if you want to change the styling. (See the notes in <a href="#15-tweak-the-newsletter-styles">Section 15</a> of this tutorial.)</p>
401 |  | <p>(Update added 2026-06-11)</p>
402 |  | </div>
403 | <h2><a href="#10-perform-in-database-configuration" id="10-perform-in-database-configuration" name="10-perform-in-database-configuration" class="anchorlink"></a>10. Perform in-database configuration</h2> | <h2><a href="#10-perform-in-database-configuration" id="10-perform-in-database-configuration" name="10-perform-in-database-configuration" class="anchorlink"></a>10. Perform in-database configuration</h2>
404 | <p>Some of <em>feedletter</em>'s config sits in the secrets file, but much lives in the application's database itself.</p> | <p>Some of <em>feedletter</em>'s config sits in the secrets file, but much lives in the application's database itself.</p>
405 | <p>We can see <em>feedletter</em>'s current (default) configuration simply by running</p> | <p>We can see <em>feedletter</em>'s current (default) configuration simply by running</p>
⋮ | ⋮ | ⋮
525 | <p>So, by default, this feed will wait at least 30 minutes before notifying, and require a post to have been stable for at least 15 minutes. After 180 minutes, it will be considered final no matter what. It will be checked every approximately 10 minutes.</p> | <p>So, by default, this feed will wait at least 30 minutes before notifying, and require a post to have been stable for at least 15 minutes. After 180 minutes, it will be considered final no matter what. It will be checked every approximately 10 minutes.</p>
526 | <p>If you don't like these values, you can change them any time with the <code>./feedletter alter-feed</code> command.</p> | <p>If you don't like these values, you can change them any time with the <code>./feedletter alter-feed</code> command.</p>
527 | <div class="note"> | <div class="note">
528 | I am not republishing these blogs without permission. That would be icky. I'm using these feeds for demonstration purposes. I'll be their only e-mail subscriber. | I am not republishing these blogs without permission. That would be icky. I'm using these feeds for demonstration purposes. I'll be their only e-mail subscriber.
529 | <p>By the time you read this tutorial, <code>play.feedletter.org</code> will have been sadly retired.</p> | <p>By the time you read this tutorial, <code>play.feedletter.org</code> will have been sadly retired.</p>
530 | </div> | </div>
531 | <p>Let's add another feed to watch, Atrios' <i>Eschaton</i> blog, whose feed URL is <a href="https://www.eschatonblog.com/feeds/posts/default?alt=rss"><code>https://www.eschatonblog.com/feeds/posts/default?alt=rss</code></a>. I'm just going to stick with the default timings for now:</p> | <p>Let's add another feed to watch, Atrios' <i>Eschaton</i> blog, whose feed URL is <a href="https://www.eschatonblog.com/feeds/posts/default?alt=rss"><code>https://www.eschatonblog.com/feeds/posts/default?alt=rss</code></a>. I'm just going to stick with the default timings for now:</p>
⋮ | ⋮ | ⋮
725 | <input name="main-submit" value="Subscribe!" type="submit"> | <input name="main-submit" value="Subscribe!" type="submit">
726 | </form> | </form>
727 | </code></pre> | </code></pre>
728 | <div class="note"> | <div class="note" <p>
729 | <p>As of <em>feedletter v0.0.8</em>, you can use <code>method="POST"</code> in subscribe forms.</p> | As of <em>feedletter v0.0.8</em>, you can use <code>method="POST"</code> in subscribe forms.
730 |  | <p></p>
731 | <p>Using <code>method="GET"</code> (and therefore also simulating form submission by pasting a URL) remain supported as well.</p> | <p>Using <code>method="GET"</code> (and therefore also simulating form submission by pasting a URL) remain supported as well.</p>
732 | </div> | </div>
733 | <p>(You can see live examples of <em>feedletter</em> subscription forms on the <a href="../../../../subscribe.html">subscribe page</a> of this site!)</p> | <p>(You can see live examples of <em>feedletter</em> subscription forms on the <a href="../../../../subscribe.html">subscribe page</a> of this site!)</p>
734 | <p>We will fake hitting the form above just by pasting the following URL into our browser:</p> | <p>We will fake hitting the form above just by pasting the following URL into our browser:</p>
735 | <pre><code class="language-plaintext">https://play.feedletter.org/v0/subscription/create?subscribableName=lgm&addressPart=swaldman@mchange.com&displayNamePart=Steve | <pre><code class="language-plaintext">https://play.feedletter.org/v0/subscription/create?subscribableName=lgm&addressPart=swaldman@mchange.com&displayNamePart=Steve
736 | </code></pre> | </code></pre>
737 | <p>We are immediately informed of our success: <img alt="Screenshot of 'Subscription Created' page" src="subscription-created.png" style="width: 100%;"> And, you've got mail!</p><img alt="Screenshot of e-mail requesting subscription confirmation" src="please-confirm.png" style="width: 100%;"> | <p>We are immediately informed of our success: <img alt="Screenshot of 'Subscription Created' page" src="subscription-created.png" style="width: 100%;"> And, you've got mail!</p>
738 | <p>We hit the confirm link and we're done:</p><img alt="Screenshot of 'Subscription confirmed!' page" src="confirmed.png" style="width: 100%;"> | <img alt="Screenshot of e-mail requesting subscription confirmation" src="please-confirm.png" style="width: 100%;">
739 |  | <p>We hit the confirm link and we're done:</p>
740 |  | <img alt="Screenshot of 'Subscription confirmed!' page" src="confirmed.png" style="width: 100%;">
741 | <p>We've made two more subscribables we'll want to test, whose let's-fake-a-form URLs will be</p> | <p>We've made two more subscribables we'll want to test, whose let's-fake-a-form URLs will be</p>
742 | <pre><code class="language-plaintext">https://play.feedletter.org/v0/subscription/create?subscribableName=lgm-daily&addressPart=swaldman@mchange.com&displayNamePart=Steve | <pre><code class="language-plaintext">https://play.feedletter.org/v0/subscription/create?subscribableName=lgm-daily&addressPart=swaldman@mchange.com&displayNamePart=Steve
743 | https://play.feedletter.org/v0/subscription/create?subscribableName=atrios-three&addressPart=swaldman@mchange.com&displayNamePart=Steve | https://play.feedletter.org/v0/subscription/create?subscribableName=atrios-three&addressPart=swaldman@mchange.com&displayNamePart=Steve
⋮ | ⋮ | ⋮
838 | Watching for changes to 14 paths and 9 other values... (Enter to re-run, Ctrl-C to exit) | Watching for changes to 14 paths and 9 other values... (Enter to re-run, Ctrl-C to exit)
839 | Starting single-page webserver on interface '0.0.0.0', port 45612... | Starting single-page webserver on interface '0.0.0.0', port 45612...
840 | </code></pre> | </code></pre>
841 | <p>Great. Now let's see how our newsletter looks, with its HTML served on <code>http://play.feedletter.org:45612/</code>. Not so good!</p><img alt="Screenshot of web-served lgm newsletter via ./feedletter-style compose-single, with a badly formatted image" src="scotus-disqualify-bad.png" style="width: 100%;"> | <p>Great. Now let's see how our newsletter looks, with its HTML served on <code>http://play.feedletter.org:45612/</code>. Not so good!</p>
842 |  | <img alt="Screenshot of web-served lgm newsletter via ./feedletter-style compose-single, with a badly formatted image" src="scotus-disqualify-bad.png" style="width: 100%;">
843 | <p>(<strong>Update:</strong> As of <em>feedletter v0.0.8</em> you can also <a href="../../../02/04/style-by-mail-in-feedletter/index.html">style newsletters by e-mail</a>, in addition to hitting a development webserver with a browser.)</p> | <p>(<strong>Update:</strong> As of <em>feedletter v0.0.8</em> you can also <a href="../../../02/04/style-by-mail-in-feedletter/index.html">style newsletters by e-mail</a>, in addition to hitting a development webserver with a browser.)</p>
844 | <div class="note"> | <div class="note">
845 | <p>By default, we just pulled the first item (and most recent, since blogs are usually reverse-chronological) from the feed. We can also pull a random item off the feed to view with <code>--random</code>, or a particular item identified by its <code><guid></code> element in the feed with <code>--guid <guid></code>.</p> | <p>By default, we just pulled the first item (and most recent, since blogs are usually reverse-chronological) from the feed. We can also pull a random item off the feed to view with <code>--random</code>, or a particular item identified by its <code><guid></code> element in the feed with <code>--guid <guid></code>.</p>
846 | </div> | </div>
847 | <p>Let's see how we can restyle this post to make it a bit better.</p> | <p>Let's see how we can restyle this post to make it a bit better.</p>
848 | <p>We have been using the built-in default untemplate to compose our items. We cannot modify that.</p> | <p>If we ran <code>./feedletter generate-starter-templates</code> before defining your subscribables, as recommended in a note <a href="#9-initialize-the-feedletter-database">above</a>, things are very easy. In the <code>untemplate</code> directory you will find a subdirectory <code>default/subscription/email</code>. In that directory, you'll see a file called <code>composeUniversal.html.untemplate</code>.</p>
849 | <p>But our feedletter installation directory contains a copy of this untemplate that we can deploy and tweak.</p> | <p>If we start editing that template, our edits will take effect immediately when running <code>feedletter-style</code>, like this:</p>
850 | <p>To do so, we'll have to create a folder for this file under <code>untemplate</code>. We’ll call it <code>tutorial</code>. Then...</p> | <pre><code class="language-plaintext">$ ./feedletter-style compose-single --subscribable-name lgm --port 45612
851 | <pre><code class="language-plaintext">$ mkdir untemplate/tutorial/ | [50/50] runMainBackground
852 |  | Watching for changes to 14 paths and 9 other values... (Enter to re-run, Ctrl-C to exit)
853 |  | Starting single-page webserver on interface '0.0.0.0', port 45612...
854 |  | </code></pre>
855 |  | <div class="note">
856 |  | <p>If we did not generate starter templates, we will have been using the compiled-in default untemplate to compose our items. We cannot modify that.</p>
857 |  | <p>But our feedletter installation directory contains a copy of this untemplate that we can deploy and tweak.</p>
858 |  | <p>To do so, we'll have to create a folder for this file under <code>untemplate</code>. We’ll call it <code>tutorial</code>. Then...</p>
859 |  | <pre><code class="language-plaintext">$ mkdir untemplate/tutorial/
860 | $ cp sample/defaultCompose.html.untemplate untemplate/tutorial/lgmCompose.html.untemplate | $ cp sample/defaultCompose.html.untemplate untemplate/tutorial/lgmCompose.html.untemplate
861 | </code></pre> | </code></pre>
862 | <p><code>feedletter</code> now has access to this untemplate, under a name you can find by calling <code>./feedletter list-untemplates</code>:</p> | <p><code>feedletter</code> now has access to this untemplate, under a name you can find by calling <code>./feedletter list-untemplates</code>:</p>
863 | <pre><code class="language-plaintext">$ ./feedletter list-untemplates | <pre><code class="language-plaintext">$ ./feedletter list-untemplates
864 | [42/49] compile | [42/49] compile
865 | [info] compiling 2 Scala sources to /home/feedletter/feedletter-local/out/compile.dest/classes ... | [info] compiling 2 Scala sources to /home/feedletter/feedletter-local/out/compile.dest/classes ...
866 | [info] done compiling | [info] done compiling
⋮ | ⋮ | ⋮
877 | ¦ tutorial.lgmCompose_html ¦ com.mchange.feedletter.style.ComposeInfo.Universal ¦ | ¦ tutorial.lgmCompose_html ¦ com.mchange.feedletter.style.ComposeInfo.Universal ¦
878 | +---------------------------------------------------------------+-----------------------------------------------------------------------------------------------------+ | +---------------------------------------------------------------+-----------------------------------------------------------------------------------------------------+
879 | </code></pre> | </code></pre>
880 | <p>Now we can ask <code>feedletter-style</code> to show us what this post would look like using our "new" untemplate to render it:</p> | <p>Now we can ask <code>feedletter-style</code> to show us what this post would look like using our "new" untemplate to render it:</p>
881 | <pre><code class="language-plaintext">$ ./feedletter-style compose-single --subscribable-name lgm --untemplate-name tutorial.lgmCompose_html --port 45612 | <pre><code class="language-plaintext">$ ./feedletter-style compose-single --subscribable-name lgm --untemplate-name tutorial.lgmCompose_html --port 45612
882 | [50/50] runMainBackground | [50/50] runMainBackground
883 | Watching for changes to 14 paths and 9 other values... (Enter to re-run, Ctrl-C to exit) | Watching for changes to 14 paths and 9 other values... (Enter to re-run, Ctrl-C to exit)
884 | Starting single-page webserver on interface '0.0.0.0', port 45612... | Starting single-page webserver on interface '0.0.0.0', port 45612...
885 | </code></pre> | </code></pre>
886 | <p>Initially it looks exactly the same, because it is just a copy of the default untemplate!</p> | </div>
887 | <p>But now we can just modify that file, <code>untemplate/tutorial/lgmCompose.html.untemplate</code>, hit reload, and play!</p> | <p>Initially it will look exactly the same, because it is just a copy of the default untemplate!</p>
888 |  | <p>But now we can just modify our untemplate (either <code>untemplate/default/subscription/email/composeUniversal.html.untemplate</code> or <code>untemplate/tutorial/lgmCompose.html.untemplate</code>), hit reload, and play around with it!</p>
889 | <p>This is why we needed a second terminal window. We edit the template in one terminal while <code>./feedletter-style</code> is running in the other. After each edit and save, we hit reload to see our changes. (We may have to wait 10-15 secs!)</p> | <p>This is why we needed a second terminal window. We edit the template in one terminal while <code>./feedletter-style</code> is running in the other. After each edit and save, we hit reload to see our changes. (We may have to wait 10-15 secs!)</p>
890 | <div class="note"> | <div class="note">
891 | <p>Occasionally the autoreload glitches out, in which case you should manually <ctrl-c> and rerun your <code>./feedletter-style</code> command.</p> | <p>Occasionally the autoreload glitches out, in which case you should manually <ctrl-c> and rerun your <code>./feedletter-style</code> command.</p>
892 | <p>If you see error messages when you rerun, you may have hit compilation errors (an <a href="https://github.com/swaldman/untemplate-doc#readme">untemplate is transformed into a Scala source code</a>, which is then compiled), which you will have to resolve. You can <a href="mailto:swaldman@mchange.com">ask for help</a>!)</p> | <p>If you see error messages when you rerun, you may have hit compilation errors (an <a href="https://github.com/swaldman/untemplate-doc#readme">untemplate is transformed into a Scala source code</a>, which is then compiled), which you will have to resolve. You can <a href="mailto:swaldman@mchange.com">ask for help</a>!)</p>
893 | </div> | </div>
894 | <p>Our new untemplate has a section that looks like this:</p> | <p>Our untemplate has a section that looks like this:</p>
895 | <pre><code class="language-html"><html> | <pre><code class="language-html"><html>
896 | <head> | <head>
897 | <style> | <style>
⋮ | ⋮ | ⋮
911 | } | }
912 | </style> | </style>
913 | </code></pre> | </code></pre>
914 | <p>We save, and hit reload on our browser still pointed at <code>http://play.feedletter.org:45612/</code>, and see...</p><img alt="Screenshot of web-served lgm newsletter with a better laid-out image." src="scotus-disqualify-good.png" style="width: 100%;"> | <p>We save, and hit reload on our browser still pointed at <code>http://play.feedletter.org:45612/</code>, and see...</p>
915 |  | <img alt="Screenshot of web-served lgm newsletter with a better laid-out image." src="scotus-disqualify-good.png" style="width: 100%;">
916 | <p>Much better!</p> | <p>Much better!</p>
917 | <p>If we are very picky, we see that at the end of our post, there is a line that doesn't logically belong <em>in</em> the post, and should be italicized or something.</p><img alt="Screenshot of web-served lgm newsletter with a better laid-out image." src="scotus-disqualify-good-but-bad-endline.png" style="width: 100%;"> | <p>If we are very picky, we see that at the end of our post, there is a line that doesn't logically belong <em>in</em> the post, and should be italicized or something.</p>
918 |  | <img alt="Screenshot of web-served lgm newsletter with a better laid-out image." src="scotus-disqualify-good-but-bad-endline.png" style="width: 100%;">
919 | <p>If we view the source, we'll find it's the last <code><p></code> element in <code><div class="item-contents"></code>. So we modify our styling as follows:</p> | <p>If we view the source, we'll find it's the last <code><p></code> element in <code><div class="item-contents"></code>. So we modify our styling as follows:</p>
920 | <pre><code class="language-html"><html> | <pre><code class="language-html"><html>
921 | <head> | <head>
⋮ | ⋮ | ⋮
931 | } | }
932 | </style> | </style>
933 | </code></pre> | </code></pre>
934 | <p>Looks better!</p><img alt="Screenshot of web-served lgm newsletter with a better laid-out image." src="scotus-disqualify-good-good-endline.png" style="width: 100%;"> | <p>Looks better!</p>
935 |  | <img alt="Screenshot of web-served lgm newsletter with a better laid-out image." src="scotus-disqualify-good-good-endline.png" style="width: 100%;">
936 | <p>We can keep editing all we like. We add the <code>--random</code> flag and run our <code>./feedletter-style</code> command over and over to make sure that posts in general render well.</p> | <p>We can keep editing all we like. We add the <code>--random</code> flag and run our <code>./feedletter-style</code> command over and over to make sure that posts in general render well.</p>
937 | <p>When we are happy, we want to tell our subscription to use the new untemplate.</p> | <p>When we are happy, we are done, if we've been editing the starter untemplate at <code>untemplate/default/subscription/email/composeUniversal.html.untemplate</code>. (If not, see the note just below.)</p>
938 | <p>Remember, the name of the untemplate we've been editing was <code>tutorial.lgmCompose_html</code>.</p> | <div class="note">
939 | <p>Let's check that command out:</p> | <p>If we created a new untemplate called <code>tutorial.lgmCompose_html</code>, we'll need to to tell our subscription to use the new untemplate.</p>
940 | <pre><code class="language-plaintext">$ ./feedletter set-untemplates --help | <p>Remember, the name of the untemplate we've been editing was <code>tutorial.lgmCompose_html</code>.</p>
941 |  | <p>Let's check that command out:</p>
942 |  | <pre><code class="language-plaintext">$ ./feedletter set-untemplates --help
943 | [49/49] runMain | [49/49] runMain
944 | Usage: feedletter set-untemplates --subscribable-name <name> [--compose-untemplate <fully-qualified-name>] [--confirm-untemplate <fully-qualified-name>] [--removal-notification-untemplate <fully-qualified-name>] [--status-change-untemplate <fully-qualified-name>] | Usage: feedletter set-untemplates --subscribable-name <name> [--compose-untemplate <fully-qualified-name>] [--confirm-untemplate <fully-qualified-name>] [--removal-notification-untemplate <fully-qualified-name>] [--status-change-untemplate <fully-qualified-name>]
945 |  |
⋮ | ⋮ | ⋮
961 | 1 targets failed | 1 targets failed
962 | runMain subprocess failed | runMain subprocess failed
963 | </code></pre> | </code></pre>
964 | <p>Okay. So we run...</p> | <p>Okay. So we run...</p>
965 | <pre><code class="language-plaintext">$ ./feedletter set-untemplates --subscribable-name lgm --compose-untemplate tutorial.lgmCompose_html | <pre><code class="language-plaintext">$ ./feedletter set-untemplates --subscribable-name lgm --compose-untemplate tutorial.lgmCompose_html
966 | [49/49] runMain | [49/49] runMain
967 | Updated Subscription Manager: { | Updated Subscription Manager: {
968 | "composeUntemplateName": "tutorial.lgmCompose_html", | "composeUntemplateName": "tutorial.lgmCompose_html",
⋮ | ⋮ | ⋮
979 | "version": 1 | "version": 1
980 | } | }
981 | </code></pre> | </code></pre>
982 |  | </div>
983 | <p>And we are done! We have restyled our newsletter.</p> | <p>And we are done! We have restyled our newsletter.</p>
984 | <p>We could (and should!) do the same with our other subscriptions (using <code>./feedletter-style compose-multiple</code>). We could also do much more elaborate things then just mess with the stylesheet. Our compose untemplate was really the definition of a pretty arbitrary Scala function that accepted a <a href="https://github.com/swaldman/feedletter/blob/main/src/com/mchange/feedletter/style/core.scala"><code>ComposeInfo.Single</code></a> object and produced a String (embedded in an <code>untemplate.Result</code>).</p> | <p>We could (and should!) do the same with our other subscriptions (using <code>./feedletter-style compose-multiple</code>). We could also do much more elaborate things then just mess with the stylesheet. Our compose untemplate was really the definition of a pretty arbitrary Scala function that accepted a <a href="https://github.com/swaldman/feedletter/blob/main/src/com/mchange/feedletter/style/core.scala"><code>ComposeInfo.Single</code></a> object and produced a String (embedded in an <code>untemplate.Result</code>).</p>
985 | <p>Learn more about untemplates <a href="https://github.com/swaldman/untemplate-doc#readme">here</a>.</p> | <p>Learn more about untemplates <a href="https://github.com/swaldman/untemplate-doc#readme">here</a>.</p>
986 | <p>The default compose untemplate actually accepts a <a href="https://github.com/swaldman/feedletter/blob/main/src/com/mchange/feedletter/style/core.scala"><code>ComposeInfo.Universal</code></a>, a parent type of both <a href="https://github.com/swaldman/feedletter/blob/main/src/com/mchange/feedletter/style/core.scala"><code>ComposeInfo.Single</code></a> and <a href="https://github.com/swaldman/feedletter/blob/main/src/com/mchange/feedletter/style/core.scala"><code>ComposeInfo.Multiple</code></a>. So we can fix up the glitches we know about already in our <code>lgm-daily</code> subscribable just by setting for it the same compose untemplate:</p> | <p>The default compose untemplate actually accepts a <a href="https://github.com/swaldman/feedletter/blob/main/src/com/mchange/feedletter/style/core.scala"><code>ComposeInfo.Universal</code></a>, a parent type of both <a href="https://github.com/swaldman/feedletter/blob/main/src/com/mchange/feedletter/style/core.scala"><code>ComposeInfo.Single</code></a> and <a href="https://github.com/swaldman/feedletter/blob/main/src/com/mchange/feedletter/style/core.scala"><code>ComposeInfo.Multiple</code></a>.</p>
987 | <pre><code class="language-plaintext">$ ./feedletter set-untemplates --subscribable-name lgm-daily --compose-untemplate tutorial.lgmCompose_html | <p>If we generated starter untemplates, our <code>lgm-daily</code> subscribable used the same compose untemplate we just edited by default. Run <code>./feedletter list-untemplates</code> to verify this.</p>
988 |  | <p>Let's take a look at what that subscription looks like. Because it shows multiple posts, we use the <code>compose-multiple</code> subcommand.</p>
989 |  | <pre><code class="language-plaintext">$ ./feedletter-style compose-multiple --subscribable-name lgm-daily --port 45612
990 |  | [50/50] runMainBackground
991 |  | Watching for changes to 14 paths and 9 other values... (Enter to re-run, Ctrl-C to exit)
992 |  | Starting single-page webserver on interface '0.0.0.0', port 45612...
993 |  | </code></pre>
994 |  | <p>We've already fixed our glitches in that subscription as well. It looks pretty good!</p>
995 |  | <div class="note">
996 |  | <p>If we did not <code>generate-starter-untemplates</code>, we still have to tell <code>lgm-daily</code> to use our new untemplate, just like we had to tell <code>lgm</code> before:</p>
997 |  | <pre><code class="language-plaintext">$ ./feedletter set-untemplates --subscribable-name lgm-daily --compose-untemplate tutorial.lgmCompose_html
998 | [49/49] runMain | [49/49] runMain
999 | Updated Subscription Manager: { | Updated Subscription Manager: {
1000 | "composeUntemplateName": "tutorial.lgmCompose_html", | "composeUntemplateName": "tutorial.lgmCompose_html",
⋮ | ⋮ | ⋮
1011 | "version": 1 | "version": 1
1012 | } | }
1013 | </code></pre> | </code></pre>
1014 | <p>If we take a look at that with <code>compose-multiple</code>..</p> | <p>If we take a look at that with <code>compose-multiple</code>..</p>
1015 | <pre><code class="language-plaintext">$ ./feedletter-style compose-multiple --subscribable-name lgm-daily --port 45612 | <pre><code class="language-plaintext">$ ./feedletter-style compose-multiple --subscribable-name lgm-daily --port 45612
1016 | [50/50] runMainBackground | [50/50] runMainBackground
1017 | Watching for changes to 14 paths and 9 other values... (Enter to re-run, Ctrl-C to exit) | Watching for changes to 14 paths and 9 other values... (Enter to re-run, Ctrl-C to exit)
1018 | Starting single-page webserver on interface '0.0.0.0', port 45612... | Starting single-page webserver on interface '0.0.0.0', port 45612...
1019 | </code></pre> | </code></pre>
1020 | <p>We'll find that it looks pretty good!</p> | <p>We'll find that it looks pretty good!</p>
1021 |  | </div>
1022 | <h2><a href="#16-advanced-customize-the-content" id="16-advanced-customize-the-content" name="16-advanced-customize-the-content" class="anchorlink"></a>16. Advanced: Customize the content</h2> | <h2><a href="#16-advanced-customize-the-content" id="16-advanced-customize-the-content" name="16-advanced-customize-the-content" class="anchorlink"></a>16. Advanced: Customize the content</h2>
1023 | <p><em>feedletter</em> supports a variety of <a href="https://github.com/swaldman/feedletter/blob/main/src/com/mchange/feedletter/style/Customizer.scala">customizers</a>, including</p> | <p><em>feedletter</em> supports a variety of <a href="https://github.com/swaldman/feedletter/blob/main/src/com/mchange/feedletter/style/Customizer.scala">customizers</a>, including</p>
1024 | <ul> | <ul>
⋮ | ⋮ | ⋮
1029 | </ul> | </ul>
1030 | <p>For each subscribable, you can define just one of each kind of customizer, but customers can perform any number of steps internally.</p> | <p>For each subscribable, you can define just one of each kind of customizer, but customers can perform any number of steps internally.</p>
1031 | <p>For an example, we'll build a content customizer. Both of our feeds frequently embed YouTube videos as <code>iframe</code> HTML elements in their blog posts. Unfortunately, mail clients generally do not render this form of embedded content, leaving awkward empty-spaces in and sometimes mangling the formatting of our newsletters.</p> | <p>For an example, we'll build a content customizer. Both of our feeds frequently embed YouTube videos as <code>iframe</code> HTML elements in their blog posts. Unfortunately, mail clients generally do not render this form of embedded content, leaving awkward empty-spaces in and sometimes mangling the formatting of our newsletters.</p>
1032 |  | <div class="note">
1033 |  | <p>As of <code>feedletter-v0.0.13</code> (released June 19, 2024), the API has changed slightly from that documented in this tutorial.</p>
1034 |  | <ol>
1035 |  | <li>
1036 |  | <p><code>Customizer</code> is no longer in the package <code>com.mchange.feedletter.style</code>, but in the base <code>com.mchange.feedletter</code> package. (This is because customizers now apply more broadly than styling nowtifications. They can be used, for example, to filter subscribables by author or category.)</p>
1037 |  | </li>
1038 |  | <li>
1039 |  | <p>Individual <code>Customizer</code> types — which are just functions — no longer include a <code>withinTypeId : String</code> argument. <code>withinTypeId</code> is how <code>feedletter</code> binds multiple items into a single notification (for example in a weekly digest feed). The several posts that will be notified share a <code>withinTypeId</code>. However, the nature and format of these IDs are really implementation details of <code>feedletter</code> and its <code>SubscriptionManager</code> classes, so we are not exposing them to customizers.</p>
1040 |  | </li>
1041 |  | </ol>
1042 |  | <p>Just use <code>com.mchange.feedletter.Customizer</code>, and</p>
1043 |  | <pre><code class="language-scala">( subscribableName : SubscribableName, subscriptionManager : SubscriptionManager, feedUrl : FeedUrl, contents : Seq[ItemContent] ) => Seq[ItemContent]
1044 |  | </code></pre>
1045 |  | <p>under newer versions of <code>feedletter</code>.</p>
1046 |  | </div>
1047 | <p>So let's build a content customizer that replaces these with well-behaved <code>div</code> elements containing links to the resources that would have been in the <code>iframe</code>. We'll include a <code>class="embedded"</code> attribute on the <code>div</code> elements, so that we will be able to style them however we want.</p> | <p>So let's build a content customizer that replaces these with well-behaved <code>div</code> elements containing links to the resources that would have been in the <code>iframe</code>. We'll include a <code>class="embedded"</code> attribute on the <code>div</code> elements, so that we will be able to style them however we want.</p>
1048 | <p>Writing customizers in writing Scala code. We'll use the excellent <a href="https://jsoup.org/">jsoup</a> library to manipulate HTML. We'll give ourselves space to work by creating a <code>tutorial</code> package in our installation's <code>src</code> directory, and then exiting a file called <code>core.scala</code> inside that.</p> | <p>Writing customizers in writing Scala code. We'll use the excellent <a href="https://jsoup.org/">jsoup</a> library to manipulate HTML. We'll give ourselves space to work by creating a <code>tutorial</code> package in our installation's <code>src</code> directory, and then exiting a file called <code>core.scala</code> inside that.</p>
1049 | <pre><code class="language-plaintext">$ mkdir src/tutorial | <pre><code class="language-plaintext">$ mkdir src/tutorial
⋮ | ⋮ | ⋮
1114 | Watching for changes to 14 paths and 9 other values... (Enter to re-run, Ctrl-C to exit) | Watching for changes to 14 paths and 9 other values... (Enter to re-run, Ctrl-C to exit)
1115 | Starting single-page webserver on interface '0.0.0.0', port 45612... | Starting single-page webserver on interface '0.0.0.0', port 45612...
1116 | </code></pre> | </code></pre>
1117 | <p>We can find one of Atrios' "Rock on." posts, which used to render blank in mail clients, but now render like...</p><img alt="Screenshot of a transformed-to-div iframe" src="rock-on.png" style="width: 100%;"> | <p>We can find one of Atrios' "Rock on." posts, which used to render blank in mail clients, but now render like...</p>
1118 |  | <img alt="Screenshot of a transformed-to-div iframe" src="rock-on.png" style="width: 100%;">
1119 | <p>Of course we can style that <code>div</code> and link however we like.</p> | <p>Of course we can style that <code>div</code> and link however we like.</p>
1120 | <div class="note"> | <div class="note">
1121 | <p><a name="templating-note" href=""></a>Re: "TemplateParams" customizers</p> | <p><a name="templating-note" href=""></a>Re: "TemplateParams" customizers</p>
⋮ | ⋮ | ⋮
1136 | <p>One feedletter instance can host as many feeds and subscribables as you like.</p> | <p>One feedletter instance can host as many feeds and subscribables as you like.</p>
1137 | <p>Restyling your subscribables, or writing customizers and bespoke untemplates for them, can take longer. Developing custom front-ends is time-consuming detail work.</p> | <p>Restyling your subscribables, or writing customizers and bespoke untemplates for them, can take longer. Developing custom front-ends is time-consuming detail work.</p>
1138 | <p>I'd love it if you gave <em>feedletter</em> a try!</p> | <p>I'd love it if you gave <em>feedletter</em> a try!</p>
1139 |  | <hr>
1140 |  | <p><strong>Update:</strong> I've <a href="../../../../2025/01/14/syndicating-rss-to-mastodon-and-bluesky-with-feedletter/index.html">added a tutorial</a> on using feedletter to syndicate post announcements from RSS to Mastodon and BlueSky.</p>
1141 | </div> | </div>
1142 | </div> | </div>
1143 | <div class="entry-footer"> | <div class="entry-footer">
1144 | <div class="post-metainfo"> | <div class="post-metainfo">
1145 | <a href="index.html">10:30 AM EST</a> | <div class="updated-note">
1146 |  | <a href="index.html#major-updates">Last major update at 2026-06-11 @ 12:30 PM EDT</a>
1147 |  | </div>
1148 |  | <div>
1149 |  | <a href="index.html" class="pubtime">10:30 AM EST</a>
1150 |  | </div>
1151 | </div> | </div>
1152 | </div> | </div>
1153 | </article> | </article>
⋮ | ⋮ | ⋮
1163 | <a href="../../../02/04/style-by-mail-in-feedletter/index.html">Style-by-mail in feedletter →</a> | <a href="../../../02/04/style-by-mail-in-feedletter/index.html">Style-by-mail in feedletter →</a>
1164 | </div> | </div>
1165 | </div> | </div>
1166 | </div><!-- after-article --> | <div id="update-history" class="update-history">
1167 |  | <h3 class="update-history-title"><a id="major-updates" href=""></a>Major revisions:</h3>
1168 |  | <ul>
1169 |  | <li><span class="update-timestamp"><i>2026-06-11 @ 12:30 PM EDT</i></span> — Modify the tutorial to encourage use of new './feedletter generate-starter-untemplates', and document by default in terms of those. (<a href="index-diff-acb84c08c1bbcc83036b9cad70f6a3c5d60865c4-to-current.html">diff</a>)</li>
1170 |  | <li><span class="update-timestamp"><i> <a href="index-oldcommit-acb84c08c1bbcc83036b9cad70f6a3c5d60865c4.html">2024-06-20 @ 01:10 PM EDT</a></i></span> — Add note to Section 16, "Advanced: Customize the content" documenting <i>feedletter</i> API changes that slightly modify this section of the tutorial. (<a href="index-diff-acb84c08c1bbcc83036b9cad70f6a3c5d60865c4-to-acb84c08c1bbcc83036b9cad70f6a3c5d60865c4.html">diff</a>)</li>
1171 |  | <li><span class="update-timestamp"><i> <a href="index-oldcommit-acb84c08c1bbcc83036b9cad70f6a3c5d60865c4.html">2024-01-29 @ 10:30 AM EST</a></i></span> — Initial publication.</li>
1172 |  | </ul>
1173 |  | <div class="update-history-note">
1174 |  | Timestamps represent "major", substantative revisions. There may have been subsequent typo fixes and language reworkings within a major revision, after the time displayed. For a more complete and fine-grained update history, you can view the <a href="https://github.com/swaldman/tech.interfluidity.com/commits/main/">git repository commit history</a>. The most recent minor modification of this entry occurred 2026-06-11 @ 02:43 PM EDT.
1175 |  | </div>
1176 |  | </div>
1177 |  | </div>
1178 |  | <!-- after-article -->
1179 | </div> | </div>
1180 | <div id="right-sidebar"> | <div id="right-sidebar">
1181 | </div> | </div>
1182 | </div> | </div>
1183 | </body> | </body>
1184 | </html> | </html>