-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 25e06c8
Showing
12 changed files
with
745 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
config.yaml | ||
notifyrss-go | ||
bin | ||
ao3-notifications.atom.xml |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}}"' . | ||
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 ¬ification | ||
} | ||
|
||
// 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 | ||
} |
Oops, something went wrong.