Skip to content

Commit

Permalink
Initial public release
Browse files Browse the repository at this point in the history
  • Loading branch information
Mihara committed Feb 22, 2025
0 parents commit 25e06c8
Show file tree
Hide file tree
Showing 12 changed files with 745 additions and 0 deletions.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
config.yaml
notifyrss-go
bin
ao3-notifications.atom.xml
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2025 by Eugene Medvedev ([email protected])

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
84 changes: 84 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# notifyrss-go

## What is it?

Imagine you have a [Miniflux](https://miniflux.app/) installation and you're tracking stories on [Archive of Our Own](https://archiveofourown.org/). And while that latter sends you email notifications about stories updating, you want them in your Miniflux instead. Or tinyRSS. Or Feedly. Or any other RSS/Atom/JSONFeed feed reader you like.

This tool was born to solve this particular problem, and so far, no others.

In the future, I plan to extend it to do other, similar jobs of transmuting email notifications into a feed of story updates (Major forums like Spacebattles and Sufficient Velocity come to mind), and there is code in there to make it easier, but right now, that's all it is doing.

## Installation

This is a [Go](https://go.dev/) program, so to build from source you just:

```shell
go install github.com/Mihara/notifyrss-go@latest
```

Or you can grab one of the binaries on the releases page. This is pure Go, and should work on any platform Go can compile for. Once you have an executable, it's on you to run it at regular intervals, or whenever a new email message comes in, in whatever way seems more expedient.

```shell
notifyrss [configuration file]
```

If you don't supply the configuration file parameter, it looks for `config.yaml` in the current directory.

You will also need to get the resulting static RSS/Atom/JSONFeed file to a web server, so that your Miniflux/tinyRSS/Feedly can pick it up. If you run your own feed aggregator, you probably already have a web server, or don't really have a problem with setting one up. If you don't, it shouldn't be difficult to set up Github Pages or Neocities or any other free static hoster to serve it, as long as you can run `notifyrss-go` at regular intervals to update your feed file.

Ideally, you want a separate email account to collect notifications and set up a forwarding scheme from your primary account that you used to register with *Archive of Our Own* where you actually receive notification emails. *(That's what I did.)* This is because your configuration file will inevitably contain the password to access this account, in plain text. Having a separate write-only account for the job is inherently more secure. While I have this account on my own email server, which runs on the same machine as my Miniflux installation, it can be anywhere, the only real requirement is to offer IMAP access and accept password login.

## Configuration

The configuration is a [YAML](https://en.wikipedia.org/wiki/YAML) file:

```yaml
mail:
host: example.com
port: 993
connection: ssl
user: notifier
pass: verysecret
folder: INBOX
options:
format: atom
files:
aoo: ao3-notifications.atom.xml
```
+ **mail**: Section pertaining to setting up the email where it will be picking up notifications from.
+ **host**: hostname of the email server. Required.
+ **port**: port of the IMAP server. Default is `993`.
+ **connection**: Connection type. Valid types are `plain`, `ssl`, `starttls`, default is `ssl`.
+ **user**: Username used for logging in. Required.
+ **pass**: Password. Required.
+ **folder**: IMAP folder to check. Default is `INBOX`, which is the primary inbox. You can use some other folder, e.g. set up your email to sort all notifications about story updates into a separate folder, and use that, although I still recommend a separate account. Ideally, there should be no extraneous emails in this folder, although if there are any, they will be ignored.
+ **options**: Section for general options.
+ **format**: Format of the feed to generate. Valid formats are `atom`, `rss`, `json`. Default is `atom`.
+ **files**: Files to be generated.
+ **aoo**: The filename for the Archive of Our Own feed. If not given, the feed will not be generated at all.

## How it works

Given the configuration file, `notifyrss-go` logs into the IMAP account, acquires every *unread* email in the given mailbox which it recognizes as coming from Archive of Our Own email notifier (or potentially, other such notifiers, once I get around to making them) and parses it to make a plausible feed item telling you that a story has a new chapter to read. The feed is then saved to a file. That's it. With Miniflux in particular, you can even configure it to fetch the actual chapter text, which is quite convenient.

It's important to note two things:

+ The emails will *stay* unread. It's on you to decide when you want to mark them read if at all.
+ Only the emails currently present in the mailbox and still unread will appear in the generated feed file as feed entries.

In practice, it will take you years of active reading to rack up enough notifications for the feed generation to start taking more than a second.

## Development

If you wish to preempt me and write a parser for some other kind of email notification, I'm open to pull requests -- take a look at `feed.go` and `parser-aoo.go` where comments should make what you need to do reasonably obvious. There's no reason this tool shouldn't be able to handle any reasonable email notifier service under the sun.

To build release binaries, you may want to use [Task](https://taskfile.dev/), although there's nothing particularly special about what it is doing here, and simple `go build` will build:

```shell
task build
```

## License

This program is released under the terms of MIT license. See the full text in [LICENSE](LICENSE)
31 changes: 31 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# https://taskfile.dev

version: '3'

tasks:
default:
desc: "List available tasks"
cmds:
- task -a
silent: true

build:
desc: "Build executables for release"
vars:
VERSION:
sh: git describe --tags --abbrev=0 || echo \(development\)
env:
CGO_ENABLED: 0
cmds:
- mkdir -p bin
- rm -f bin/*
- for:
matrix:
OS: ["windows", "linux", "darwin"]
ARCH: ["amd64", "arm64"]
cmd: >
GOOS={{.ITEM.OS}} GOARCH={{.ITEM.ARCH}}
go build -o
./bin/notifyrss_{{.ITEM.OS}}_{{.ITEM.ARCH}}{{ ternary ".exe" "" (eq .ITEM.OS "windows")}}
-trimpath -ldflags '-s -w -X main.version={{.VERSION}}"' .
11 changes: 11 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
mail:
host: example.com
port: 993
connection: ssl
user: notifier
pass: -------------
folder: INBOX
options:
format: atom
files:
aoo: ao3-notifications.atom.xml
193 changes: 193 additions & 0 deletions email.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package main

// The most arcane part of the whole thing, because apparently,
// to parse email with golang you need to actually know the IMAP standard
// by heart, because none of this is properly documented.
//
// Oh well.

import (
"bytes"
"fmt"
"io"
"log"
"strings"
"time"

"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
"github.com/emersion/go-message/mail"
"golang.org/x/net/html/charset"
)

// Because the actual types are truly mindbending, we consolidate the interesting
// parts of the email into a simpler structure before handing that over to the parsers.
type NotificationEmail struct {
From string
Subj string
Date time.Time
Text string
Html string
}

// The annoying part: taking the message apart into its html and txt bodies.
// It is my intuition, (the documentation is exceedingly lacking)
// that the BodySection[] map always contains exactly one element
// when the message was downloaded with Collect() as above.
// Whose key is a struct.
// And bizarrely, it's not a nil struct.
// So we have to curse to high heavens and loop through sections.
func parseEmail(message *imapclient.FetchMessageBuffer) *NotificationEmail {

notification := NotificationEmail{
From: message.Envelope.From[0].Addr(),
Subj: message.Envelope.Subject,
Date: message.Envelope.Date,
}

for _, bodyPart := range message.BodySection {

mr, err := mail.CreateReader(bytes.NewReader(bodyPart))
if err != nil {
// Looking at the createreader code, if there was
// an error, it's probably a borked message anyway.
return nil
}

partLoop:
for {
p, err := mr.NextPart()
if err == io.EOF {
break
} else if err != nil {
log.Printf("failed to read message part: %v", err)
return nil
}

// We ignore attachments and stuff...
switch h := p.Header.(type) {
case *mail.InlineHeader:
chunkBytes, _ := io.ReadAll(p.Body)
contentType, contentTypeParams, err := h.ContentType()
if err != nil {
continue partLoop
}

// In case we got utf-8, that's where it ends.
text := string(chunkBytes)

// But encodings that are not utf-8 should be converted to utf-8.
// We're assuming they didn't lie to us. (they can)
cs := contentTypeParams["charset"]
if strings.ToUpper(cs) != "UTF-8" {
enc, _, certain := charset.DetermineEncoding(chunkBytes, h.Get("Content-Type"))
if certain && enc != nil {
decodedText, err := enc.NewDecoder().Bytes(chunkBytes)
if err != nil {
log.Printf("failed to decode message, skipping.")
continue partLoop
}
text = string(decodedText)
}
}

switch contentType {
case "text/plain":
notification.Text = text
case "text/html":
notification.Html = text
}

}
}
}
return &notification
}

// Reach into the IMAP server and ask it for all unread emails matching a certain From address.
func fetchMail(client *imapclient.Client, fromAddress string) []*NotificationEmail {

searchResult, err := client.Search(
&imap.SearchCriteria{
Header: []imap.SearchCriteriaHeaderField{
{Key: "From", Value: fromAddress},
},
NotFlag: []imap.Flag{
"\\Seen",
}}, &imap.SearchOptions{}).Wait()
if err != nil {
log.Fatalf("search failed: %v", err)
}

fetchOptions := &imap.FetchOptions{
Flags: true,
Envelope: true,
BodyStructure: &imap.FetchItemBodyStructure{Extended: true},
BodySection: []*imap.FetchItemBodySection{{}},
}

messages, err := client.Fetch(searchResult.All, fetchOptions).Collect()
if err != nil {
log.Fatalf("failed to fetch: %v", err)
}

log.Printf("unread messages from %s: %d", fromAddress, len(messages))

parsedMessages := make([]*NotificationEmail, 0, len(messages))

for _, message := range messages {
result := parseEmail(message)
if result != nil {
parsedMessages = append(parsedMessages, result)
}
}
return parsedMessages
}

// Log into the IMAP server, fetch all interesting emails,
// and produce a slice of structures containing the important parts we're looking for.
func acquireEmail(cfg NotifyRSSConfig) []*NotificationEmail {
var err error

dialTone := fmt.Sprintf("%s:%d", cfg.Mail.Host, cfg.Mail.Port)
var client *imapclient.Client
switch strings.ToLower(cfg.Mail.Connection) {
case "ssl":
client, err = imapclient.DialTLS(dialTone, nil)
case "starttls":
client, err = imapclient.DialStartTLS(dialTone, nil)
case "plain":
client, err = imapclient.DialInsecure(dialTone, nil)
default:
log.Fatalf("ssl parameter must be one of 'plain', 'ssl', 'starttls'")
}
if err != nil {
log.Fatalf("connection failure: %v", err)
}
defer client.Close()

if err := client.Login(cfg.Mail.User, cfg.Mail.Pass).Wait(); err != nil {
log.Fatalf("failed to login: %v", err)
}

mailbox, err := client.Select(cfg.Mail.Folder, &imap.SelectOptions{ReadOnly: true}).Wait()
if err != nil {
log.Fatalf("failed to reach %s: %v", cfg.Mail.Folder, err)
}

log.Printf("%s contains %v messages", cfg.Mail.Folder, mailbox.NumMessages)

var notifications []*NotificationEmail

if mailbox.NumMessages > 0 {
for _, notifier := range SupportedNotifiers {
notifications = append(notifications, fetchMail(client, notifier.From)...)
}
}

if err := client.Logout().Wait(); err != nil {
log.Fatalf("failed to logout: %v", err)
}

return notifications
}
Loading

0 comments on commit 25e06c8

Please sign in to comment.