Author: Adam Evyčędo <git@apiote.xyz>
conflict
config_example.dirty | 6 | 140 ++++++----- | 6 eostre.sh | 18 + | 10 gersemi.go | 363 ------------------------------- gersemi/gersemi.go | 313 +++++++++++++++++++++++++++ go.mod | 2 hermodr.go | 211 ------------------ hermodr/hermodr.go | 184 ++++++++++++++++ himinbjorg/address.go | 22 + himinbjorg/knownAddress.go | 11 himinbjorg/lock.go | 29 ++ himinbjorg/message.go | 28 ++ idavollr/errors.go | 20 + idavollr/mailbox.go | 162 ++++++++++++++ idavollr/message.go | 164 ++++++++++++++ | 46 +-- jotunheim/config.go | 124 ++++++++++ main.go | 149 ++---------- mimir.go | 457 ---------------------------------------- mimir/mimir.go | 343 ++++++++++++++++++++++++++++++ tyr.go | 366 -------------------------------- tyr/tyr.go | 365 +++++++++++++++++++++++++++++++ | 0
diff --git a/config_example.dirty b/config_example.dirty index 570f2484cd838af55d78008218c7d814ec8163f9..d10150a89d2d46220a63acc1aeb279a6e4116c16 100644 --- a/config_example.dirty +++ b/config_example.dirty @@ -11,6 +11,8 @@ ('imapFolderDrafts' 'Drafts') ('imapFolderQuarantine' 'Quarantine') ('imapFolderSent' 'Sent') ('imapFolderTrash' 'Trash') + ('recipientDomain' '') + ('mainEmailAddress' '') ) ) ('hermodr' @@ -35,11 +37,12 @@ ('imapUsername' '') ('imapPassword' '') ('imapInbox' 'Inbox') ('recipientTemplate' '[:]@exmaple.com') - ('categories' ('cars', 'music')) + ('categories' ('cars' 'music')) ('forwardAddress' 'user+[:]@exmaple.com') ('personalAddress' 'user@example.com') ('smtpAddress' '') ('smtpSender' '') + ('companion' '') ) ) ('eostre' @@ -79,6 +82,7 @@ ('firefly' '') ('imapAddress' '') ('imapUsername' '') ('imapPassword' '') + ('imapInbox' '') ('messageMime' '') ('doneFolder' '') ('defaultSource' '') diff --git a/db.go b/db.go deleted file mode 100644 index b12efb681ea49bc271a60eb3b598c07f919fdca5..0000000000000000000000000000000000000000 --- a/db.go +++ /dev/null @@ -1,281 +0,0 @@ -package main - -import ( - "database/sql" - "fmt" - "path/filepath" - "strings" - "time" - - "github.com/emersion/go-imap" - - _ "github.com/mattn/go-sqlite3" -) - -func makeNameAddress(a *imap.Address, encode bool) string { - personalName := "" - if encode { - fields := a.Format() - personalName = fields[0].(string) - } else { - personalName = a.PersonalName - } - if personalName != "" { - return fmt.Sprintf("%s <%s>", personalName, a.Address()) - } else { - return a.Address() - } -} - -type NoMessageError struct { - MessageID string -} - -func (e NoMessageError) Error() string { - return "no message " + e.MessageID -} - -type ArchiveEntry struct { - This Message - Previous Message - Next []Message -} - -type Message struct { - ID string - Subject string - Body string - Date time.Time - Dkim bool - Sender string - Category string - Thread string -} - -func (m Message) FormatDate() string { - return m.Date.Format(time.RFC822Z) -} - -func (m Message) RESubject() string { - if m.Subject[:3] != "Re:" { - return "Re: " + m.Subject - } else { - return m.Subject - } -} - -func migrate(dbPath string) (*sql.DB, error) { - db, err := open(dbPath) - _, err = db.Exec(`create table tyr_knownAddresses(address_from text, address_to text, ban boolean, unique(address, direction))`) - if err != nil && err.Error() != "table tyr_knownAddresses already exists" { - return nil, err - } - _, err = db.Exec(`create table tyr_locks(address text unique, token text, date date)`) - if err != nil && err.Error() != "table tyr_locks already exists" { - return nil, err - } - - _, err = db.Exec(`create table mimir_archive(message_id text primary key, subject text, body text, date datetime, in_reply_to text, dkim_status bool, sender text, category text, root_id text, raw_message text, foreign key(in_reply_to) references mimir_archive(message_id))`) - if err != nil && err.Error() != "table mimir_archive already exists" { - return nil, err - } - _, err = db.Exec(`create table mimir_recipients(root_message_id text, recipient text, primary key(root_message_id, recipient), foreign key(root_message_id) references mimir_archive(message_id))`) - if err != nil && err.Error() != "table mimir_recipients already exists" { - return nil, err - } - - return db, nil -} - -func open(dbPath string) (*sql.DB, error) { - path, err := filepath.Abs(dbPath) - if err != nil { - return nil, err - } - db, err := sql.Open("sqlite3", path) - if err != nil { - return nil, err - } - return db, nil -} - -func getAddressLock(db *sql.DB, address string) (Lock, error) { - address = strings.ToLower(address) - lock := Lock{ - address: address, - } - row := db.QueryRow(`select token, date from tyr_locks where address = ?`, address) - err := row.Scan(&lock.token, &lock.date) - if err == sql.ErrNoRows { - return Lock{}, nil - } else { - return lock, err - } -} - -func getLock(db *sql.DB, token string) (Lock, error) { - lock := Lock{ - token: token, - } - row := db.QueryRow(`select address, date from tyr_locks where token = ?`, token) - err := row.Scan(&lock.address, &lock.date) - if err == sql.ErrNoRows { - return Lock{}, nil - } else { - return lock, err - } -} - -func listLocks(db *sql.DB) ([]Lock, error) { - locks := []Lock{} - rows, err := db.Query(`select address, token from tyr_locks`) - if err != nil { - return locks, err - } - for rows.Next() { - lock := Lock{} - err := rows.Scan(&lock.address, &lock.token) - if err != nil { - return locks, err - } - locks = append(locks, lock) - } - return locks, nil -} - -func insertLock(db *sql.DB, lock Lock) error { - _, err := db.Exec(`insert into tyr_locks values(?, ?, ?) on - conflict(address) do nothing`, - lock.address, lock.token, lock.date) - return err -} - -func deleteLock(db *sql.DB, lock Lock) error { - _, err := db.Exec(`delete from tyr_locks where address = ?`, lock.address) - return err -} - -func updateLock(db *sql.DB, lock Lock) error { - _, err := db.Exec(`update tyr_locks set date = ? where address = ?`, lock.date, lock.address) - return err -} - -func getKnownAddress(db *sql.DB, address string) ([]KnownAddress, error) { - knownAddresses := []KnownAddress{} - - rows, err := db.Query(`select address_from, address_to, ban from tyr_knownAddresses where address_from = ?`, address) - if err != nil { - return []KnownAddress{}, err - } - for rows.Next() { - knownAddress := KnownAddress{} - err := rows.Scan(&knownAddress.addressFrom, &knownAddress.addressTo, &knownAddress.ban) - if err != nil { - return []KnownAddress{}, err - } - knownAddresses = append(knownAddresses, knownAddress) - } - return knownAddresses, nil -} - -func insertKnownAddress(db *sql.DB, address KnownAddress) error { - _, err := db.Exec(`insert into tyr_knownAddresses values(?, ?, ?) on - conflict(address_to, address_from) do nothing`, - address.addressFrom, address.addressTo, address.ban) - return err -} - -func addArchiveEntry(db *sql.DB, messageID, category, subject string, body []byte, date time.Time, inReplyTo string, dkim bool, sender *imap.Address, messageBytes string) error { - var rootID string - row := db.QueryRow(`select root_id from mimir_archive where message_id = ?`, inReplyTo) - err := row.Scan(&rootID) - if err != nil { - if err == (sql.ErrNoRows) { - rootID = messageID - } else { - return err - } - } - _, err = db.Exec(`insert into mimir_archive values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, messageID, subject, body, date, inReplyTo, dkim, makeNameAddress(sender, false), category, rootID, messageBytes) - - return err -} - -func updateRecipients(db *sql.DB, address *imap.Address, msgID string) error { - var ( - rootID string - recipient sql.NullString - ) - row := db.QueryRow(`select root_id, recipient from mimir_archive left outer join mimir_recipients on(root_id == root_message_id) where message_id = ? and sender = ?`, msgID, makeNameAddress(address, false)) - err := row.Scan(&rootID, &recipient) - if err != nil { - return err - } - if !recipient.Valid { - _, err = db.Exec(`insert into mimir_recipients values(?, ?)`, rootID, address.Address()) - } - return err -} - -func getRecipients(db *sql.DB, messageID string, sender *imap.Address) ([]string, error) { - recipients := []string{} - - rows, err := db.Query(`select recipient from mimir_archive join mimir_recipients on(root_id == root_message_id) where message_id = ?`, messageID) - if err != nil { - return recipients, err - } - for rows.Next() { - var recipient string - err := rows.Scan(&recipient) - if err != nil { - return recipients, err - } - if recipient != sender.Address() { - recipients = append(recipients, recipient) - } - } - return recipients, nil -} - -func getArchivedThread(db *sql.DB, msgID string) ([]Message, error) { - messages := []Message{} - rows, err := db.Query(`select subject, body, date, dkim_status, sender, category, message_id from mimir_archive where root_id = ? order by date asc`, msgID) - if err != nil { - return messages, fmt.Errorf("while selecting thread: %w", err) - } - for rows.Next() { - message := Message{} - err := rows.Scan(&message.Subject, &message.Body, &message.Date, &message.Dkim, &message.Sender, &message.Category, &message.ID) - if err != nil { - return messages, fmt.Errorf("while scanning message in thread: %w", err) - } - messages = append(messages, message) - } - return messages, err -} - -func getArchivedThreads(db *sql.DB, page int64) ([]Message, int, error) { - messages := []Message{} - var numThreads int - row := db.QueryRow(`select count(*) from mimir_archive where root_id == message_id`) - err := row.Scan(&numThreads) - if err != nil { - return messages, 0, fmt.Errorf("while selecting count: %w", err) - } - if numThreads == 0 { - return messages, numThreads, nil - } - rows, err := db.Query(`select subject, date, sender, category, message_id, root_id, CASE WHEN LENGTH(body) > 256 THEN substr(body,1,256) || '…' ELSE body END from mimir_archive where root_id = message_id order by date desc limit 12 offset ?`, (page-1)*12) - if err != nil { - return messages, 0, fmt.Errorf("while selecting threads: %w", err) - } - for rows.Next() { - msg := Message{} - err := rows.Scan(&msg.Subject, &msg.Date, &msg.Sender, &msg.Category, &msg.ID, &msg.Thread, &msg.Body) - if err != nil { - return messages, 0, fmt.Errorf("while scanning message: %w", err) - } - messages = append(messages, msg) - } - return messages, numThreads, nil -} diff --git a/eostre/eostre.go b/eostre/eostre.go new file mode 100644 index 0000000000000000000000000000000000000000..8a922db590ed75f4cd884e29188a64d153593cad --- /dev/null +++ b/eostre/eostre.go @@ -0,0 +1,166 @@ +package eostre + +// todo make gott + +import ( + "bytes" + "errors" + "fmt" + "io" + "log" + "os" + "strings" + + "apiote.xyz/p/asgard/jotunheim" + + "github.com/ProtonMail/gopenpgp/v2/helper" + "github.com/bytesparadise/libasciidoc" + "github.com/bytesparadise/libasciidoc/pkg/configuration" + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/client" + "github.com/emersion/go-message" + _ "github.com/emersion/go-message/charset" +) + +func Eostre(config jotunheim.Config) (int, error) { + c, err := client.DialTLS(config.Eostre.ImapAddress, nil) + if err != nil { + log.Fatalln(err) + } + log.Println("Connected") + defer c.Logout() + if err := c.Login(config.Eostre.ImapUsername, config.Eostre.ImapPassword); err != nil { + log.Fatalln(err) + } + log.Println("Logged in") + mbox, err := c.Select("INBOX", false) + if err != nil { + log.Fatalln(err) + } + from := uint32(1) + to := mbox.Messages + seqset := new(imap.SeqSet) + seqset.AddRange(from, to) + messages := make(chan *imap.Message, 10) + done := make(chan error, 1) + section := &imap.BodySectionName{} + go func() { + done <- c.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope, section.FetchItem(), imap.FetchFlags, imap.FetchUid}, messages) + }() + delSeqset := new(imap.SeqSet) + doneMessages := 0 + for msg := range messages { + log.Println("doing", msg.Uid) + subject := msg.Envelope.Subject + sender := msg.Envelope.From[0] + if sender.Address() != config.Eostre.AuthorisedSender { + // todo remove message + log.Printf("ignoring from %s as not authorised\n", sender) + continue + } + bodyReader := msg.GetBody(section) + if bodyReader == nil { + log.Printf("body for %d is nil\n", msg.Uid) + } + m, err := message.Read(bodyReader) + if err != nil { + log.Fatalln(err) + } + t, params, err := m.Header.ContentType() + if err != nil { + log.Fatalln(err) + } + var part *message.Entity + if t == "text/plain" { + part = m + } else if t == "multipart/encrypted" && params["protocol"] == "application/pgp-encrypted" { + mr := m.MultipartReader() + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } else if err != nil { + return 0, fmt.Errorf("while reading next part: %w", err) + } + t, _, err := p.Header.ContentType() + if err != nil { + log.Fatalln(err) + } + if t == "application/octet-stream" { + bodyReader := p.Body + body, err := io.ReadAll(bodyReader) + if err != nil { + log.Fatalln(err) + } + decrypted, err := helper.DecryptVerifyMessageArmored(config.Eostre.PublicKey, config.Eostre.PrivateKey, []byte(config.Eostre.PrivateKeyPass), string(body)) + if err != nil { + log.Fatalln(err) + } + part, err = message.Read(strings.NewReader(decrypted)) + if err != nil { + log.Fatalln(err) + } + } + } + } else { + log.Printf("%d is not PGP encrypted and is not plain-text\n", msg.Uid) + continue + } + encryptedSubject := part.Header.Get("Subject") + if encryptedSubject != "" { + subject = encryptedSubject + } + partBodyReader := part.Body + body, err := io.ReadAll(partBodyReader) + if err != nil { + log.Fatalln(err) + } + asciidoc, _, _ := strings.Cut(string(body), "\n-- ") + + filename := msg.Envelope.Date.Format("20060102.html") + _, err = os.Stat(filename) + if err == nil { + asciidoc = "=== " + msg.Envelope.Date.Format("03:04 -0700") + "\n\n_" + subject + "_\n\n" + asciidoc + } else { + if errors.Is(err, os.ErrNotExist) { + asciidoc = "== " + msg.Envelope.Date.Format("Jan 2") + "\n\n_" + subject + "_\n\n" + asciidoc + } else { + log.Fatalln(err) + } + } + + reader := strings.NewReader(asciidoc) + writer := bytes.NewBuffer([]byte{}) + config := configuration.NewConfiguration() + libasciidoc.Convert(reader, writer, config) + + html := string(writer.Bytes()) + html = strings.ReplaceAll(html, "<div class=\"sect2\">\n", "") + html = strings.ReplaceAll(html, "<div class=\"sect1\">\n", "") + html = strings.ReplaceAll(html, "<div class=\"sectionbody\">\n", "") + html = strings.ReplaceAll(html, "<div class=\"paragraph\">\n", "") + html = strings.ReplaceAll(html, "</div>\n", "") + f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + log.Fatalln(err) + } + defer f.Close() + f.WriteString(html) + delSeqset.AddNum(msg.Uid) + doneMessages++ + } + if !delSeqset.Empty() { + item := imap.FormatFlagsOp(imap.AddFlags, true) + flags := []interface{}{imap.DeletedFlag} + _, err = c.Select("INBOX", false) + if err != nil { + log.Fatalln(err) + } + err = c.UidStore(delSeqset, item, flags, nil) + if err != nil { + log.Fatalln(err) + } + return doneMessages, c.Expunge(nil) + } + return doneMessages, nil +} diff --git a/eostre/eostre_2.go b/eostre/eostre_2.go new file mode 100644 index 0000000000000000000000000000000000000000..b6a30cb5f4ccb9a09baaf8c14065bad3521830e8 --- /dev/null +++ b/eostre/eostre_2.go @@ -0,0 +1,171 @@ +package eostre + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "log" + "os" + "os/exec" + "path" + "strings" + "time" + + "apiote.xyz/p/asgard/jotunheim" + + "filippo.io/age" + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/client" + "github.com/emersion/go-message" + _ "github.com/emersion/go-message/charset" + "github.com/emersion/go-sasl" + "github.com/emersion/go-smtp" +) + +func DownloadDiary(config jotunheim.Config) error { + c, err := client.DialTLS(config.Eostre.DiaryImapAddress, nil) + if err != nil { + log.Fatalln(err) + } + defer c.Close() + log.Println("Connected") + defer c.Logout() + if err := c.Login(config.Eostre.DiaryImapUsername, config.Eostre.DiaryImapPassword); err != nil { + log.Fatalln(err) + } + log.Println("Logged in") + mbox, err := c.Select("INBOX", false) + if err != nil { + log.Fatalln(err) + } + from := uint32(1) + to := mbox.Messages + seqset := new(imap.SeqSet) + seqset.AddRange(from, to) + messages := make(chan *imap.Message, 10) + done := make(chan error, 1) + section := &imap.BodySectionName{} + go func() { + done <- c.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope, section.FetchItem(), imap.FetchFlags, imap.FetchUid, imap.FetchInternalDate}, messages) + }() + var latestMessage message.Entity + var latestDate time.Time + for msg := range messages { + subject := msg.Envelope.Subject + if subject != config.Eostre.DiarySubject { + log.Printf("ignoring subject %s\n", subject) + continue + } + sender := msg.Envelope.From[0] + if sender.Address() != config.Eostre.DiarySender { + log.Printf("ignoring from %s as not authorised\n", sender) + continue + } + bodyReader := msg.GetBody(section) + if bodyReader == nil { + log.Printf("body for %d is nil\n", msg.Uid) + continue + } + m, err := message.Read(bodyReader) + if err != nil { + log.Println(err) + continue + } + t, _, err := m.Header.ContentType() + if err != nil { + log.Println(err) + continue + } + if t != "application/age" { + log.Printf("%d is not age\n", msg.Uid) + continue + } + log.Printf("msg %v is after %v?\n", msg.InternalDate, latestDate) + if msg.InternalDate.After(latestDate) { + log.Printf("yes\n") + latestDate = msg.InternalDate + latestMessage = *m + } + } + if latestMessage.Header.Len() == 0 { + return fmt.Errorf("No messages with diary") + } + identity, err := age.ParseX25519Identity(config.Eostre.DiaryPrivateKey) + if err != nil { + log.Fatalf("Failed to parse private key: %v", err) + } + r, err := age.Decrypt(latestMessage.Body, identity) + if err != nil { + log.Fatalf("Failed to open encrypted file: %v", err) + } + out, err := os.Create("diary.epub") + if err != nil { + log.Fatalf("Failed to create diary file: %v", err) + } + if _, err := io.Copy(out, r); err != nil { + log.Fatalf("Failed to read encrypted file: %v", err) + } + out.Close() + return nil +} + +func UpdateDiary(config jotunheim.Config) error { + home, err := os.UserHomeDir() + if err != nil { + return err + } + _, err = exec.Command(path.Join(home, ".local/share/asgard/eostre.sh")).Output() + return err +} + +func SendDiary(config jotunheim.Config) error { + recipient, err := age.ParseX25519Recipient(config.Eostre.DiaryPublicKey) + if err != nil { + log.Fatalf("Failed to parse public key: %v", err) + } + b := &bytes.Buffer{} + b64 := base64.NewEncoder(base64.StdEncoding, b) + in, err := os.Open("diary.epub") + if err != nil { + log.Fatalf("Failed to open decrypted file: %v", err) + } + w, err := age.Encrypt(b64, recipient) + if err != nil { + log.Fatalf("Failed to encrypt file: %v", err) + } + if _, err := io.Copy(w, in); err != nil { + log.Fatalf("Failed to read decrypted file: %v", err) + } + in.Close() + w.Close() + b64.Close() + os.Remove("diary.epub") + + now := time.Now().Format("20060102T150405Z0700") + msg := strings.NewReader("To: " + config.Eostre.DiaryRecipient + "\r\n" + + "From: " + config.Eostre.DiarySender + "\r\n" + + "Date: " + now + "\r\n" + + "Message-ID: " + now + "_eostre@apiote.xyz\r\n" + + "MIME-Version: 1.0\r\n" + + "Subject: Diary\r\n" + + "Content-Type: application/age; name=diary.epub.age\r\n" + + "Content-Transfer-Encoding: base64\r\n" + + "\r\n" + + string(b.Bytes()), + ) + c, err := smtp.DialTLS(config.Eostre.DiarySmtpAddress, nil) + if err != nil { + log.Fatalf("Failed smtp dial: %v", err) + } + auth := sasl.NewPlainClient("", config.Eostre.DiarySmtpUsername, config.Eostre.DiarySmtpPassword) + err = c.Auth(auth) + if err != nil { + log.Fatalf("Failed smtp auth: %v", err) + } + err = c.SendMail(config.Eostre.DiarySender, []string{config.Eostre.DiaryRecipient}, msg) + if err != nil { + return err + } + return nil +} diff --git a/eostre.go b/eostre.go deleted file mode 100644 index 810f016df087f50769b20d2f3081bc3d99f7d05b..0000000000000000000000000000000000000000 --- a/eostre.go +++ /dev/null @@ -1,164 +0,0 @@ -package main - -// todo make gott - -import ( - "bytes" - "errors" - "fmt" - "io" - "log" - "os" - "strings" - - "github.com/ProtonMail/gopenpgp/v2/helper" - "github.com/bytesparadise/libasciidoc" - "github.com/bytesparadise/libasciidoc/pkg/configuration" - "github.com/emersion/go-imap" - "github.com/emersion/go-imap/client" - "github.com/emersion/go-message" - _ "github.com/emersion/go-message/charset" -) - -func eostre(config Config) (int, error) { - c, err := client.DialTLS(config.Eostre.ImapAddress, nil) - if err != nil { - log.Fatalln(err) - } - log.Println("Connected") - defer c.Logout() - if err := c.Login(config.Eostre.ImapUsername, config.Eostre.ImapPassword); err != nil { - log.Fatalln(err) - } - log.Println("Logged in") - mbox, err := c.Select("INBOX", false) - if err != nil { - log.Fatalln(err) - } - from := uint32(1) - to := mbox.Messages - seqset := new(imap.SeqSet) - seqset.AddRange(from, to) - messages := make(chan *imap.Message, 10) - done := make(chan error, 1) - section := &imap.BodySectionName{} - go func() { - done <- c.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope, section.FetchItem(), imap.FetchFlags, imap.FetchUid}, messages) - }() - delSeqset := new(imap.SeqSet) - doneMessages := 0 - for msg := range messages { - log.Println("doing", msg.Uid) - subject := msg.Envelope.Subject - sender := msg.Envelope.From[0] - if sender.Address() != config.Eostre.AuthorisedSender { - // todo remove message - log.Printf("ignoring from %s as not authorised\n", sender) - continue - } - bodyReader := msg.GetBody(section) - if bodyReader == nil { - log.Printf("body for %d is nil\n", msg.Uid) - } - m, err := message.Read(bodyReader) - if err != nil { - log.Fatalln(err) - } - t, params, err := m.Header.ContentType() - if err != nil { - log.Fatalln(err) - } - var part *message.Entity - if t == "text/plain" { - part = m - } else if t == "multipart/encrypted" && params["protocol"] == "application/pgp-encrypted" { - mr := m.MultipartReader() - for { - p, err := mr.NextPart() - if err == io.EOF { - break - } else if err != nil { - return 0, fmt.Errorf("while reading next part: %w", err) - } - t, _, err := p.Header.ContentType() - if err != nil { - log.Fatalln(err) - } - if t == "application/octet-stream" { - bodyReader := p.Body - body, err := io.ReadAll(bodyReader) - if err != nil { - log.Fatalln(err) - } - decrypted, err := helper.DecryptVerifyMessageArmored(config.Eostre.PublicKey, config.Eostre.PrivateKey, []byte(config.Eostre.PrivateKeyPass), string(body)) - if err != nil { - log.Fatalln(err) - } - part, err = message.Read(strings.NewReader(decrypted)) - if err != nil { - log.Fatalln(err) - } - } - } - } else { - log.Printf("%d is not PGP encrypted and is not plain-text\n", msg.Uid) - continue - } - encryptedSubject := part.Header.Get("Subject") - if encryptedSubject != "" { - subject = encryptedSubject - } - partBodyReader := part.Body - body, err := io.ReadAll(partBodyReader) - if err != nil { - log.Fatalln(err) - } - asciidoc, _, _ := strings.Cut(string(body), "\n-- ") - - filename := msg.Envelope.Date.Format("20060102.html") - _, err = os.Stat(filename) - if err == nil { - asciidoc = "=== " + msg.Envelope.Date.Format("03:04 -0700") + "\n\n_" + subject + "_\n\n" + asciidoc - } else { - if errors.Is(err, os.ErrNotExist) { - asciidoc = "== " + msg.Envelope.Date.Format("Jan 2") + "\n\n_" + subject + "_\n\n" + asciidoc - } else { - log.Fatalln(err) - } - } - - reader := strings.NewReader(asciidoc) - writer := bytes.NewBuffer([]byte{}) - config := configuration.NewConfiguration() - libasciidoc.Convert(reader, writer, config) - - html := string(writer.Bytes()) - html = strings.ReplaceAll(html, "<div class=\"sect2\">\n", "") - html = strings.ReplaceAll(html, "<div class=\"sect1\">\n", "") - html = strings.ReplaceAll(html, "<div class=\"sectionbody\">\n", "") - html = strings.ReplaceAll(html, "<div class=\"paragraph\">\n", "") - html = strings.ReplaceAll(html, "</div>\n", "") - f, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) - if err != nil { - log.Fatalln(err) - } - defer f.Close() - f.WriteString(html) - delSeqset.AddNum(msg.Uid) - doneMessages++ - } - if !delSeqset.Empty() { - item := imap.FormatFlagsOp(imap.AddFlags, true) - flags := []interface{}{imap.DeletedFlag} - _, err = c.Select("INBOX", false) - if err != nil { - log.Fatalln(err) - } - err = c.UidStore(delSeqset, item, flags, nil) - if err != nil { - log.Fatalln(err) - } - return doneMessages, c.Expunge(nil) - } - return doneMessages, nil -} diff --git a/eostre.sh b/eostre.sh index 96b7791a2e906b58b1f38c7e8d38280c1a8ad359..02f1910cc343e0d319debe2e0ac1a542fb41e54d 100755 --- a/eostre.sh +++ b/eostre.sh @@ -1,6 +1,19 @@ #!/bin/sh # NEEDS sed,grep,zip,unzip +findTemplate() { + templateName=$1 + for d in /usr/share/asgard/templates ~/.local/share/asgard/templates templates . + do + if stat "$d/$templateName" >/dev/null 2>&1 + then + echo "$d/$templateName" + return + fi + done + return 1 +} + set -e # shellcheck disable=SC2010 @@ -49,12 +62,13 @@ for year in $years do manifest="$manifest\n\t\t<item href=\"Text/$year.xhtml\" id=\"$year.xhtml\" media-type=\"application/xhtml+xml\"/>" spine="$spine\n\t\t<itemref idref=\"$year.xhtml\"/>" - cp templates/content.opf.template tmp/OEBPS/content.opf + + cp "$(findTemplate content.opf.template)" tmp/OEBPS/content.opf sed -i "s|{{manifest}}|$manifest|" tmp/OEBPS/content.opf sed -i "s|{{spine}}|$spine|" tmp/OEBPS/content.opf toc="$toc\n\t\t<li><a href=\"$year.xhtml\">$year</a></li>" - cp templates/nav.xhtml.template tmp/OEBPS/Text/nav.xhtml + cp "$(findTemplate nav.xhtml.template)" tmp/OEBPS/Text/nav.xhtml sed -i "s|{{toc}}|$toc|" tmp/OEBPS/Text/nav.xhtml done diff --git a/eostre_2.go b/eostre_2.go deleted file mode 100644 index 92de049ff5e7a0b9f55de6785268e04303e6444c..0000000000000000000000000000000000000000 --- a/eostre_2.go +++ /dev/null @@ -1,169 +0,0 @@ -package main - -import ( - "bytes" - "encoding/base64" - "fmt" - "io" - "log" - "os" - "os/exec" - "path" - "strings" - "time" - - "filippo.io/age" - "github.com/emersion/go-imap" - "github.com/emersion/go-imap/client" - "github.com/emersion/go-message" - _ "github.com/emersion/go-message/charset" - "github.com/emersion/go-sasl" - "github.com/emersion/go-smtp" -) - -func downloadDiary(config Config) error { - c, err := client.DialTLS(config.Eostre.DiaryImapAddress, nil) - if err != nil { - log.Fatalln(err) - } - defer c.Close() - log.Println("Connected") - defer c.Logout() - if err := c.Login(config.Eostre.DiaryImapUsername, config.Eostre.DiaryImapPassword); err != nil { - log.Fatalln(err) - } - log.Println("Logged in") - mbox, err := c.Select("INBOX", false) - if err != nil { - log.Fatalln(err) - } - from := uint32(1) - to := mbox.Messages - seqset := new(imap.SeqSet) - seqset.AddRange(from, to) - messages := make(chan *imap.Message, 10) - done := make(chan error, 1) - section := &imap.BodySectionName{} - go func() { - done <- c.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope, section.FetchItem(), imap.FetchFlags, imap.FetchUid, imap.FetchInternalDate}, messages) - }() - var latestMessage message.Entity - var latestDate time.Time - for msg := range messages { - subject := msg.Envelope.Subject - if subject != config.Eostre.DiarySubject { - log.Printf("ignoring subject %s\n", subject) - continue - } - sender := msg.Envelope.From[0] - if sender.Address() != config.Eostre.DiarySender { - log.Printf("ignoring from %s as not authorised\n", sender) - continue - } - bodyReader := msg.GetBody(section) - if bodyReader == nil { - log.Printf("body for %d is nil\n", msg.Uid) - continue - } - m, err := message.Read(bodyReader) - if err != nil { - log.Println(err) - continue - } - t, _, err := m.Header.ContentType() - if err != nil { - log.Println(err) - continue - } - if t != "application/age" { - log.Printf("%d is not age\n", msg.Uid) - continue - } - log.Printf("msg %v is after %v?\n", msg.InternalDate, latestDate) - if msg.InternalDate.After(latestDate) { - log.Printf("yes\n") - latestDate = msg.InternalDate - latestMessage = *m - } - } - if latestMessage.Header.Len() == 0 { - return fmt.Errorf("No messages with diary") - } - identity, err := age.ParseX25519Identity(config.Eostre.DiaryPrivateKey) - if err != nil { - log.Fatalf("Failed to parse private key: %v", err) - } - r, err := age.Decrypt(latestMessage.Body, identity) - if err != nil { - log.Fatalf("Failed to open encrypted file: %v", err) - } - out, err := os.Create("diary.epub") - if err != nil { - log.Fatalf("Failed to create diary file: %v", err) - } - if _, err := io.Copy(out, r); err != nil { - log.Fatalf("Failed to read encrypted file: %v", err) - } - out.Close() - return nil -} - -func updateDiary(config Config) error { - home, err := os.UserHomeDir() - if err != nil { - return err - } - _, err = exec.Command(path.Join(home, ".local/share/asgard/eostre.sh")).Output() - return err -} - -func sendDiary(config Config) error { - recipient, err := age.ParseX25519Recipient(config.Eostre.DiaryPublicKey) - if err != nil { - log.Fatalf("Failed to parse public key: %v", err) - } - b := &bytes.Buffer{} - b64 := base64.NewEncoder(base64.StdEncoding, b) - in, err := os.Open("diary.epub") - if err != nil { - log.Fatalf("Failed to open decrypted file: %v", err) - } - w, err := age.Encrypt(b64, recipient) - if err != nil { - log.Fatalf("Failed to encrypt file: %v", err) - } - if _, err := io.Copy(w, in); err != nil { - log.Fatalf("Failed to read decrypted file: %v", err) - } - in.Close() - w.Close() - b64.Close() - os.Remove("diary.epub") - - now := time.Now().Format("20060102T150405Z0700") - msg := strings.NewReader("To: " + config.Eostre.DiaryRecipient + "\r\n" + - "From: " + config.Eostre.DiarySender + "\r\n" + - "Date: " + now + "\r\n" + - "Message-ID: " + now + "_eostre@apiote.xyz\r\n" + - "MIME-Version: 1.0\r\n" + - "Subject: Diary\r\n" + - "Content-Type: application/age; name=diary.epub.age\r\n" + - "Content-Transfer-Encoding: base64\r\n" + - "\r\n" + - string(b.Bytes()), - ) - c, err := smtp.DialTLS(config.Eostre.DiarySmtpAddress, nil) - if err != nil { - log.Fatalf("Failed smtp dial: %v", err) - } - auth := sasl.NewPlainClient("", config.Eostre.DiarySmtpUsername, config.Eostre.DiarySmtpPassword) - err = c.Auth(auth) - if err != nil { - log.Fatalf("Failed smtp auth: %v", err) - } - err = c.SendMail(config.Eostre.DiarySender, []string{config.Eostre.DiaryRecipient}, msg) - if err != nil { - return err - } - return nil -} diff --git a/gersemi/gersemi.go b/gersemi/gersemi.go new file mode 100644 index 0000000000000000000000000000000000000000..c41bf6e3f965ad765b06016fc615c6d3ae3b637e --- /dev/null +++ b/gersemi/gersemi.go @@ -0,0 +1,313 @@ +package gersemi + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "regexp" + "strings" + "time" + + "apiote.xyz/p/asgard/idavollr" + "apiote.xyz/p/asgard/jotunheim" + + "apiote.xyz/p/gott/v2" + _ "github.com/emersion/go-message/charset" +) + +type InvalidMessageError struct{} + +func (InvalidMessageError) Error() string { + return "message does not match withdrawal or deposit regex" +} + +type Transaction interface { + IsTransaction() +} + +type TransactionData struct { + Type string `json:"type"` + Date string `json:"date"` //yyyy-mm-ddT00:00:00+00:00 + Amount string `json:"amount"` + Description string `json:"description"` +} + +type Withdrawal struct { + TransactionData + SourceID string `json:"source_id"` + DestinationName string `json:"destination_name"` +} + +func (w Withdrawal) IsTransaction() {} + +type Deposit struct { + TransactionData + SourceName string `json:"source_name"` + DestinationID string `json:"destination_id"` +} + +func (w Deposit) IsTransaction() {} + +type GersemiRequestBody struct { + Transactions []Transaction `json:"transactions"` +} + +type GersemiMailbox struct { + idavollr.Mailbox + hc *http.Client + withdrawalRegexes []*regexp.Regexp + depositRegexes []*regexp.Regexp +} + +type GersemiImapMessage struct { + idavollr.ImapMessage + hc *http.Client + withdrawalRegexes []*regexp.Regexp + depositRegexes []*regexp.Regexp + config jotunheim.Config + src, dst string + title string + amount string + day, month, year string + requestBody GersemiRequestBody + requestBodyBytes []byte + request *http.Request + response *http.Response +} + +func Gersemi(config jotunheim.Config) error { + timeout, _ := time.ParseDuration("60s") + mailbox := &GersemiMailbox{ + Mailbox: idavollr.Mailbox{ + MboxName: config.Gersemi.ImapInbox, + ImapAdr: config.Gersemi.ImapAddress, + ImapUser: config.Gersemi.ImapUsername, + ImapPass: config.Gersemi.ImapPassword, + Conf: config, + }, + hc: &http.Client{ + Timeout: timeout, + }, + } + mailbox.SetupChannels() + + r := gott.R[idavollr.AbstractMailbox]{ + S: mailbox, + }. + Bind(prepareRegexes). + Bind(idavollr.Connect). + Tee(idavollr.Login). + Bind(idavollr.SelectInbox). + Tee(idavollr.CheckEmptyBox). + Map(idavollr.FetchMessages). + Tee(createTransactions). + Tee(idavollr.CheckFetchError). + Recover(idavollr.IgnoreEmptyBox). + Recover(idavollr.Disconnect) + + return r.E +} + +func prepareRegexes(am idavollr.AbstractMailbox) (idavollr.AbstractMailbox, error) { + m := am.(*GersemiMailbox) + for i, wr := range m.Config().Gersemi.WithdrawalRegexes { + re, err := regexp.Compile(wr) + if err != nil { + return m, fmt.Errorf("while compiling withdrawal regex %d: %w", i, err) + } + m.withdrawalRegexes = append(m.withdrawalRegexes, re) + } + + for i, dr := range m.Config().Gersemi.DepositRegexes { + re, err := regexp.Compile(dr) + if err != nil { + return m, fmt.Errorf("while compiling deposit regex %d: %w", i, err) + } + m.depositRegexes = append(m.depositRegexes, re) + } + + return m, nil +} + +func createTransactions(am idavollr.AbstractMailbox) error { + m := am.(*GersemiMailbox) + for msg := range m.Messages() { + imapMessage := idavollr.ImapMessage{ + Msg: msg, + Sect: m.Section(), + Mimetype: m.Config().Gersemi.MessageMime, + } + imapMessage.SetClient(m.Client()) + r := gott.R[idavollr.AbstractImapMessage]{ + S: &GersemiImapMessage{ + ImapMessage: imapMessage, + config: m.Config(), + withdrawalRegexes: m.withdrawalRegexes, + depositRegexes: m.depositRegexes, + hc: m.hc, + }, + }. + Bind(idavollr.ReadMessageBody). + Bind(idavollr.ParseMimeMessage). + Bind(idavollr.GetBody). + Bind(idavollr.ReadBody). + Bind(createTransaction). + Bind(marshalBody). + Bind(createRequest). + Bind(doRequest). + Bind(handleHttpError). + Tee(moveMessage). + Recover(ignoreInvalidMessage). + Recover(idavollr.RecoverMalformedMessage). + Recover(idavollr.RecoverErroredMessages) + if r.E != nil { + return r.E + } + } + return nil +} + +func matchRegex(m *GersemiImapMessage, match []string, groupNames []string) *GersemiImapMessage { + for groupIdx, group := range match { + names := groupNames[groupIdx] + for _, name := range strings.Split(names, "_") { + switch name { + case "TITLE": + m.title = regexp.MustCompile(" +").ReplaceAllString(group, " ") + case "SRC": + m.src = group + case "DST": + m.dst = regexp.MustCompile(" +").ReplaceAllString(group, " ") + case "AMOUNTC": + m.amount = group + m.amount = strings.Replace(m.amount, ",", ".", -1) + case "AMOUNT": + m.amount = group + case "DAY": + m.day = group + case "MONTH": + m.month = group + case "YEAR": + m.year = group + } + } + } + return m +} + +func createWithdrawal(m *GersemiImapMessage) *GersemiImapMessage { + for _, regex := range m.withdrawalRegexes { + groupNames := regex.SubexpNames() + matches := regex.FindAllStringSubmatch(string(m.MessageBody()), -1) + if matches == nil { + continue + } + match := matches[0] + m = matchRegex(m, match, groupNames) + transaction := Withdrawal{ + TransactionData: TransactionData{ + Type: "withdrawal", + Date: m.year + "-" + m.month + "-" + m.day + "T06:00:00+00:00", + Amount: m.amount, + Description: m.title, + }, + SourceID: m.config.Gersemi.Accounts[m.src], + DestinationName: m.dst, + } + body := GersemiRequestBody{ + Transactions: []Transaction{transaction}, + } + m.requestBody = body + return m + } + return nil +} + +func createDeposit(m *GersemiImapMessage) *GersemiImapMessage { + for _, regex := range m.depositRegexes { + groupNames := regex.SubexpNames() + matches := regex.FindAllStringSubmatch(string(m.MessageBody()), -1) + if matches == nil { + continue + } + match := matches[0] + m = matchRegex(m, match, groupNames) + transaction := Deposit{ + TransactionData: TransactionData{ + Type: "deposit", + Date: m.year + "-" + m.month + "-" + m.day + "T06:00:00+00:00", + Amount: m.amount, + Description: m.title, + }, + SourceName: m.src, + DestinationID: m.config.Gersemi.Accounts[m.dst], + } + body := GersemiRequestBody{ + Transactions: []Transaction{transaction}, + } + m.requestBody = body + return m + } + return nil +} + +func createTransaction(am idavollr.AbstractImapMessage) (idavollr.AbstractImapMessage, error) { + m := am.(*GersemiImapMessage) + m.src = m.config.Gersemi.DefaultSource + result := createWithdrawal(m) + if result != nil { + return result, nil + } + result = createDeposit(m) + if result != nil { + return result, nil + } + + return m, InvalidMessageError{} +} + +func marshalBody(am idavollr.AbstractImapMessage) (_ idavollr.AbstractImapMessage, err error) { + m := am.(*GersemiImapMessage) + m.requestBodyBytes, err = json.Marshal(m.requestBody) + return m, err +} + +func createRequest(am idavollr.AbstractImapMessage) (_ idavollr.AbstractImapMessage, err error) { + m := am.(*GersemiImapMessage) + m.request, err = http.NewRequest("POST", m.config.Gersemi.Firefly+"/api/v1/transactions", bytes.NewReader(m.requestBodyBytes)) + m.request.Header.Add("Authorization", "Bearer "+m.config.Gersemi.FireflyToken) + m.request.Header.Add("Accept", "application/vnd.api+json") + m.request.Header.Add("Content-Type", "application/json") + return m, err +} + +func doRequest(am idavollr.AbstractImapMessage) (_ idavollr.AbstractImapMessage, err error) { + m := am.(*GersemiImapMessage) + m.response, err = m.hc.Do(m.request) + return m, err +} + +func handleHttpError(am idavollr.AbstractImapMessage) (idavollr.AbstractImapMessage, error) { + m := am.(*GersemiImapMessage) + if m.response.StatusCode != 200 { + return m, fmt.Errorf(m.response.Status) + } + return m, nil +} + +func moveMessage(am idavollr.AbstractImapMessage) error { // TODO collect messages out of loop and move all + m := am.(*GersemiImapMessage) + return idavollr.MoveMsg(m.Client(), m.Msg, m.config.Gersemi.DoneFolder) +} + +func ignoreInvalidMessage(s idavollr.AbstractImapMessage, e error) (idavollr.AbstractImapMessage, error) { + var invalidMessageErr InvalidMessageError + if errors.As(e, &invalidMessageErr) { + log.Println(e.Error()) + return s, nil + } + return s, e +} diff --git a/gersemi.go b/gersemi.go deleted file mode 100644 index 3103ca1d0401dbd9da969de7ccb0c6815c86ef77..0000000000000000000000000000000000000000 --- a/gersemi.go +++ /dev/null @@ -1,363 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "log" - "net/http" - "regexp" - "strings" - "time" - - "apiote.xyz/p/gott/v2" - "github.com/emersion/go-imap" - "github.com/emersion/go-imap/client" - "github.com/emersion/go-message" - _ "github.com/emersion/go-message/charset" -) - -type InvalidMessageError struct{} - -func (InvalidMessageError) Error() string { - return "message does not match withdrawal or deposit regex" -} - -type Transaction interface { - IsTransaction() -} - -type TransactionData struct { - Type string `json:"type"` - Date string `json:"date"` //yyyy-mm-ddT00:00:00+00:00 - Amount string `json:"amount"` - Description string `json:"description"` -} - -type Withdrawal struct { - TransactionData - SourceID string `json:"source_id"` - DestinationName string `json:"destination_name"` -} - -func (w Withdrawal) IsTransaction() {} - -type Deposit struct { - TransactionData - SourceName string `json:"source_name"` - DestinationID string `json:"destination_id"` -} - -func (w Deposit) IsTransaction() {} - -type GersemiRequestBody struct { - Transactions []Transaction `json:"transactions"` -} - -type gersemiStruct struct { - c *client.Client - config Config - hc *http.Client - - mbox *imap.MailboxStatus - section *imap.BodySectionName - messages chan *imap.Message - done chan error - withdrawalRegexes []*regexp.Regexp - depositRegexes []*regexp.Regexp - - message *imap.Message - messageBytes []byte - mimeMessage *message.Entity - plainTextBodyReader io.Reader - plainTextBody []byte - src, dst string - title string - amount string - day, month, year string - requestBody GersemiRequestBody - requestBodyBytes []byte - request *http.Request - response *http.Response -} - -func gersemi(config Config) error { - c, err := client.DialTLS(config.Gersemi.ImapAddress, nil) - if err != nil { - log.Fatalln(err) - } - log.Println("Connected") - defer c.Close() - defer c.Logout() - if err := c.Login(config.Gersemi.ImapUsername, config.Gersemi.ImapPassword); err != nil { - log.Fatalln(err) - } - log.Println("Logged in") - - timeout, _ := time.ParseDuration("60s") - r := gott.R[gersemiStruct]{ - S: gersemiStruct{ - c: c, - config: config, - hc: &http.Client{ - Timeout: timeout, - }, - }, - }. - Bind(prepareRegexes). - Bind(selectGersemiInbox). - Tee(checkGersemiEmptyBox). - Map(fetchGersemiMessages). - Bind(createTransactions). - Recover(ignoreGersemiEmptyBox) - - return r.E -} - -func prepareRegexes(s gersemiStruct) (gersemiStruct, error) { - for i, wr := range s.config.Gersemi.WithdrawalRegexes { - re, err := regexp.Compile(wr) - if err != nil { - return s, fmt.Errorf("while compiling withdrawal regex %d: %w", i, err) - } - s.withdrawalRegexes = append(s.withdrawalRegexes, re) - } - - for i, dr := range s.config.Gersemi.DepositRegexes { - re, err := regexp.Compile(dr) - if err != nil { - return s, fmt.Errorf("while compiling deposit regex %d: %w", i, err) - } - s.depositRegexes = append(s.depositRegexes, re) - } - - return s, nil -} - -func selectGersemiInbox(s gersemiStruct) (gersemiStruct, error) { - mbox, err := s.c.Select("INBOX", false) - s.mbox = mbox - return s, err -} - -func checkGersemiEmptyBox(s gersemiStruct) error { - return checkImapEmptyBox(s.mbox) -} - -func fetchGersemiMessages(s gersemiStruct) gersemiStruct { // fixme same as in mimir - from := uint32(1) - to := s.mbox.Messages - seqset := new(imap.SeqSet) - seqset.AddRange(from, to) - - s.section = &imap.BodySectionName{} - items := []imap.FetchItem{imap.FetchEnvelope, s.section.FetchItem(), imap.FetchUid} - s.messages = make(chan *imap.Message, 10) - s.done = make(chan error, 1) - go func() { - s.done <- s.c.Fetch(seqset, items, s.messages) - }() - return s -} - -func ignoreGersemiEmptyBox(s gersemiStruct, e error) (gersemiStruct, error) { - var emptyBoxError EmptyBoxError - if errors.As(e, &emptyBoxError) { - log.Println("Mailbox is empty") - return s, nil - } - return s, e -} - -func createTransactions(s gersemiStruct) (gersemiStruct, error) { - for msg := range s.messages { - s.message = msg - r := gott.R[gersemiStruct]{S: s}. - Bind(readGersemiMsgBody). - Bind(parseGersemiMimeMessage). - Bind(getGersemiBody). - Bind(readGersemiBody). - Bind(createTransaction). - Bind(marshalBody). - Bind(createRequest). - Bind(doRequest). - Bind(handleHttpError). - SafeTee(moveGersemiMessage). - Recover(ignoreGersemiInvalidMessage). - Recover(recoverGersemiMalformedMessage). - Recover(recoverGersemiErroredMessages) - if r.E != nil { - return s, r.E - } - } - return s, nil -} - -func readGersemiMsgBody(s gersemiStruct) (gersemiStruct, error) { - r := s.message.GetBody(s.section) - if r == nil { - return s, MalformedMessageError{ - Cause: errors.New("no body in message"), - MessageID: s.message.Envelope.MessageId, - } - } - messageBytes, err := io.ReadAll(r) - s.messageBytes = messageBytes - return s, err -} - -func parseGersemiMimeMessage(s gersemiStruct) (gersemiStruct, error) { - r := bytes.NewReader(s.messageBytes) - m, err := message.Read(r) - s.mimeMessage = m - return s, err -} - -func getGersemiBody(s gersemiStruct) (gersemiStruct, error) { - body, err := getNextPart(s.mimeMessage, s.message.Envelope.MessageId, s.config.Gersemi.MessageMime) - s.plainTextBodyReader = body - return s, err -} - -func readGersemiBody(s gersemiStruct) (gersemiStruct, error) { - body, err := io.ReadAll(s.plainTextBodyReader) - s.plainTextBody = body - return s, err -} - -func matchRegex(s gersemiStruct, match []string, groupNames []string) gersemiStruct { - for groupIdx, group := range match { - names := groupNames[groupIdx] - for _, name := range strings.Split(names, "_") { - switch name { - case "TITLE": - s.title = regexp.MustCompile(" +").ReplaceAllString(group, " ") - case "SRC": - s.src = group - case "DST": - s.dst = regexp.MustCompile(" +").ReplaceAllString(group, " ") - case "AMOUNTC": - s.amount = group - s.amount = strings.Replace(s.amount, ",", ".", -1) - case "AMOUNT": - s.amount = group - case "DAY": - s.day = group - case "MONTH": - s.month = group - case "YEAR": - s.year = group - } - } - } - return s -} - -func createTransaction(s gersemiStruct) (gersemiStruct, error) { - s.src = s.config.Gersemi.DefaultSource - for _, wr := range s.withdrawalRegexes { - groupNames := wr.SubexpNames() - matches := wr.FindAllStringSubmatch(string(s.plainTextBody), -1) - if matches == nil { - continue - } - match := matches[0] - s = matchRegex(s, match, groupNames) - transaction := Withdrawal{ - TransactionData: TransactionData{ - Type: "withdrawal", - Date: s.year + "-" + s.month + "-" + s.day + "T06:00:00+00:00", - Amount: s.amount, - Description: s.title, - }, - SourceID: s.config.Gersemi.Accounts[s.src], - DestinationName: s.dst, - } - body := GersemiRequestBody{ - Transactions: []Transaction{transaction}, - } - s.requestBody = body - return s, nil - } - for _, dr := range s.depositRegexes { - groupNames := dr.SubexpNames() - matches := dr.FindAllStringSubmatch(string(s.plainTextBody), -1) - if matches == nil { - continue - } - match := matches[0] - s = matchRegex(s, match, groupNames) - transaction := Deposit{ - TransactionData: TransactionData{ - Type: "deposit", - Date: s.year + "-" + s.month + "-" + s.day + "T06:00:00+00:00", - Amount: s.amount, - Description: s.title, - }, - SourceName: s.src, - DestinationID: s.config.Gersemi.Accounts[s.dst], - } - body := GersemiRequestBody{ - Transactions: []Transaction{transaction}, - } - s.requestBody = body - return s, nil - } - - return s, InvalidMessageError{} -} - -func marshalBody(s gersemiStruct) (_ gersemiStruct, err error) { - s.requestBodyBytes, err = json.Marshal(s.requestBody) - return s, err -} - -func createRequest(s gersemiStruct) (_ gersemiStruct, err error) { - s.request, err = http.NewRequest("POST", s.config.Gersemi.Firefly+"/api/v1/transactions", bytes.NewReader(s.requestBodyBytes)) - s.request.Header.Add("Authorization", "Bearer "+s.config.Gersemi.FireflyToken) - s.request.Header.Add("Accept", "application/vnd.api+json") - s.request.Header.Add("Content-Type", "application/json") - return s, err -} - -func doRequest(s gersemiStruct) (_ gersemiStruct, err error) { - s.response, err = s.hc.Do(s.request) - return s, err -} - -func handleHttpError(s gersemiStruct) (gersemiStruct, error) { - if s.response.StatusCode != 200 { - return s, fmt.Errorf(s.response.Status) - } - return s, nil -} - -func moveGersemiMessage(s gersemiStruct) { - moveMsg(s.c, s.message, s.config.Gersemi.DoneFolder) -} - -func ignoreGersemiInvalidMessage(s gersemiStruct, e error) (gersemiStruct, error) { - var invalidMessageErr InvalidMessageError - if errors.As(e, &invalidMessageErr) { - log.Println(e.Error()) - return s, nil - } - return s, e -} - -func recoverGersemiMalformedMessage(s gersemiStruct, err error) (gersemiStruct, error) { - var malformedMessageError MalformedMessageError - if errors.As(err, &malformedMessageError) { - err = nil - log.Println(malformedMessageError.Error()) - log.Println(string(s.messageBytes)) - } - return s, err -} - -func recoverGersemiErroredMessages(s gersemiStruct, err error) (gersemiStruct, error) { - log.Printf("message %s errored: %v\n", s.message.Envelope.MessageId, err) - return s, nil -} diff --git a/go.mod b/go.mod index 4578277033bb4a1610856ba5e0a6e7b2c285b254..78cdc780b4b427e2f99d3a30ac9c35349d605e4f 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module git.apiote.xyz/me/asgard +module apiote.xyz/p/asgard go 1.18 diff --git a/hermodr/hermodr.go b/hermodr/hermodr.go new file mode 100644 index 0000000000000000000000000000000000000000..bc191aa24b153ae53c09cbca6fa905c2186860de --- /dev/null +++ b/hermodr/hermodr.go @@ -0,0 +1,184 @@ +package hermodr + +import ( + "io" + "net/mail" + "strings" + + "apiote.xyz/p/asgard/idavollr" + "apiote.xyz/p/asgard/jotunheim" + + "apiote.xyz/p/gott/v2" + "github.com/ProtonMail/gopenpgp/v2/helper" + "github.com/emersion/go-imap" + "github.com/emersion/go-smtp" +) + +type EmptyMessageError struct{} + +func (EmptyMessageError) Error() string { + return "Server didn't return message body" +} + +type HermodrMailbox struct { + idavollr.Mailbox +} + +type HermodrImapMessage struct { + idavollr.ImapMessage + config jotunheim.Config + literal imap.Literal + mailMsg *mail.Message + body string + plain string + armour string +} + +func redirectMessages(am idavollr.AbstractMailbox) error { + m := am.(*HermodrMailbox) + for msg := range m.Messages() { + imapMessage := idavollr.ImapMessage{ + Msg: msg, + Sect: m.Section(), + } + imapMessage.SetClient(am.Client()) + r := gott.R[idavollr.AbstractImapMessage]{ + S: &HermodrImapMessage{ + ImapMessage: imapMessage, + config: m.Conf, + }, + }. + Bind(getBodySection). + Bind(readLiteralMessage). + Bind(readLiteralBody). + Map(composePlaintextBody). + Bind(encrypt). + Tee(send). + Tee(markRead). + Tee(moveMessage) + + if r.E != nil { + return r.E + } + } + return nil +} + +func getBodySection(m idavollr.AbstractImapMessage) (idavollr.AbstractImapMessage, error) { + hm := m.(*HermodrImapMessage) + r := hm.Message().GetBody(m.Section()) + if r == nil { + return hm, EmptyMessageError{} + } + hm.literal = r + return hm, nil +} + +func readLiteralMessage(m idavollr.AbstractImapMessage) (idavollr.AbstractImapMessage, error) { + hm := m.(*HermodrImapMessage) + msg, err := mail.ReadMessage(hm.literal) + hm.mailMsg = msg + return hm, err +} + +func readLiteralBody(m idavollr.AbstractImapMessage) (idavollr.AbstractImapMessage, error) { + hm := m.(*HermodrImapMessage) + body, err := io.ReadAll(hm.mailMsg.Body) + hm.body = string(body) + return hm, err +} + +func composePlaintextBody(m idavollr.AbstractImapMessage) idavollr.AbstractImapMessage { + hm := m.(*HermodrImapMessage) + header := hm.mailMsg.Header + plainText := "Content-Type: " + header.Get("Content-Type") + "; protected-headers=\"v1\"\r\n" + plainText += "From: " + header.Get("From") + "\r\n" + plainText += "Message-ID: " + header.Get("Message-ID") + "\r\n" + plainText += "Subject: " + header.Get("Subject") + "\r\n" + plainText += "\r\n" + plainText += string(hm.body) + hm.plain = plainText + return hm +} + +func encrypt(m idavollr.AbstractImapMessage) (idavollr.AbstractImapMessage, error) { + hm := m.(*HermodrImapMessage) + armour, err := helper.EncryptMessageArmored(hm.config.Hermodr.PublicKey, hm.plain) + hm.armour = armour + return hm, err +} + +func send(m idavollr.AbstractImapMessage) error { + hm := m.(*HermodrImapMessage) + from := hm.mailMsg.Header.Get("From") + date := hm.mailMsg.Header.Get("Date") + messageID := hm.mailMsg.Header.Get("Message-ID") + to := []string{hm.config.Hermodr.Recipient} + msg := strings.NewReader("To: " + hm.config.Hermodr.Recipient + "\r\n" + + "From: " + from + "\r\n" + + "Date: " + date + "\r\n" + + "Message-ID: " + messageID + "\r\n" + + "MIME-Version: 1.0\r\n" + + "Subject: ...\r\n" + + "Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"---------------------997d365ae018229dc62ea2ff6b617cac\"; charset=utf-8\r\n" + + "\r\n" + + "This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)\r\n" + + "-----------------------997d365ae018229dc62ea2ff6b617cac\r\n" + + "Content-Type: application/pgp-encrypted\r\n" + + "Content-Description: PGP/MIME version identification\r\n" + + "\r\n" + + "Version: 1\r\n" + + "\r\n" + + "-----------------------997d365ae018229dc62ea2ff6b617cac\r\n" + + "Content-Type: application/octet-stream; name=\"encrypted.asc\"\r\n" + + "Content-Description: OpenPGP encrypted message\r\n" + + "Content-Disposition: inline; filename=\"encrypted.asc\"\r\n" + + "\r\n" + + hm.armour + + "\r\n" + + "\r\n" + + "-----------------------997d365ae018229dc62ea2ff6b617cac--\r\n") + err := smtp.SendMail(hm.config.Hermodr.SmtpServer, nil, hm.config.Hermodr.SmtpUsername, to, msg) + return err +} + +func markRead(m idavollr.AbstractImapMessage) error { + seqset := new(imap.SeqSet) + seqset.AddNum(1) // TODO collect seqset + item := imap.FormatFlagsOp(imap.AddFlags, true) + flags := []interface{}{imap.SeenFlag} + err := m.Client().Store(seqset, item, flags, nil) // TODO store outside of loop + return err +} + +func moveMessage(m idavollr.AbstractImapMessage) error { // TODO collect messages out of loop and move all + hm := m.(*HermodrImapMessage) + return idavollr.MoveMsg(m.Client(), hm.Msg, hm.config.Hermodr.ImapFolderRedirected) +} + +func Hermodr(config jotunheim.Config) error { + mailbox := &HermodrMailbox{ + Mailbox: idavollr.Mailbox{ + MboxName: config.Hermodr.ImapFolderInbox, + ImapAdr: config.Hermodr.ImapAddress, + ImapUser: config.Hermodr.ImapUsername, + ImapPass: config.Hermodr.ImapPassword, + Conf: config, + }, + } + + mailbox.SetupChannels() + r := gott.R[idavollr.AbstractMailbox]{ + S: mailbox, + }. + Bind(idavollr.Connect). + Tee(idavollr.Login). + Bind(idavollr.SelectInbox). + Tee(idavollr.CheckEmptyBox). + Map(idavollr.FetchMessages). + Tee(redirectMessages). + Recover(idavollr.IgnoreEmptyBox). + Recover(idavollr.Disconnect) + + return r.E +} diff --git a/hermodr.go b/hermodr.go deleted file mode 100644 index 2b070345719cf5c7da2e8cd44b6bd8199eab0ec1..0000000000000000000000000000000000000000 --- a/hermodr.go +++ /dev/null @@ -1,211 +0,0 @@ -package main - -import ( - "fmt" - "io" - "net/mail" - "os" - "strings" - - "github.com/ProtonMail/gopenpgp/v2/helper" - "github.com/emersion/go-imap" - "github.com/emersion/go-imap/client" - "github.com/emersion/go-smtp" - "notabug.org/apiote/gott" -) - -type EmptyMessageError struct{} - -func (EmptyMessageError) Error() string { - return "Server didn't return message body" -} - -type Result struct { - Config Config - - Client *client.Client - Mailbox *imap.MailboxStatus - Literal imap.Literal - Message *mail.Message - Body string - PlainText string - Armour string -} - -func connect(args ...interface{}) (interface{}, error) { - result := args[0].(Result) - c, err := client.Dial(result.Config.Hermodr.ImapAddress) - result.Client = c - return result, err -} - -func login(args ...interface{}) error { - result := args[0].(Result) - err := result.Client.Login(result.Config.Hermodr.ImapUsername, result.Config.Hermodr.ImapPassword) - return err -} - -func selectInbox(args ...interface{}) (interface{}, error) { - result := args[0].(Result) - mbox, err := result.Client.Select(result.Config.Hermodr.ImapFolderInbox, false) - result.Mailbox = mbox - return result, err -} - -func redirectMessages(args ...interface{}) (interface{}, error) { - result := args[0].(Result) - - for result.Mailbox.Messages > 0 { - r, err := gott.NewResult(result). - Bind(getMessage). - Bind(readMessage). - Bind(readBody). - Map(composePlaintextBody). - Bind(encrypt). - Tee(send). - Tee(markRead). - Tee(moveMessage). - Bind(selectInbox). - Finish() - if err != nil { - return r, err - } - result = r.(Result) - } - return result, nil -} -func getMessage(args ...interface{}) (interface{}, error) { - result := args[0].(Result) - - seqset := new(imap.SeqSet) - seqset.AddNum(1) - - section := &imap.BodySectionName{} - items := []imap.FetchItem{section.FetchItem()} - - messages := make(chan *imap.Message, 1) - done := make(chan error, 1) - go func() { - done <- result.Client.Fetch(seqset, items, messages) - }() - - msg := <-messages - r := msg.GetBody(section) - if r == nil { - return result, EmptyMessageError{} - } - result.Literal = r - - err := <-done - return result, err -} - -func readMessage(args ...interface{}) (interface{}, error) { - result := args[0].(Result) - m, err := mail.ReadMessage(result.Literal) - result.Message = m - return result, err -} - -func readBody(args ...interface{}) (interface{}, error) { - result := args[0].(Result) - body, err := io.ReadAll(result.Message.Body) - result.Body = string(body) - return result, err -} - -func composePlaintextBody(args ...interface{}) interface{} { - result := args[0].(Result) - - header := result.Message.Header - plainText := "Content-Type: " + header.Get("Content-Type") + "; protected-headers=\"v1\"\r\n" - plainText += "From: " + header.Get("From") + "\r\n" - plainText += "Message-ID: " + header.Get("Message-ID") + "\r\n" - plainText += "Subject: " + header.Get("Subject") + "\r\n" - plainText += "\r\n" - plainText += string(result.Body) - result.PlainText = plainText - return result -} - -func encrypt(args ...interface{}) (interface{}, error) { - result := args[0].(Result) - armour, err := helper.EncryptMessageArmored(result.Config.Hermodr.PublicKey, result.PlainText) - result.Armour = armour - return result, err -} - -func send(args ...interface{}) error { - result := args[0].(Result) - - from := result.Message.Header.Get("From") - date := result.Message.Header.Get("Date") - messageID := result.Message.Header.Get("Message-ID") - to := []string{result.Config.Hermodr.Recipient} - msg := strings.NewReader("To: " + result.Config.Hermodr.Recipient + "\r\n" + - "From: " + from + "\r\n" + - "Date: " + date + "\r\n" + - "Message-ID: " + messageID + "\r\n" + - "MIME-Version: 1.0\r\n" + - "Subject: ...\r\n" + - "Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"---------------------997d365ae018229dc62ea2ff6b617cac\"; charset=utf-8\r\n" + - "\r\n" + - "This is an OpenPGP/MIME encrypted message (RFC 4880 and 3156)\r\n" + - "-----------------------997d365ae018229dc62ea2ff6b617cac\r\n" + - "Content-Type: application/pgp-encrypted\r\n" + - "Content-Description: PGP/MIME version identification\r\n" + - "\r\n" + - "Version: 1\r\n" + - "\r\n" + - "-----------------------997d365ae018229dc62ea2ff6b617cac\r\n" + - "Content-Type: application/octet-stream; name=\"encrypted.asc\"\r\n" + - "Content-Description: OpenPGP encrypted message\r\n" + - "Content-Disposition: inline; filename=\"encrypted.asc\"\r\n" + - "\r\n" + - result.Armour + - "\r\n" + - "\r\n" + - "-----------------------997d365ae018229dc62ea2ff6b617cac--\r\n") - err := smtp.SendMail(result.Config.Hermodr.SmtpServer, nil, result.Config.Hermodr.SmtpUsername, to, msg) - return err -} - -func markRead(args ...interface{}) error { - result := args[0].(Result) - - seqset := new(imap.SeqSet) - seqset.AddNum(1) - item := imap.FormatFlagsOp(imap.AddFlags, true) - flags := []interface{}{imap.SeenFlag} - err := result.Client.Store(seqset, item, flags, nil) - return err -} - -func moveMessage(args ...interface{}) error { - result := args[0].(Result) - - seqset := new(imap.SeqSet) - seqset.AddNum(1) - err := result.Client.Copy(seqset, result.Config.Hermodr.ImapFolderRedirected) - return err -} - -func hermodr(config Config) { - r, err := gott.NewResult(Result{Config: config}). - SetLevelLog(gott.Debug). - Bind(connect). - Tee(login). - Bind(selectInbox). - Bind(redirectMessages). - Finish() - - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v", err) - return - } - // Don't forget to logout - if r.(Result).Client != nil { - r.(Result).Client.Logout() - } - -} diff --git a/himinbjorg/address.go b/himinbjorg/address.go new file mode 100644 index 0000000000000000000000000000000000000000..1564103ededf745e4cd3a33038aec6edef67ed85 --- /dev/null +++ b/himinbjorg/address.go @@ -0,0 +1,22 @@ +package himinbjorg + +import ( + "fmt" + + "github.com/emersion/go-imap" +) + +func MakeNameAddress(a *imap.Address, encode bool) string { + personalName := "" + if encode { + fields := a.Format() + personalName = fields[0].(string) + } else { + personalName = a.PersonalName + } + if personalName != "" { + return fmt.Sprintf("%s <%s>", personalName, a.Address()) + } else { + return a.Address() + } +} diff --git a/himinbjorg/db.go b/himinbjorg/db.go new file mode 100644 index 0000000000000000000000000000000000000000..e1b05da04c649971f79b7edf2a61878469c42780 --- /dev/null +++ b/himinbjorg/db.go @@ -0,0 +1,291 @@ +package himinbjorg + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/emersion/go-imap" + + "github.com/mattn/go-sqlite3" + _ "github.com/mattn/go-sqlite3" +) + +type NoMessageError struct { + MessageID string +} + +func (e NoMessageError) Error() string { + return "no message " + e.MessageID +} + +type ArchiveEntry struct { + This Message + Previous Message + Next []Message +} + +func Migrate(dbPath string) (*sql.DB, error) { + sql.Register("sqlite3_extended", + &sqlite3.SQLiteDriver{ + ConnectHook: func(conn *sqlite3.SQLiteConn) error { + return conn.RegisterFunc("regexp", func(re, s string) (bool, error) { + return regexp.MatchString(re, s) + }, true) + }, + }, + ) + + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("while getting user home dir: %w", err) + } + possibleDbDirs := []string{ + "/var/lib/asgard", + home + "/.local/state/asgard", + ".", + } + + if dbPath != "" { + possibleDbDirs = append([]string{filepath.Dir(dbPath)}, possibleDbDirs...) + } + + finalDbPath := "" + for _, possibleDbDir := range possibleDbDirs { + dirInfo, err := os.Stat(possibleDbDir) + if err == nil && dirInfo.IsDir() { + if filepath.Dir(dbPath) == possibleDbDir && dbPath != "" { + finalDbPath = dbPath + } else { + finalDbPath = possibleDbDir + "/asgard.db" + } + break + } + } + if finalDbPath == "" { + return nil, fmt.Errorf("no suitable db directory found") + } + db, err := open(finalDbPath) + _, err = db.Exec(`create table tyr_knownAddresses(address_from text, address_to text, ban boolean, unique(address, direction))`) + if err != nil && err.Error() != "table tyr_knownAddresses already exists" { + return nil, err + } + _, err = db.Exec(`create table tyr_locks(address text unique, token text, date date)`) + if err != nil && err.Error() != "table tyr_locks already exists" { + return nil, err + } + + _, err = db.Exec(`create table mimir_archive(message_id text primary key, subject text, body text, date datetime, in_reply_to text, dkim_status bool, sender text, category text, root_id text, raw_message text, foreign key(in_reply_to) references mimir_archive(message_id))`) + if err != nil && err.Error() != "table mimir_archive already exists" { + return nil, err + } + _, err = db.Exec(`create table mimir_recipients(root_message_id text, recipient text, primary key(root_message_id, recipient), foreign key(root_message_id) references mimir_archive(message_id))`) + if err != nil && err.Error() != "table mimir_recipients already exists" { + return nil, err + } + + _, err = db.Exec(`alter table tyr_locks add column recipient text`) + if err != nil && err.Error() != "duplicate column name: recipient" { + return nil, err + } + + return db, nil +} + +func open(dbPath string) (*sql.DB, error) { + path, err := filepath.Abs(dbPath) + if err != nil { + return nil, err + } + db, err := sql.Open("sqlite3_extended", path) + if err != nil { + return nil, err + } + return db, nil +} + +func GetAddressLock(db *sql.DB, address string) (Lock, error) { + address = strings.ToLower(address) + lock := Lock{ + Address: address, + } + row := db.QueryRow(`select token, date from tyr_locks where address = ?`, address) + err := row.Scan(&lock.Token, &lock.Date) + if err == sql.ErrNoRows { + return Lock{}, nil + } else { + return lock, err + } +} + +func GetLock(db *sql.DB, token string) (Lock, error) { + lock := Lock{ + Token: token, + } + row := db.QueryRow(`select address, date from tyr_locks where token = ?`, token) + err := row.Scan(&lock.Address, &lock.Date) + if err == sql.ErrNoRows { + return Lock{}, nil + } else { + return lock, err + } +} + +func ListLocks(db *sql.DB) ([]Lock, error) { + locks := []Lock{} + rows, err := db.Query(`select address, token from tyr_locks`) + if err != nil { + return locks, err + } + for rows.Next() { + lock := Lock{} + err := rows.Scan(&lock.Address, &lock.Token) + if err != nil { + return locks, err + } + locks = append(locks, lock) + } + return locks, nil +} + +func InsertLock(db *sql.DB, lock Lock) error { + _, err := db.Exec(`insert into tyr_locks values(?, ?, ?, ?) on + conflict(address) do nothing`, + lock.Address, lock.Token, lock.Date, lock.Recipient) + return err +} + +func DeleteLock(db *sql.DB, lock Lock) error { + _, err := db.Exec(`delete from tyr_locks where address = ?`, lock.Address) + return err +} + +func UpdateLock(db *sql.DB, lock Lock) error { + _, err := db.Exec(`update tyr_locks set date = ? where address = ?`, lock.Date, lock.Address) + return err +} + +func GetKnownAddress(db *sql.DB, address string) ([]KnownAddress, error) { + knownAddresses := []KnownAddress{} + + rows, err := db.Query(`select address_from, address_to, ban from tyr_knownAddresses where ? REGEXP address_from`, address) + if err != nil { + return []KnownAddress{}, err + } + for rows.Next() { + knownAddress := KnownAddress{} + err := rows.Scan(&knownAddress.AddressFrom, &knownAddress.AddressTo, &knownAddress.Ban) + if err != nil { + return []KnownAddress{}, err + } + knownAddresses = append(knownAddresses, knownAddress) + } + return knownAddresses, nil +} + +func InsertKnownAddress(db *sql.DB, address KnownAddress) error { + _, err := db.Exec(`insert into tyr_knownAddresses values(?, ?, ?) on + conflict(address_to, address_from) do nothing`, + address.AddressFrom, address.AddressTo, address.Ban) + return err +} + +func AddArchiveEntry(db *sql.DB, messageID, category, subject string, body []byte, date time.Time, inReplyTo string, dkim bool, sender *imap.Address, messageBytes string) error { + var rootID string + row := db.QueryRow(`select root_id from mimir_archive where message_id = ?`, inReplyTo) + err := row.Scan(&rootID) + if err != nil { + if err == (sql.ErrNoRows) { + rootID = messageID + } else { + return err + } + } + + _, err = db.Exec(`insert into mimir_archive values(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, messageID, subject, body, date, inReplyTo, dkim, MakeNameAddress(sender, false), category, rootID, messageBytes) + + return err +} + +func UpdateRecipients(db *sql.DB, address *imap.Address, msgID string) error { + var ( + rootID string + recipient sql.NullString + ) + row := db.QueryRow(`select root_id, recipient from mimir_archive left outer join mimir_recipients on(root_id == root_message_id) where message_id = ? and sender = ?`, msgID, MakeNameAddress(address, false)) + err := row.Scan(&rootID, &recipient) + if err != nil { + return err + } + if !recipient.Valid { + _, err = db.Exec(`insert into mimir_recipients values(?, ?)`, rootID, address.Address()) + } + return err +} + +func GetRecipients(db *sql.DB, messageID string, sender *imap.Address) ([]string, error) { + recipients := []string{} + + rows, err := db.Query(`select recipient from mimir_archive join mimir_recipients on(root_id == root_message_id) where message_id = ?`, messageID) + if err != nil { + return recipients, err + } + for rows.Next() { + var recipient string + err := rows.Scan(&recipient) + if err != nil { + return recipients, err + } + if recipient != sender.Address() { + recipients = append(recipients, recipient) + } + } + return recipients, nil +} + +func GetArchivedThread(db *sql.DB, msgID string) ([]Message, error) { + messages := []Message{} + rows, err := db.Query(`select subject, body, date, dkim_status, sender, category, message_id from mimir_archive where root_id = ? order by date asc`, msgID) + if err != nil { + return messages, fmt.Errorf("while selecting thread: %w", err) + } + for rows.Next() { + message := Message{} + err := rows.Scan(&message.Subject, &message.Body, &message.Date, &message.Dkim, &message.Sender, &message.Category, &message.ID) + if err != nil { + return messages, fmt.Errorf("while scanning message in thread: %w", err) + } + messages = append(messages, message) + } + return messages, err +} + +func GetArchivedThreads(db *sql.DB, page int64) ([]Message, int, error) { + messages := []Message{} + var numThreads int + row := db.QueryRow(`select count(*) from mimir_archive where root_id == message_id`) + err := row.Scan(&numThreads) + if err != nil { + return messages, 0, fmt.Errorf("while selecting count: %w", err) + } + if numThreads == 0 { + return messages, numThreads, nil + } + rows, err := db.Query(`select subject, date, sender, category, message_id, root_id, CASE WHEN LENGTH(body) > 256 THEN substr(body,1,256) || '…' ELSE body END from mimir_archive where root_id = message_id order by date desc limit 12 offset ?`, (page-1)*12) + if err != nil { + return messages, 0, fmt.Errorf("while selecting threads: %w", err) + } + for rows.Next() { + msg := Message{} + err := rows.Scan(&msg.Subject, &msg.Date, &msg.Sender, &msg.Category, &msg.ID, &msg.Thread, &msg.Body) + if err != nil { + return messages, 0, fmt.Errorf("while scanning message: %w", err) + } + messages = append(messages, msg) + } + return messages, numThreads, nil +} diff --git a/himinbjorg/knownAddress.go b/himinbjorg/knownAddress.go new file mode 100644 index 0000000000000000000000000000000000000000..f78e52d3e0dbfae60ee8cd20d9cbeb5fff7616ec --- /dev/null +++ b/himinbjorg/knownAddress.go @@ -0,0 +1,11 @@ +package himinbjorg + +type KnownAddress struct { + AddressFrom string + AddressTo string + Ban bool +} + +func (a KnownAddress) empty() bool { + return a.AddressFrom == "" && a.AddressTo == "" +} diff --git a/himinbjorg/lock.go b/himinbjorg/lock.go new file mode 100644 index 0000000000000000000000000000000000000000..229c57bf5b5e1288ad8a17ca1bda6f54b49ec902 --- /dev/null +++ b/himinbjorg/lock.go @@ -0,0 +1,29 @@ +package himinbjorg + +import ( + "math/rand" + "strconv" + "time" +) + +type Lock struct { + Address string + Token string + Date time.Time + Recipient string +} + +func NewLock(address, recipient string) Lock { + token := strconv.FormatUint(rand.Uint64(), 16) + lock := Lock{ + Address: address, + Token: token, + Date: time.Now(), + Recipient: recipient, + } + return lock +} + +func (l Lock) Empty() bool { + return l.Address == "" && l.Token == "" && l.Date.IsZero() +} diff --git a/himinbjorg/message.go b/himinbjorg/message.go new file mode 100644 index 0000000000000000000000000000000000000000..35c876a87ac7a1412b93467675f5d1ee53d3ade8 --- /dev/null +++ b/himinbjorg/message.go @@ -0,0 +1,28 @@ +package himinbjorg + +import ( + "time" +) + +type Message struct { + ID string + Subject string + Body string + Date time.Time + Dkim bool + Sender string + Category string + Thread string +} + +func (m Message) FormatDate() string { + return m.Date.Format(time.RFC822Z) +} + +func (m Message) RESubject() string { + if m.Subject[:3] != "Re:" { + return "Re: " + m.Subject + } else { + return m.Subject + } +} diff --git a/idavollr/errors.go b/idavollr/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..fa1c43b8cf0607b67adef28b48f79a6d054dc4b0 --- /dev/null +++ b/idavollr/errors.go @@ -0,0 +1,20 @@ +package idavollr + +import ( + "fmt" +) + +type EmptyBoxError struct{} + +func (EmptyBoxError) Error() string { + return "" +} + +type MalformedMessageError struct { + MessageID string + Cause error +} + +func (e MalformedMessageError) Error() string { + return fmt.Sprintf("Malformed message %s: %s", e.MessageID, e.Cause.Error()) +} diff --git a/idavollr/imap.go b/idavollr/imap.go new file mode 100644 index 0000000000000000000000000000000000000000..652c883456da1ed11ba23de7afa5cc6a5e5ae287 --- /dev/null +++ b/idavollr/imap.go @@ -0,0 +1,148 @@ +package idavollr + +import ( + "io" + "log" + "mime" + "mime/quotedprintable" + "strings" + + "apiote.xyz/p/asgard/himinbjorg" + "apiote.xyz/p/asgard/jotunheim" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap-move" + "github.com/emersion/go-imap/client" + "github.com/emersion/go-sasl" + "github.com/emersion/go-smtp" +) + +func SendRepeatedQuarantine(address *imap.Address, lock himinbjorg.Lock) { + // todo + log.Printf("sending repeated quarantine to %s from %+v\n", address.Address(), lock) +} + +func SendQuarantine(address *imap.Address) { + // todo + log.Printf("sending quarantine to %s\n", address.Address()) +} + +func MoveMsg(c *client.Client, msg *imap.Message, dest string) error { + log.Printf("moving %v : %s from %s to %s\n", msg.Envelope.Date, msg.Envelope.Subject, msg.Envelope.From[0].Address(), dest) + moveClient := move.NewClient(c) + seqSet := new(imap.SeqSet) + seqSet.AddNum(msg.Uid) + return moveClient.UidMoveWithFallback(seqSet, dest) +} + +func MoveMultiple(c *client.Client, seqSet *imap.SeqSet, dest string) error { + moveClient := move.NewClient(c) + if !seqSet.Empty() { + log.Println("moving multiple to " + dest) + err := moveClient.UidMoveWithFallback(seqSet, dest) + return err + } + return nil +} + +func MoveFromQuarantine(c *client.Client, mbox *imap.MailboxStatus, address, dest string) error { + log.Printf("moving %s from quarantine to %s\n", address, dest) + moveClient := move.NewClient(c) + from := uint32(1) + to := mbox.Messages + allMessagesSet := new(imap.SeqSet) + allMessagesSet.AddRange(from, to) + var err error = nil + + messages := make(chan *imap.Message, 10) + done := make(chan error, 1) + go func() { + done <- c.Fetch(allMessagesSet, []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}, messages) + }() + + moveSet := new(imap.SeqSet) + for msg := range messages { + sender := msg.Envelope.From[0] + if sender.Address() == address { + moveSet.AddNum(msg.Uid) + } + } + if !moveSet.Empty() { + err = moveClient.UidMoveWithFallback(moveSet, dest) + } + if err := <-done; err != nil { + return err + } + return err +} + +func ForwardMessage(config jotunheim.Config, category, messageID, inReplyTo, subject string, body []byte, recipients []string, sender *imap.Address) error { + // todo reformat, errors, &c. + + msg := "To: " + strings.Join(recipients, ", ") + "\r\n" + + "From: " + himinbjorg.MakeNameAddress(sender, true) + "\r\n" + + "Message-ID: " + messageID + "\r\n" + + "Subject: " + mime.QEncoding.Encode("utf-8", subject) + "\r\n" + if inReplyTo != "" { + msg = msg + "In-Reply-To: " + inReplyTo + "\r\n" + } + msg += "Reply-To: " + mime.QEncoding.Encode("utf-8", strings.Replace(config.Mimir.RecipientTemplate, "[:]", category, 1)) + "\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + "Content-Transfer-Encoding: quoted-printable\r\n" + + "\r\n" + msgReader := strings.NewReader(msg) + auth := sasl.NewPlainClient("", config.Mimir.ImapUsername, config.Mimir.ImapPassword) + c, err := smtp.DialTLS(config.Mimir.SmtpAddress, nil) + if err != nil { + log.Fatal(err) + } + + if err = c.Auth(auth); err != nil { + log.Fatal(err) + } + + if err := c.Mail(config.Mimir.SmtpSender, nil); err != nil { + log.Fatal(err) + } + for _, recipient := range recipients { + if err := c.Rcpt(recipient, nil); err != nil { + log.Fatal(err) + } + } + + wc, err := c.Data() + if err != nil { + log.Fatal(err) + } + _, err = io.Copy(wc, msgReader) + qpWriter := quotedprintable.NewWriter(wc) + _, err = qpWriter.Write(body) + _, err = qpWriter.Write([]byte("\r\n")) + if err != nil { + log.Fatal(err) + } + err = wc.Close() + if err != nil { + log.Fatal(err) + } + + err = c.Quit() + if err != nil { + log.Fatal(err) + } + return nil +} + +func RemoveMessage(c *client.Client, msgUid uint32, mailbox string) error { + _, err := c.Select(mailbox, false) + if err != nil { + return err + } + + seqset := new(imap.SeqSet) + seqset.AddNum(msgUid) + + item := imap.FormatFlagsOp(imap.AddFlags, true) + flags := []interface{}{imap.DeletedFlag} + return c.UidStore(seqset, item, flags, nil) +} diff --git a/idavollr/mailbox.go b/idavollr/mailbox.go new file mode 100644 index 0000000000000000000000000000000000000000..adade67e4dfba65deeadc0444e208ca3ef479d5c --- /dev/null +++ b/idavollr/mailbox.go @@ -0,0 +1,162 @@ +package idavollr + +import ( + "errors" + "log" + + "apiote.xyz/p/asgard/jotunheim" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/client" +) + +type AbstractMailbox interface { + MailboxName() string + ImapAddress() string + ImapUsername() string + ImapPassword() string + SetClient(*client.Client) + Client() *client.Client + Config() jotunheim.Config + SetMailbox(*imap.MailboxStatus) + GetMailbox() *imap.MailboxStatus + SetSection(*imap.BodySectionName) + Section() *imap.BodySectionName + Messages() chan *imap.Message + Done() chan error + + SetupChannels() +} + +type Mailbox struct { + MboxName string + ImapAdr string + ImapUser string + ImapPass string + Conf jotunheim.Config + + cli *client.Client + mbox *imap.MailboxStatus + sect *imap.BodySectionName + msgs chan *imap.Message + dn chan error +} + +func (m Mailbox) MailboxName() string { + return m.MboxName +} + +func (m Mailbox) ImapAddress() string { + return m.ImapAdr +} + +func (m Mailbox) ImapUsername() string { + return m.ImapUser +} + +func (m Mailbox) ImapPassword() string { + return m.ImapPass +} + +func (m *Mailbox) SetClient(c *client.Client) { + m.cli = c +} + +func (m Mailbox) Client() *client.Client { + return m.cli +} + +func (m Mailbox) Config() jotunheim.Config { + return m.Conf +} + +func (m *Mailbox) SetMailbox(mbox *imap.MailboxStatus) { + m.mbox = mbox +} + +func (m Mailbox) GetMailbox() *imap.MailboxStatus { + return m.mbox +} + +func (m *Mailbox) SetSection(sect *imap.BodySectionName) { + m.sect = sect +} + +func (m Mailbox) Section() *imap.BodySectionName { + return m.sect +} + +func (m Mailbox) Messages() chan *imap.Message { + return m.msgs +} + +func (m Mailbox) Done() chan error { + return m.dn +} + +func (m *Mailbox) SetupChannels() { + m.msgs = make(chan *imap.Message, 10) + m.dn = make(chan error, 1) +} + +func Connect(m AbstractMailbox) (AbstractMailbox, error) { + c, err := client.DialTLS(m.ImapAddress(), nil) + m.SetClient(c) + return m, err +} + +func Login(m AbstractMailbox) error { + return m.Client().Login(m.ImapUsername(), m.ImapPassword()) +} + +func SelectInbox(m AbstractMailbox) (AbstractMailbox, error) { + mbox, err := m.Client().Select(m.MailboxName(), false) + m.SetMailbox(mbox) + return m, err +} + +func CheckEmptyBox(m AbstractMailbox) error { + if m.GetMailbox().Messages == 0 { + return EmptyBoxError{} + } + return nil +} + +func FetchMessages(m AbstractMailbox) AbstractMailbox { + from := uint32(1) + to := m.GetMailbox().Messages + seqset := new(imap.SeqSet) + seqset.AddRange(from, to) + + m.SetSection(&imap.BodySectionName{}) + items := []imap.FetchItem{imap.FetchEnvelope, m.Section().FetchItem(), imap.FetchUid} + go func() { + m.Done() <- m.Client().Fetch(seqset, items, m.Messages()) + }() + return m +} + +func CheckFetchError(m AbstractMailbox) error { + return <-m.Done() +} + +func Expunge(m AbstractMailbox) error { + return m.Client().Expunge(nil) +} + +func IgnoreEmptyBox(m AbstractMailbox, e error) (AbstractMailbox, error) { + var emptyBoxError EmptyBoxError + if errors.As(e, &emptyBoxError) { + log.Println("Mailbox is empty") + return m, nil + } + return m, e +} + +func Disconnect(m AbstractMailbox, e error) (AbstractMailbox, error) { + if m.Client() != nil { + m.Client().Logout() + m.Client().Close() + } + return m, e +} diff --git a/idavollr/message.go b/idavollr/message.go new file mode 100644 index 0000000000000000000000000000000000000000..cfdd81b36afc1e8e7f5eb673f469d69f998dc64c --- /dev/null +++ b/idavollr/message.go @@ -0,0 +1,164 @@ +package idavollr + +import ( + "bytes" + "errors" + "fmt" + "io" + "log" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/client" + "github.com/emersion/go-message" +) + +type AbstractImapMessage interface { + Section() *imap.BodySectionName + Message() *imap.Message + MimeType() string + SetMessageBytes([]byte) + MessageBytes() []byte + SetMimeMessage(*message.Entity) + MimeMessage() *message.Entity + SetMessageReader(io.Reader) + MessageReader() io.Reader + SetMessageBody([]byte) + SetClient(*client.Client) + Client() *client.Client +} + +type ImapMessage struct { + Sect *imap.BodySectionName + Msg *imap.Message + Mimetype string + + cli *client.Client + msgBytes []byte + mimeMsg *message.Entity + bdyRdr io.Reader + bdy []byte +} + +func (m ImapMessage) Section() *imap.BodySectionName { + return m.Sect +} + +func (m ImapMessage) Message() *imap.Message { + return m.Msg +} + +func (m ImapMessage) MimeType() string { + return m.Mimetype +} + +func (m *ImapMessage) SetMessageBytes(b []byte) { + m.msgBytes = b +} + +func (m ImapMessage) MessageBytes() []byte { + return m.msgBytes +} + +func (m *ImapMessage) SetMimeMessage(msg *message.Entity) { + m.mimeMsg = msg +} + +func (m ImapMessage) MimeMessage() *message.Entity { + return m.mimeMsg +} + +func (m *ImapMessage) SetMessageReader(r io.Reader) { + m.bdyRdr = r +} + +func (m ImapMessage) MessageReader() io.Reader { + return m.bdyRdr +} + +func (m *ImapMessage) SetMessageBody(b []byte) { + m.bdy = b +} + +func (m ImapMessage) MessageBody() []byte { + return m.bdy +} + +func (m ImapMessage) SetClient(c *client.Client) { + m.cli = c +} + +func (m ImapMessage) Client() *client.Client { + return m.cli +} + +func ReadMessageBody(m AbstractImapMessage) (AbstractImapMessage, error) { + r := m.Message().GetBody(m.Section()) + if r == nil { + return m, MalformedMessageError{ + Cause: errors.New("no body in message"), + MessageID: m.Message().Envelope.MessageId, + } + } + messageBytes, err := io.ReadAll(r) + m.SetMessageBytes(messageBytes) + return m, err +} + +func ParseMimeMessage(m AbstractImapMessage) (AbstractImapMessage, error) { + r := bytes.NewReader(m.MessageBytes()) + message, err := message.Read(r) + m.SetMimeMessage(message) + return m, err +} +func getNextPart(message *message.Entity, ID string, mimetype string) (io.Reader, error) { + if mr := message.MultipartReader(); mr != nil { + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } else if err != nil { + return nil, fmt.Errorf("while reading next part: %w", err) + } + return getNextPart(p, ID, mimetype) + } + } else { + t, _, err := message.Header.ContentType() + if err != nil { + return nil, fmt.Errorf("while getting content type: %w", err) + } + if t == mimetype { + return message.Body, nil + } + } + return nil, MalformedMessageError{ + Cause: errors.New(mimetype + " not found"), + MessageID: ID, + } +} + +func GetBody(m AbstractImapMessage) (AbstractImapMessage, error) { + reader, err := getNextPart(m.MimeMessage(), m.Message().Envelope.MessageId, m.MimeType()) + m.SetMessageReader(reader) + return m, err +} + +func ReadBody(m AbstractImapMessage) (AbstractImapMessage, error) { + body, err := io.ReadAll(m.MessageReader()) + m.SetMessageBody(body) + return m, err +} + +func RecoverMalformedMessage(m AbstractImapMessage, err error) (AbstractImapMessage, error) { + var malformedMessageError MalformedMessageError + if errors.As(err, &malformedMessageError) { + err = nil + log.Println(malformedMessageError.Error()) + log.Println(string(m.MessageBytes())) + } + return m, err +} + +func RecoverErroredMessages(m AbstractImapMessage, err error) (AbstractImapMessage, error) { + log.Printf("message %s errored: %v\n", m.Message().Envelope.MessageId, err) + return m, nil +} diff --git a/imap.go b/imap.go deleted file mode 100644 index cb83b5aec054634519d1da8a1c8b81135c1a3bad..0000000000000000000000000000000000000000 --- a/imap.go +++ /dev/null @@ -1,158 +0,0 @@ -package main - -import ( - "io" - "log" - "mime" - "mime/quotedprintable" - "strings" - - "github.com/emersion/go-imap" - "github.com/emersion/go-imap-move" - "github.com/emersion/go-imap/client" - "github.com/emersion/go-sasl" - "github.com/emersion/go-smtp" -) - -type EmptyBoxError struct{} - -func (EmptyBoxError) Error() string { - return "" -} - -func moveMsg(c *client.Client, msg *imap.Message, dest string) { - log.Printf("moving %v : %s from %s to %s\n", msg.Envelope.Date, msg.Envelope.Subject, msg.Envelope.From[0].Address(), dest) - moveClient := move.NewClient(c) - seqSet := new(imap.SeqSet) - seqSet.AddNum(msg.Uid) - moveClient.UidMoveWithFallback(seqSet, dest) -} - -func moveMultiple(c *client.Client, seqSet *imap.SeqSet, dest string) error { - moveClient := move.NewClient(c) - if !seqSet.Empty() { - log.Println("moving multiple to " + dest) - err := moveClient.UidMoveWithFallback(seqSet, dest) - return err - } - return nil -} - -func moveFromQuarantine(c *client.Client, mbox *imap.MailboxStatus, address, dest string) error { - log.Printf("moving %s from quarantine to %s\n", address, dest) - moveClient := move.NewClient(c) - from := uint32(1) - to := mbox.Messages - allMessagesSet := new(imap.SeqSet) - allMessagesSet.AddRange(from, to) - var err error = nil - - messages := make(chan *imap.Message, 10) - done := make(chan error, 1) - go func() { - done <- c.Fetch(allMessagesSet, []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}, messages) - }() - - moveSet := new(imap.SeqSet) - for msg := range messages { - sender := msg.Envelope.From[0] - if sender.Address() == address { - moveSet.AddNum(msg.Uid) - } - } - if !moveSet.Empty() { - err = moveClient.UidMoveWithFallback(moveSet, dest) - } - if err := <-done; err != nil { - return err - } - return err -} - -func sendRepeatedQuarantine(address *imap.Address, lock Lock) { - // todo - log.Printf("sending repeated quarantine to %s from %+v\n", address.Address(), lock) -} - -func sendQuarantine(address *imap.Address) { - // todo - log.Printf("sending quarantine to %s\n", address.Address()) -} - -func forwardMessage(config Config, category, messageID, inReplyTo, subject string, body []byte, recipients []string, sender *imap.Address) error { - // todo reformat, errors, &c. - - msg := "To: " + strings.Join(recipients, ", ") + "\r\n" + - "From: " + makeNameAddress(sender, true) + "\r\n" + - "Message-ID: " + messageID + "\r\n" + - "Subject: " + mime.QEncoding.Encode("utf-8", subject) + "\r\n" - if inReplyTo != "" { - msg = msg + "In-Reply-To: " + inReplyTo + "\r\n" - } - msg += "Reply-To: " + mime.QEncoding.Encode("utf-8", strings.Replace(config.Mimir.RecipientTemplate, "[:]", category, 1)) + "\r\n" + - "Content-Type: text/plain; charset=utf-8\r\n" + - "Content-Transfer-Encoding: quoted-printable\r\n" + - "\r\n" - msgReader := strings.NewReader(msg) - auth := sasl.NewPlainClient("", config.Mimir.ImapUsername, config.Mimir.ImapPassword) - c, err := smtp.DialTLS(config.Mimir.SmtpAddress, nil) - if err != nil { - log.Fatal(err) - } - - if err = c.Auth(auth); err != nil { - log.Fatal(err) - } - - if err := c.Mail(config.Mimir.SmtpSender, nil); err != nil { - log.Fatal(err) - } - for _, recipient := range recipients { - if err := c.Rcpt(recipient, nil); err != nil { - log.Fatal(err) - } - } - - wc, err := c.Data() - if err != nil { - log.Fatal(err) - } - _, err = io.Copy(wc, msgReader) - qpWriter := quotedprintable.NewWriter(wc) - _, err = qpWriter.Write(body) - _, err = qpWriter.Write([]byte("\r\n")) - if err != nil { - log.Fatal(err) - } - err = wc.Close() - if err != nil { - log.Fatal(err) - } - - err = c.Quit() - if err != nil { - log.Fatal(err) - } - return nil -} - -func removeMessage(c *client.Client, msgUid uint32, mailbox string) error { - _, err := c.Select(mailbox, false) - if err != nil { - return err - } - - seqset := new(imap.SeqSet) - seqset.AddNum(msgUid) - - item := imap.FormatFlagsOp(imap.AddFlags, true) - flags := []interface{}{imap.DeletedFlag} - return c.UidStore(seqset, item, flags, nil) -} - -func checkImapEmptyBox(mbox *imap.MailboxStatus) error { - if mbox.Messages == 0 { - return EmptyBoxError{} - } - return nil -} diff --git a/jotunheim/config.go b/jotunheim/config.go new file mode 100644 index 0000000000000000000000000000000000000000..47a6ad55c3a6f2370ca61c13c84ad588ad7b065e --- /dev/null +++ b/jotunheim/config.go @@ -0,0 +1,124 @@ +package jotunheim + +import ( + "fmt" + "os" + + "apiote.xyz/p/go-dirty" +) + +type TyrConfig struct { + ImapAddress string + ImapUsername string + ImapPassword string + ImapFolderInbox string + ImapFolderJunk string + ImapFolderArchive string + ImapFolderTrash string + ImapFolderDrafts string + ImapFolderQuarantine string + ImapFolderSent string + RecipientDomain string + MainEmailAddress string +} + +type HermodrConfig struct { + ImapAddress string + ImapUsername string + ImapPassword string + ImapFolderInbox string + ImapFolderRedirected string + Recipient string + SmtpServer string + SmtpUsername string + PublicKey string +} +type MimirConfig struct { + ImapAddress string + ImapUsername string + ImapPassword string + ImapInbox string + RecipientTemplate string + Categories []string + ForwardAddress string + PersonalAddress string + SmtpAddress string + SmtpSender string + Companion string +} + +type EostreConfig struct { + ImapAddress string + ImapUsername string + ImapPassword string + DiaryImapAddress string + DiaryImapUsername string + DiaryImapPassword string + DiarySmtpAddress string + DiarySmtpUsername string + DiarySmtpPassword string + DiarySubject string + DiarySender string + DiaryRecipient string + AuthorisedSender string + PrivateKeyPass string + PrivateKey string + PublicKey string + DiaryPrivateKey string + DiaryPublicKey string +} + +type GersemiConfig struct { + FireflyToken string + Firefly string + ImapAddress string + ImapUsername string + ImapPassword string + ImapInbox string + MessageMime string + DoneFolder string + DefaultSource string + WithdrawalRegexes []string + DepositRegexes []string + Accounts map[string]string +} + +type Config struct { + Tyr TyrConfig + Hermodr HermodrConfig + Mimir MimirConfig + Eostre EostreConfig + Gersemi GersemiConfig +} + +func Read(configPath string) (Config, error) { + config := Config{} + userConfigDir, err := os.UserConfigDir() + if err != nil { + return config, fmt.Errorf("while getting user config dir: %w", err) + } + possibleConfigs := []string{ + configPath, + "/etc/asgard.dirty", + userConfigDir + "/asgard.dirty", + "asgard.dirty", + } + finalConfigPath := "" + for _, possibleConfig := range possibleConfigs { + _, err := os.Stat(possibleConfig) + if err == nil { + finalConfigPath = possibleConfig + break + } + } + if finalConfigPath == "" { + return config, fmt.Errorf("no config found") + } + file, err := os.Open(finalConfigPath) + if err != nil { + return config, fmt.Errorf("while opening config: %w", err) + } + defer file.Close() + err = dirty.LoadStruct(file, &config) + return config, err +} diff --git a/main.go b/main.go index 3b2acfbbdd9dd9369b7a7459b3bf6c5a1eb8be16..7eb9b6a0e60fd5104f5931b0865ca290d9ab9613 100644 --- a/main.go +++ b/main.go @@ -1,112 +1,25 @@ package main import ( + "embed" "errors" "log" "net/http" "os" - "apiote.xyz/p/go-dirty" + "apiote.xyz/p/asgard/eostre" + "apiote.xyz/p/asgard/gersemi" + "apiote.xyz/p/asgard/hermodr" + "apiote.xyz/p/asgard/himinbjorg" + "apiote.xyz/p/asgard/jotunheim" + "apiote.xyz/p/asgard/mimir" + "apiote.xyz/p/asgard/tyr" + "git.sr.ht/~sircmpwn/getopt" ) -type TyrConfig struct { - ImapAddress string - ImapUsername string - ImapPassword string - ImapFolderInbox string - ImapFolderJunk string - ImapFolderArchive string - ImapFolderTrash string - ImapFolderDrafts string - ImapFolderQuarantine string - ImapFolderSent string -} - -type HermodrConfig struct { - ImapAddress string - ImapUsername string - ImapPassword string - ImapFolderInbox string - ImapFolderRedirected string - Recipient string - SmtpServer string - SmtpUsername string - PublicKey string -} -type MimirConfig struct { - ImapAddress string - ImapUsername string - ImapPassword string - ImapInbox string - RecipientTemplate string - Categories []string - ForwardAddress string - PersonalAddress string - SmtpAddress string - SmtpSender string - Companion string -} - -type EostreConfig struct { - ImapAddress string - ImapUsername string - ImapPassword string - DiaryImapAddress string - DiaryImapUsername string - DiaryImapPassword string - DiarySmtpAddress string - DiarySmtpUsername string - DiarySmtpPassword string - DiarySubject string - DiarySender string - DiaryRecipient string - AuthorisedSender string - PrivateKeyPass string - PrivateKey string - PublicKey string - DiaryPrivateKey string - DiaryPublicKey string -} - -type GersemiConfig struct { - FireflyToken string - Firefly string - ImapAddress string - ImapUsername string - ImapPassword string - ImapInbox string - MessageMime string - DoneFolder string - DefaultSource string - WithdrawalRegexes []string - DepositRegexes []string - Accounts map[string]string -} - -type Config struct { - Tyr TyrConfig - Hermodr HermodrConfig - Mimir MimirConfig - Eostre EostreConfig - Gersemi GersemiConfig -} - -func readConfig(configPath string) (Config, error) { - if configPath == "" { - // TODO try XDG_CONFIG/asgard.dirty, /etc/asgard.dirty, ~/.config/asgard.dirty - configPath = "asgard.dirty" - } - file, err := os.Open(configPath) - if err != nil { - log.Printf("error opening configuration %v\n", err) - return Config{}, err - } - defer file.Close() - config := Config{} - err = dirty.LoadStruct(file, &config) - return config, err -} +//go:embed templates +var templatesFS embed.FS func main() { var ( @@ -114,15 +27,15 @@ configPath string dbPath string ) getopt.StringVar(&configPath, "c", "", "path to config file") - getopt.StringVar(&dbPath, "b", "asgard.db", "path to database file") + getopt.StringVar(&dbPath, "b", "", "path to database file") getopt.Parse() - config, err := readConfig(configPath) + config, err := jotunheim.Read(configPath) if err != nil { log.Fatalln(err) } - //log.Printf("running with conifig\n%+v\n", config) - db, err := migrate(dbPath) + + db, err := himinbjorg.Migrate(dbPath) if err != nil { log.Fatalln(err) } @@ -134,45 +47,43 @@ switch args[0] { case "hermodr": fallthrough case "hermóðr": - hermodr(config) + hermodr.Hermodr(config) case "tyr": fallthrough case "týr": if len(args) == 1 { - tyr(db, config) + tyr.Tyr(db, config) } else { switch args[1] { case "list": - tyr_lists_locks(db) + tyr.ListLocks(db) case "offend": if len(args) == 2 { log.Fatalln("missing token") } - tyr_release(db, config, args[2], "*", config.Tyr.ImapFolderJunk) + tyr.Release(db, config, args[2], "*", config.Tyr.ImapFolderJunk) case "release": if len(args) == 2 { log.Fatalln("missing token (and recipient)") } addressTo := "" - if len(args) == 3 { - addressTo = "*" - } else { + if len(args) != 3 { addressTo = args[3] } - tyr_release(db, config, args[2], addressTo, config.Tyr.ImapFolderInbox) + tyr.Release(db, config, args[2], addressTo, config.Tyr.ImapFolderInbox) } } case "mimir": fallthrough case "mímir": - mimir(db, config) + mimir.Mimir(db, config) case "ēostre": fallthrough case "eostre": - n, err := eostre(config) + n, err := eostre.Eostre(config) if err != nil { log.Println(err) return @@ -182,32 +93,32 @@ if err != nil && errors.Is(err, os.ErrNotExist) { if n == 0 { return } - err = downloadDiary(config) + err = eostre.DownloadDiary(config) if err != nil { log.Println(err) return } } if n > 0 { - err = updateDiary(config) + err = eostre.UpdateDiary(config) if err != nil { log.Println(err) return } } - err = sendDiary(config) + err = eostre.SendDiary(config) if err != nil { log.Println(err) return } case "gersemi": - log.Println(gersemi(config)) + log.Println(gersemi.Gersemi(config)) case "serve": - http.HandleFunc("/tyr", tyr_serve(db, config)) - http.HandleFunc("/mimir", mimir_serve(db)) - http.HandleFunc("/mimir/", mimir_serve(db)) + http.HandleFunc("/tyr", tyr.Serve(db, config, templatesFS)) + http.HandleFunc("/mimir", mimir.Serve(db, templatesFS)) + http.HandleFunc("/mimir/", mimir.Serve(db, templatesFS)) e := http.ListenAndServe(":8081", nil) if e != nil { log.Println(e) diff --git a/mimir/mimir.go b/mimir/mimir.go new file mode 100644 index 0000000000000000000000000000000000000000..94cf421633f2d9670c18ca11c31b20bd988613a9 --- /dev/null +++ b/mimir/mimir.go @@ -0,0 +1,343 @@ +package mimir + +// todo test utf-8 in subject, sender (mímir), body + +// ---- release v1 + +/* todo views: +default: threads click-> thread +thread: linear messages by datetime oldest on top, with #message_id, and is_reply_to: <a href=#message_id> + +search ordered by datetime, newest on top; thread – most recent message + +search by time: messages [in thread] click-> thread#message_id +search by from: messages [in thread] click-> thread#message_id +filter by category: inherit +|search by subject: threads click-> thread +|search by subject+: messages [in thread] click-> thread#message_id +|search by full text: messages [in thread] click-> thread#message_id +*/ + +/* todo moderation: +ban address, right to forget, unsubscribe (from one topic, from all topics) +*/ + +// ---- release v2 + +// todo in thread card: add number of messages and interested people +// todo highlight patches +// todo check pgp/mime signatures + +import ( + "bytes" + "database/sql" + "embed" + "errors" + "fmt" + "html/template" + "log" + "net/http" + "regexp" + "strconv" + "strings" + + "apiote.xyz/p/asgard/himinbjorg" + "apiote.xyz/p/asgard/idavollr" + "apiote.xyz/p/asgard/jotunheim" + + "apiote.xyz/p/gott/v2" + "github.com/emersion/go-imap" + _ "github.com/emersion/go-message/charset" + "github.com/emersion/go-msgauth/dkim" +) + +type UnknownCategoryError struct { + MessageID string + Category string +} + +func (e UnknownCategoryError) Error() string { + return fmt.Sprintf("Unknown category ‘%s’ in message %s", e.Category, e.MessageID) +} + +type ListingPage struct { + Messages []himinbjorg.Message + Page int + NumPages int +} + +func (l ListingPage) PrevPage() int { + return l.Page - 1 +} +func (l ListingPage) NextPage() int { + return l.Page + 1 +} + +type MimirMailbox struct { + idavollr.Mailbox + categories []string + categoryRegexp *regexp.Regexp + db *sql.DB +} + +type MimirImapMessage struct { + idavollr.ImapMessage + categoryRegexp *regexp.Regexp + categories []string + category string + dkimStatus bool + db *sql.DB + config jotunheim.Config + recipients []string + mboxName string +} + +func Mimir(db *sql.DB, config jotunheim.Config) error { + r := gott.R[idavollr.AbstractMailbox]{ + S: &MimirMailbox{ + Mailbox: idavollr.Mailbox{ + MboxName: config.Mimir.ImapInbox, + ImapAdr: config.Mimir.ImapAddress, + ImapUser: config.Mimir.ImapUsername, + ImapPass: config.Mimir.ImapPassword, + Conf: config, + }, + db: db, + }, + }. + Bind(getCategories). + Bind(prepareCategoryRegexp). + Bind(idavollr.Connect). + Tee(idavollr.Login). + Bind(idavollr.SelectInbox). + Tee(idavollr.CheckEmptyBox). + Map(idavollr.FetchMessages). + Tee(archiveMessages). + Tee(idavollr.CheckFetchError). + Tee(idavollr.Expunge). + Recover(idavollr.IgnoreEmptyBox). + Recover(idavollr.Disconnect) + + return r.E +} + +func getCategories(am idavollr.AbstractMailbox) (idavollr.AbstractMailbox, error) { + m := am.(*MimirMailbox) + m.categories = m.Config().Mimir.Categories + if len(m.categories) == 0 { + return m, errors.New("no categories defined") + } + return m, nil +} + +func prepareCategoryRegexp(am idavollr.AbstractMailbox) (idavollr.AbstractMailbox, error) { + m := am.(*MimirMailbox) + if !strings.Contains(m.Config().Mimir.RecipientTemplate, "[:]") { + return m, errors.New("recipient template does not contain ‘[:]’") + } + recipientRegexp := strings.Replace(m.Config().Mimir.RecipientTemplate, "[:]", "(.*)", 1) + r, err := regexp.Compile(recipientRegexp) + m.categoryRegexp = r + return m, err +} + +func archiveMessages(am idavollr.AbstractMailbox) error { + m := am.(*MimirMailbox) + for msg := range m.Messages() { + imapMessage := idavollr.ImapMessage{ + Msg: msg, + Sect: m.Section(), + Mimetype: "text/plain", + } + imapMessage.SetClient(m.Client()) + r := gott.R[idavollr.AbstractImapMessage]{ + S: &MimirImapMessage{ + ImapMessage: imapMessage, + categoryRegexp: m.categoryRegexp, + categories: m.categories, + db: m.db, + config: m.Config(), + mboxName: m.MboxName, + }, + }. + Bind(getMessageCategory). + Bind(idavollr.ReadBody). + Bind(verifyDkim). + Bind(idavollr.ParseMimeMessage). + Bind(idavollr.GetBody). + Bind(idavollr.ReadBody). + Tee(archiveMessage). + Tee(updateTopicRecipients). + Bind(getMessageRecipients). + Tee(forwardMimirMessage). + Recover(idavollr.RecoverMalformedMessage). + Recover(recoverUnknownCategory). + Tee(removeMessage). + Recover(idavollr.RecoverErroredMessages) + if r.E != nil { + return r.E + } + } + return nil +} + +func getMessageCategory(am idavollr.AbstractImapMessage) (idavollr.AbstractImapMessage, error) { + m := am.(*MimirImapMessage) + categoryInAddress := "" + recipients := append(m.Message().Envelope.To, m.Message().Envelope.Cc...) + for _, recipient := range recipients { + matches := m.categoryRegexp.FindStringSubmatch(recipient.Address()) + if len(matches) != 2 { + continue + } + categoryInAddress = matches[1] + for _, category := range m.categories { + if matches[1] == category { + m.category = category + return m, nil + } + } + } + return m, UnknownCategoryError{ + MessageID: m.Message().Envelope.MessageId, + Category: categoryInAddress, + } +} + +func verifyDkim(am idavollr.AbstractImapMessage) (idavollr.AbstractImapMessage, error) { + m := am.(*MimirImapMessage) + dkimStatus := false + r := bytes.NewReader(m.MessageBytes()) + verifications, err := dkim.Verify(r) + if err != nil { + return m, err + } + for _, v := range verifications { + if v.Err == nil && m.Message().Envelope.From[0].HostName == v.Domain { + dkimStatus = true + } + } + m.dkimStatus = dkimStatus + return m, nil +} + +func archiveMessage(am idavollr.AbstractImapMessage) error { + m := am.(*MimirImapMessage) + messageID := m.Message().Envelope.MessageId + subject := m.Message().Envelope.Subject + date := m.Message().Envelope.Date.UTC() + inReplyTo := m.Message().Envelope.InReplyTo + sender := m.Message().Envelope.From[0] + log.Printf("archiving %s\n", messageID) + return himinbjorg.AddArchiveEntry(m.db, messageID, m.category, subject, m.MessageBody(), date, inReplyTo, m.dkimStatus, sender, string(m.MessageBytes())) +} + +func updateTopicRecipients(am idavollr.AbstractImapMessage) error { + m := am.(*MimirImapMessage) + var sender *imap.Address + if len(m.Message().Envelope.ReplyTo) > 0 { + sender = m.Message().Envelope.ReplyTo[0] + } else { + sender = m.Message().Envelope.From[0] + } + messageID := m.Message().Envelope.MessageId + return himinbjorg.UpdateRecipients(m.db, sender, messageID) +} + +func getMessageRecipients(am idavollr.AbstractImapMessage) (idavollr.AbstractImapMessage, error) { + m := am.(*MimirImapMessage) + sender := m.Message().Envelope.From[0] + messageID := m.Message().Envelope.MessageId + recipients, err := himinbjorg.GetRecipients(m.db, messageID, sender) + if sender.Address() != m.config.Mimir.PersonalAddress { + recipients = append(recipients, strings.Replace(m.config.Mimir.ForwardAddress, "[:]", m.category, 1)) + } + m.recipients = recipients + return m, err +} + +func forwardMimirMessage(am idavollr.AbstractImapMessage) error { + m := am.(*MimirImapMessage) + messageID := m.Message().Envelope.MessageId + inReplyTo := m.Message().Envelope.InReplyTo + subject := m.Message().Envelope.Subject + sender := m.Message().Envelope.From[0] + log.Printf("forwarding %s to %v\n", messageID, m.recipients) + return idavollr.ForwardMessage(m.config, m.category, messageID, inReplyTo, subject, m.MessageBody(), m.recipients, sender) +} + +func recoverUnknownCategory(m idavollr.AbstractImapMessage, err error) (idavollr.AbstractImapMessage, error) { + var unknownCategoryError UnknownCategoryError + if errors.As(err, &unknownCategoryError) { + err = nil + log.Println(unknownCategoryError.Error()) + } + return m, err +} + +func removeMessage(am idavollr.AbstractImapMessage) error { + m := am.(*MimirImapMessage) + return idavollr.RemoveMessage(m.Client(), m.Message().Uid, m.mboxName) +} + +func Serve(db *sql.DB, templatesFs embed.FS) func(w http.ResponseWriter, r *http.Request) { + // TODO on back check with cache + return func(w http.ResponseWriter, r *http.Request) { + path := strings.Split(r.URL.Path[1:], "/") + if len(path) == 1 { + r.ParseForm() + pageParam := r.Form.Get("page") + var ( + page int64 = 1 + err error + ) + if pageParam != "" { + page, err = strconv.ParseInt(pageParam, 10, 0) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + log.Println(err) + } + } + messages, numThreads, err := himinbjorg.GetArchivedThreads(db, page) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + log.Println(err) + } + t, err := template.ParseFS(templatesFs, "templates/mimir_threads.html") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + log.Println(err) + } + b := bytes.NewBuffer([]byte{}) + err = t.Execute(b, ListingPage{ + Messages: messages, + Page: int(page), + NumPages: numThreads / 12, + }) + w.Write(b.Bytes()) + } else if len(path) == 3 && path[1] == "m" { + thread, err := himinbjorg.GetArchivedThread(db, path[2]) + if err != nil { + var noMsgErr himinbjorg.NoMessageError + if errors.As(err, &noMsgErr) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(noMsgErr.Error())) + } else { + w.WriteHeader(http.StatusInternalServerError) + log.Println(err) + } + return + } + t, err := template.ParseFS(templatesFs, "templates/mimir_message.html") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + log.Println(err) + } + b := bytes.NewBuffer([]byte{}) + err = t.Execute(b, thread) + w.Write(b.Bytes()) + } else { + w.WriteHeader(http.StatusNotFound) + } + } +} diff --git a/mimir.go b/mimir.go deleted file mode 100644 index 652b8e972578fa55b7edac0a8c3696e2ae89b65c..0000000000000000000000000000000000000000 --- a/mimir.go +++ /dev/null @@ -1,457 +0,0 @@ -package main - -// todo test utf-8 in subject, sender (mímir), body - -// ---- release v1 - -/* todo views: -default: threads click-> thread -thread: linear messages by datetime oldest on top, with #message_id, and is_reply_to: <a href=#message_id> - -search ordered by datetime, newest on top; thread – most recent message - -search by time: messages [in thread] click-> thread#message_id -search by from: messages [in thread] click-> thread#message_id -filter by category: inherit -|search by subject: threads click-> thread -|search by subject+: messages [in thread] click-> thread#message_id -|search by full text: messages [in thread] click-> thread#message_id -*/ - -/* todo moderation: -ban address, right to forget, unsubscribe (from one topic, from all topics) -*/ - -// ---- release v2 - -// todo in thread card: add number of messages and interested people -// todo highlight patches -// todo check pgp/mime signatures - -import ( - "bytes" - "database/sql" - "errors" - "fmt" - "html/template" - "io" - "log" - "net/http" - "regexp" - "strconv" - "strings" - - "apiote.xyz/p/gott/v2" - "github.com/emersion/go-imap" - "github.com/emersion/go-imap/client" - "github.com/emersion/go-message" - _ "github.com/emersion/go-message/charset" - "github.com/emersion/go-msgauth/dkim" -) - -type MalformedMessageError struct { - MessageID string - Cause error -} - -func (e MalformedMessageError) Error() string { - return fmt.Sprintf("Malformed message %s: %s", e.MessageID, e.Cause.Error()) -} - -type UnknownCategoryError struct { - MessageID string - Category string -} - -func (e UnknownCategoryError) Error() string { - return fmt.Sprintf("Unknown category ‘%s’ in message %s", e.Category, e.MessageID) -} - -type ListingPage struct { - Messages []Message - Page int - NumPages int -} - -func (l ListingPage) PrevPage() int { - return l.Page - 1 -} -func (l ListingPage) NextPage() int { - return l.Page + 1 -} - -type mimirStruct struct { - mboxName string - c *client.Client - config Config - db *sql.DB - - categories []string - categoryRegexp *regexp.Regexp - mbox *imap.MailboxStatus - messages chan *imap.Message - section *imap.BodySectionName - done chan error - - message *imap.Message - category string - messageBytes []byte - dkimStatus bool - mimeMessage *message.Entity - plainTextBodyReader io.Reader - plainTextBody []byte - recipients []string -} - -func getCategories(s mimirStruct) (mimirStruct, error) { - s.categories = s.config.Mimir.Categories - if len(s.categories) == 0 { - return s, errors.New("no categories defined") - } - return s, nil -} - -func prepareCategoryRegexp(s mimirStruct) (mimirStruct, error) { - if !strings.Contains(s.config.Mimir.RecipientTemplate, "[:]") { - return s, errors.New("recipient template does not contain ‘[:]’") - } - recipientRegexp := strings.Replace(s.config.Mimir.RecipientTemplate, "[:]", "(.*)", 1) - r, err := regexp.Compile(recipientRegexp) - s.categoryRegexp = r - return s, err -} - -func checkEmptyBox(s mimirStruct) error { - return checkImapEmptyBox(s.mbox) -} - -func selectMimirInbox(s mimirStruct) (mimirStruct, error) { - mbox, err := s.c.Select(s.mboxName, true) - s.mbox = mbox - return s, err -} - -func fetchMessages(s mimirStruct) mimirStruct { - from := uint32(1) - to := s.mbox.Messages - seqset := new(imap.SeqSet) - seqset.AddRange(from, to) - - s.section = &imap.BodySectionName{} - items := []imap.FetchItem{imap.FetchEnvelope, s.section.FetchItem(), imap.FetchUid} - s.messages = make(chan *imap.Message, 10) - s.done = make(chan error, 1) - go func() { - s.done <- s.c.Fetch(seqset, items, s.messages) - }() - return s -} - -func getMessageCategory(s mimirStruct) (mimirStruct, error) { - categoryInAddress := "" - recipients := append(s.message.Envelope.To, s.message.Envelope.Cc...) - for _, recipient := range recipients { - matches := s.categoryRegexp.FindStringSubmatch(recipient.Address()) - if len(matches) != 2 { - continue - } - categoryInAddress = matches[1] - for _, category := range s.categories { - if matches[1] == category { - s.category = category - return s, nil - } - } - } - return s, UnknownCategoryError{ - MessageID: s.message.Envelope.MessageId, - Category: categoryInAddress, - } -} - -func readMimirBody(s mimirStruct) (mimirStruct, error) { - r := s.message.GetBody(s.section) - if r == nil { - return s, MalformedMessageError{ - Cause: errors.New("no body in message"), - MessageID: s.message.Envelope.MessageId, - } - } - messageBytes, err := io.ReadAll(r) - s.messageBytes = messageBytes - return s, err -} - -func verifyDkim(s mimirStruct) (mimirStruct, error) { - dkimStatus := false - r := bytes.NewReader(s.messageBytes) - verifications, err := dkim.Verify(r) - if err != nil { - return s, err - } - for _, v := range verifications { - if v.Err == nil && s.message.Envelope.From[0].HostName == v.Domain { - dkimStatus = true - } - } - s.dkimStatus = dkimStatus - return s, nil -} - -func parseMimeMessage(s mimirStruct) (mimirStruct, error) { - r := bytes.NewReader(s.messageBytes) - m, err := message.Read(r) - s.mimeMessage = m - return s, err -} - -func getNextPart(message *message.Entity, ID string, mimetype string) (io.Reader, error) { - if mr := message.MultipartReader(); mr != nil { - for { - p, err := mr.NextPart() - if err == io.EOF { - break - } else if err != nil { - return nil, fmt.Errorf("while reading next part: %w", err) - } - return getNextPart(p, ID, mimetype) - } - } else { - t, _, err := message.Header.ContentType() - if err != nil { - return nil, fmt.Errorf("while getting content type: %w", err) - } - if t == mimetype { - return message.Body, nil - } - } - return nil, MalformedMessageError{ - Cause: errors.New(mimetype + " not found"), - MessageID: ID, - } -} - -func getPlainTextBody(s mimirStruct) (mimirStruct, error) { - body, err := getNextPart(s.mimeMessage, s.message.Envelope.MessageId, "text/plain") - s.plainTextBodyReader = body - return s, err -} - -func readPlainTextBody(s mimirStruct) (mimirStruct, error) { - body, err := io.ReadAll(s.plainTextBodyReader) - s.plainTextBody = body - return s, err -} - -func archiveMessage(s mimirStruct) error { - messageID := s.message.Envelope.MessageId - subject := s.message.Envelope.Subject - date := s.message.Envelope.Date.UTC() - inReplyTo := s.message.Envelope.InReplyTo - sender := s.message.Envelope.From[0] - log.Printf("archiving %s\n", messageID) - return addArchiveEntry(s.db, messageID, s.category, subject, s.plainTextBody, date, inReplyTo, s.dkimStatus, sender, string(s.messageBytes)) -} - -func updateTopicRecipients(s mimirStruct) error { - var sender *imap.Address - if len(s.message.Envelope.ReplyTo) > 0 { - sender = s.message.Envelope.ReplyTo[0] - } else { - sender = s.message.Envelope.From[0] - } - messageID := s.message.Envelope.MessageId - return updateRecipients(s.db, sender, messageID) -} - -func getMessageRecipients(s mimirStruct) (mimirStruct, error) { - sender := s.message.Envelope.From[0] - messageID := s.message.Envelope.MessageId - recipients, err := getRecipients(s.db, messageID, sender) - if sender.Address() != s.config.Mimir.PersonalAddress { - recipients = append(recipients, strings.Replace(s.config.Mimir.ForwardAddress, "[:]", s.category, 1)) - } - s.recipients = recipients - return s, err -} - -func forwardMimirMessage(s mimirStruct) error { - messageID := s.message.Envelope.MessageId - inReplyTo := s.message.Envelope.InReplyTo - subject := s.message.Envelope.Subject - sender := s.message.Envelope.From[0] - log.Printf("forwarding %s to %v\n", messageID, s.recipients) - return forwardMessage(s.config, s.category, messageID, inReplyTo, subject, s.plainTextBody, s.recipients, sender) -} - -func recoverMalformedMessage(s mimirStruct, err error) (mimirStruct, error) { - var malformedMessageError MalformedMessageError - if errors.As(err, &malformedMessageError) { - err = nil - log.Println(malformedMessageError.Error()) - log.Println(string(s.messageBytes)) - } - return s, err -} - -func recoverUnknownCategory(s mimirStruct, err error) (mimirStruct, error) { - var unknownCategoryError UnknownCategoryError - if errors.As(err, &unknownCategoryError) { - err = nil - log.Println(unknownCategoryError.Error()) - } - return s, err -} - -func removeMimirMessage(s mimirStruct) error { - return removeMessage(s.c, s.message.Uid, s.mboxName) -} - -func recoverErroredMessages(s mimirStruct, err error) (mimirStruct, error) { - log.Printf("message %s errored: %v\n", s.message.Envelope.MessageId, err) - return s, nil -} - -func archiveMessages(s mimirStruct) (mimirStruct, error) { - for msg := range s.messages { - s.message = msg - r := gott.R[mimirStruct]{S: s}. - Bind(getMessageCategory). - Bind(readMimirBody). - Bind(verifyDkim). - Bind(parseMimeMessage). - Bind(getPlainTextBody). - Bind(readPlainTextBody). - Tee(archiveMessage). - Tee(updateTopicRecipients). - Bind(getMessageRecipients). - Tee(forwardMimirMessage). - Recover(recoverMalformedMessage). - Recover(recoverUnknownCategory). - Tee(removeMimirMessage). - Recover(recoverErroredMessages) - if r.E != nil { - return s, r.E - } - } - return s, nil -} - -func checkFetchError(s mimirStruct) error { - return <-s.done -} - -func expunge(s mimirStruct) error { - return s.c.Expunge(nil) -} - -func ignoreEmptyBox(s mimirStruct, e error) (mimirStruct, error) { - var emptyBoxError EmptyBoxError - if errors.As(e, &emptyBoxError) { - log.Println("Mailbox is empty") - return s, nil - } - return s, e -} - -func archiveInbox(db *sql.DB, mboxName string, c *client.Client, config Config) error { - r := gott.R[mimirStruct]{ - S: mimirStruct{ - mboxName: mboxName, - c: c, - config: config, - db: db, - }, - }. - Bind(getCategories). - Bind(prepareCategoryRegexp). - Bind(selectMimirInbox). - Tee(checkEmptyBox). - Map(fetchMessages). - Bind(archiveMessages). - Tee(checkFetchError). - Tee(expunge). - Recover(ignoreEmptyBox) - - return r.E -} - -func mimir(db *sql.DB, config Config) { - c, err := client.DialTLS(config.Mimir.ImapAddress, nil) - if err != nil { - log.Fatalln(err) - } - log.Println("Connected") - defer c.Close() - defer c.Logout() - if err := c.Login(config.Mimir.ImapUsername, config.Mimir.ImapPassword); err != nil { - log.Fatalln(err) - } - log.Println("Logged in") - err = archiveInbox(db, config.Mimir.ImapInbox, c, config) - if err != nil { - log.Fatalln(err) - } -} - -func mimir_serve(db *sql.DB) func(w http.ResponseWriter, r *http.Request) { - // todo on back check with cache - return func(w http.ResponseWriter, r *http.Request) { - path := strings.Split(r.URL.Path[1:], "/") - if len(path) == 1 { - r.ParseForm() - pageParam := r.Form.Get("page") - var ( - page int64 = 1 - err error - ) - if pageParam != "" { - page, err = strconv.ParseInt(pageParam, 10, 0) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - log.Println(err) - } - } - messages, numThreads, err := getArchivedThreads(db, page) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - log.Println(err) - } - t, err := template.ParseFiles("templates/mimir_threads.html") - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - log.Println(err) - } - b := bytes.NewBuffer([]byte{}) - err = t.Execute(b, ListingPage{ - Messages: messages, - Page: int(page), - NumPages: numThreads / 12, - }) - w.Write(b.Bytes()) - } else if len(path) == 3 && path[1] == "m" { - thread, err := getArchivedThread(db, path[2]) - if err != nil { - var noMsgErr NoMessageError - if errors.As(err, &noMsgErr) { - w.WriteHeader(http.StatusNotFound) - w.Write([]byte(noMsgErr.Error())) - } else { - w.WriteHeader(http.StatusInternalServerError) - log.Println(err) - } - return - } - t, err := template.ParseFiles("templates/mimir_message.html") - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - log.Println(err) - } - b := bytes.NewBuffer([]byte{}) - err = t.Execute(b, thread) - w.Write(b.Bytes()) - } else { - w.WriteHeader(http.StatusNotFound) - } - } -} diff --git a/tyr/tyr.go b/tyr/tyr.go new file mode 100644 index 0000000000000000000000000000000000000000..4ba18414d621365629c4d4d45e6c8d7369ead6e8 --- /dev/null +++ b/tyr/tyr.go @@ -0,0 +1,365 @@ +package tyr + +import ( + "bytes" + "crypto/sha256" + "database/sql" + "embed" + "fmt" + "html/template" + "io" + "log" + "net/http" + "strings" + "time" + + "apiote.xyz/p/asgard/himinbjorg" + "apiote.xyz/p/asgard/idavollr" + "apiote.xyz/p/asgard/jotunheim" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/client" +) + +func addSentTo(db *sql.DB, c *client.Client, mbox *imap.MailboxStatus, config jotunheim.Config) error { + from := uint32(1) + to := mbox.Messages + seqset := new(imap.SeqSet) + seqset.AddRange(from, to) + + messages := make(chan *imap.Message, 10) + done := make(chan error, 1) + go func() { + done <- c.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope}, messages) + }() + + for msg := range messages { + recipients := append(msg.Envelope.To, msg.Envelope.Cc...) + sender := msg.Envelope.From[0].Address() + for _, recipient := range recipients { + knownAddress := himinbjorg.KnownAddress{ + AddressFrom: recipient.Address(), + AddressTo: sender, + Ban: false, + } + err := himinbjorg.InsertKnownAddress(db, knownAddress) + if err != nil { + log.Println(err) + continue + } + } + } + + clearLocks(db, config) + + if err := <-done; err != nil { + return err + } + return nil +} + +func clearLocks(db *sql.DB, config jotunheim.Config) { + locks, _ := himinbjorg.ListLocks(db) + for _, lock := range locks { + knownAddresses, _ := himinbjorg.GetKnownAddress(db, lock.Address) + for _, knownAddress := range knownAddresses { + if knownAddress.AddressTo == lock.Recipient { + releaseQuarantine(db, config, lock, config.Tyr.ImapFolderInbox) + } + } + } +} + +func listInboxes(c *client.Client, config jotunheim.Config) ([]*imap.MailboxInfo, error) { + mailboxes := make(chan *imap.MailboxInfo, 10) + inboxes := []*imap.MailboxInfo{} + done := make(chan error, 1) + go func() { + done <- c.List("", "*", mailboxes) + }() + + for m := range mailboxes { + if m.Name != config.Tyr.ImapFolderArchive && m.Name != config.Tyr.ImapFolderDrafts && m.Name != config.Tyr.ImapFolderJunk && + m.Name != config.Tyr.ImapFolderQuarantine && m.Name != config.Tyr.ImapFolderSent && m.Name != config.Tyr.ImapFolderTrash { + inboxes = append(inboxes, m) + } + } + + if err := <-done; err != nil { + return inboxes, err + } + return inboxes, nil +} + +func checkInbox(db *sql.DB, config jotunheim.Config, c *client.Client, mbox *imap.MailboxStatus) error { + from := uint32(1) + to := mbox.Messages + seqset := new(imap.SeqSet) + moveSet := new(imap.SeqSet) + seqset.AddRange(from, to) + + messages := make(chan *imap.Message, 10) + done := make(chan error, 1) + go func() { + done <- c.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid}, messages) + }() + +messagesLoop: + for msg := range messages { + for _, flag := range msg.Flags { + if flag == imap.FlaggedFlag { + log.Printf("Ignoring %s as flagged\n", msg.Envelope.Subject) + continue messagesLoop + } + } + recipients := append(msg.Envelope.To, msg.Envelope.Cc...) + recipients = append(recipients, msg.Envelope.Bcc...) + domainRecipient := findDomainRecipient(recipients, config) + recipients_ := map[string]struct{}{} + for _, recipient := range recipients { + recipients_[recipient.Address()] = struct{}{} + } + sender := msg.Envelope.From[0] + + senderRows, err := himinbjorg.GetKnownAddress(db, sender.Address()) + if err != nil { + log.Println(err) + continue + } + lock, err := himinbjorg.GetAddressLock(db, sender.Address()) + if err != nil { + log.Println(err) + continue + } + + for _, senderRow := range senderRows { + if _, present := recipients_[senderRow.AddressTo]; present || senderRow.AddressTo == "*" { + if senderRow.Ban { + idavollr.MoveMsg(c, msg, config.Tyr.ImapFolderJunk) + log.Printf("%s -> %s is a known offender\n", senderRow.AddressFrom, senderRow.AddressTo) + } else { + log.Printf("%s -> %s is a known friend\n", senderRow.AddressFrom, senderRow.AddressTo) + } + continue messagesLoop + } + } + + if !lock.Empty() { + now := time.Now() + weekBefore := now.AddDate(0, 0, -7) + if lock.Date.Before(weekBefore) { + if domainRecipient == config.Tyr.MainEmailAddress { + idavollr.SendRepeatedQuarantine(sender, lock) + } + lock.Date = time.Now() + himinbjorg.UpdateLock(db, lock) + } else { + log.Printf("lock %+v is still valid\n", lock) + } + log.Printf("moving repeated %v : %s from %s to quarantine\n", msg.Envelope.Date, msg.Envelope.Subject, msg.Envelope.From[0].Address()) + moveSet.AddNum(msg.Uid) + continue + } + + if domainRecipient == config.Tyr.MainEmailAddress { + idavollr.SendQuarantine(sender) + } + lock = himinbjorg.NewLock(sender.Address(), domainRecipient) + himinbjorg.InsertLock(db, lock) + log.Printf("moving %v : %s from %s to %s to quarantine\n", msg.Envelope.Date, msg.Envelope.Subject, msg.Envelope.From[0].Address(), domainRecipient) + moveSet.AddNum(msg.Uid) + } + + if err := <-done; err != nil { + return err + } + return idavollr.MoveMultiple(c, moveSet, config.Tyr.ImapFolderQuarantine) +} + +func findDomainRecipient(recipients []*imap.Address, config jotunheim.Config) string { + for _, recipient := range recipients { + if recipient.HostName == config.Tyr.RecipientDomain { + return recipient.Address() + } + } + return config.Tyr.MainEmailAddress +} + +func Tyr(db *sql.DB, config jotunheim.Config) { + c, err := client.DialTLS(config.Tyr.ImapAddress, nil) + if err != nil { + log.Fatalln(err) + } + log.Println("Connected") + defer c.Logout() + if err := c.Login(config.Tyr.ImapUsername, config.Tyr.ImapPassword); err != nil { + log.Fatalln(err) + } + log.Println("Logged in") + + mbox, err := c.Select(config.Tyr.ImapFolderSent, false) + if err != nil { + log.Fatalln(err) + } + err = addSentTo(db, c, mbox, config) + if err != nil { + log.Fatalln(err) + } + + inboxes, err := listInboxes(c, config) + if err != nil { + log.Fatalln(err) + } +inboxLoop: + for _, inbox := range inboxes { + for _, attribute := range inbox.Attributes { + if attribute == "\\Noselect" { + continue inboxLoop + } + } + mbox, err := c.Select(inbox.Name, false) + if err != nil { + log.Fatalln(err) + } + checkInbox(db, config, c, mbox) + } +} + +func ListLocks(db *sql.DB) { + locks, err := himinbjorg.ListLocks(db) + if err != nil { + log.Fatalln(err) + } + if len(locks) == 0 { + fmt.Println("no locks") + } + for _, lock := range locks { + fmt.Printf("%s: %s\n", lock.Token, lock.Address) + } +} + +func Release(db *sql.DB, config jotunheim.Config, token, addressTo, dest string) { + lock, err := himinbjorg.GetLock(db, token) + if err != nil { + log.Fatalln(err) + } + if addressTo != "" { + lock.Recipient = addressTo + } + err = releaseQuarantine(db, config, lock, dest) + if err != nil { + log.Fatalln(err) + } +} + +type TyrData struct { + Address string + Token string + Captcha string + Error string +} + +func releaseQuarantine(db *sql.DB, config jotunheim.Config, lock himinbjorg.Lock, dest string) error { + himinbjorg.DeleteLock(db, lock) + knownAddress := himinbjorg.KnownAddress{ + AddressFrom: lock.Address, + AddressTo: lock.Recipient, + Ban: dest == config.Tyr.ImapFolderJunk, + } + err := himinbjorg.InsertKnownAddress(db, knownAddress) + if err != nil { + return err + } + c, err := client.DialTLS(config.Tyr.ImapAddress, nil) + if err != nil { + return err + } + defer c.Logout() + if err := c.Login(config.Tyr.ImapUsername, config.Tyr.ImapPassword); err != nil { + log.Fatalln(err) + } + mbox, err := c.Select(config.Tyr.ImapFolderQuarantine, false) + if err != nil { + log.Fatalln(err) + } + idavollr.MoveFromQuarantine(c, mbox, lock.Address, dest) + return nil +} + +func Serve(db *sql.DB, config jotunheim.Config, templatesFs embed.FS) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + formAddress := r.Form.Get("address") + formToken := r.Form.Get("token") + formError := r.Form.Get("error") + if r.Method == "GET" { + tyrData := TyrData{ + Address: formAddress, + Token: formToken, + Captcha: "$696a04444feea781aeca9c546e220e0981aff4a8db0b2998decdf13265a95c31", // todo with salt and randomised time + Error: formError, + } + t, _ := template.ParseFS(templatesFs, "templates/tyr.html") + b := bytes.NewBuffer([]byte{}) + _ = t.Execute(b, tyrData) + io.Copy(w, b) + } else if r.Method == "POST" { + formCaptcha := r.Form.Get("captcha") + captchaResult := strings.Split(r.Form.Get("captcha_result"), "$") + shaCaptcha := sha256.Sum256([]byte(formCaptcha + captchaResult[0])) + hexCaptcha := fmt.Sprintf("%x", shaCaptcha) + if hexCaptcha != captchaResult[1] { + w.Header().Add("Location", "?error=captcha") + w.WriteHeader(303) + return + } + + lock, err := himinbjorg.GetAddressLock(db, "*") + if err != nil { + w.WriteHeader(500) + w.Write([]byte(err.Error())) + return + } + if lock.Token != "" && lock.Token == formToken { + lock.Address = formAddress + err = releaseQuarantine(db, config, lock, config.Tyr.ImapFolderInbox) + if err != nil { + w.WriteHeader(500) + w.Write([]byte(err.Error())) + } else { + w.Header().Add("Location", "?error=success") + w.WriteHeader(303) + } + return + } + + lock, err = himinbjorg.GetAddressLock(db, formAddress) + if err != nil { + w.WriteHeader(500) + w.Write([]byte(err.Error())) + return + } + if lock.Token == "" { + w.Header().Add("Location", "?error=address&address="+formAddress) + w.WriteHeader(303) + return + } + if lock.Token != formToken { + w.Header().Add("Location", "?error=token&address="+formAddress) + w.WriteHeader(303) + return + } + err = releaseQuarantine(db, config, lock, config.Tyr.ImapFolderInbox) + if err != nil { + w.WriteHeader(500) + w.Write([]byte(err.Error())) + } else { + w.Header().Add("Location", "?error=success") + w.WriteHeader(303) + } + return + } else { + w.WriteHeader(405) + } + } +} diff --git a/tyr.go b/tyr.go deleted file mode 100644 index c4f2459c9bb2cf2ff20ee7044d21e7ecf9521bb6..0000000000000000000000000000000000000000 --- a/tyr.go +++ /dev/null @@ -1,366 +0,0 @@ -package main - -import ( - "bytes" - "crypto/sha256" - "database/sql" - "fmt" - "html/template" - "io" - "log" - "math/rand" - "net/http" - "strconv" - "strings" - "time" - - "github.com/emersion/go-imap" - "github.com/emersion/go-imap/client" -) - -type KnownAddress struct { - addressFrom string - addressTo string - ban bool -} - -func (a KnownAddress) empty() bool { - return a.addressFrom == "" && a.addressTo == "" -} - -type Lock struct { - address string - token string - date time.Time -} - -func NewLock(address string) Lock { - token := strconv.FormatUint(rand.Uint64(), 16) - lock := Lock{ - address: address, - token: token, - date: time.Now(), - } - return lock -} - -func (l Lock) empty() bool { - return l.address == "" && l.token == "" && l.date.IsZero() -} - -/* ASGARD */ - -func addSentTo(db *sql.DB, c *client.Client, mbox *imap.MailboxStatus) error { - // todo also release from Quarantine - from := uint32(1) - to := mbox.Messages - seqset := new(imap.SeqSet) - seqset.AddRange(from, to) - - messages := make(chan *imap.Message, 10) - done := make(chan error, 1) - go func() { - done <- c.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope}, messages) - }() - - for msg := range messages { - recipients := append(msg.Envelope.To, msg.Envelope.Cc...) - for _, recipient := range recipients { - knownAddress := KnownAddress{ - addressFrom: recipient.Address(), - addressTo: "*", - ban: false, - } - err := insertKnownAddress(db, knownAddress) - if err != nil { - log.Println(err) - continue - } - } - } - - if err := <-done; err != nil { - return err - } - return nil -} - -func listInboxes(c *client.Client, config Config) ([]*imap.MailboxInfo, error) { - mailboxes := make(chan *imap.MailboxInfo, 10) - inboxes := []*imap.MailboxInfo{} - done := make(chan error, 1) - go func() { - done <- c.List("", "*", mailboxes) - }() - - for m := range mailboxes { - if m.Name != config.Tyr.ImapFolderArchive && m.Name != config.Tyr.ImapFolderDrafts && m.Name != config.Tyr.ImapFolderJunk && - m.Name != config.Tyr.ImapFolderQuarantine && m.Name != config.Tyr.ImapFolderSent && m.Name != config.Tyr.ImapFolderTrash { - inboxes = append(inboxes, m) - } - } - - if err := <-done; err != nil { - return inboxes, err - } - return inboxes, nil -} - -func checkInbox(db *sql.DB, config Config, c *client.Client, mbox *imap.MailboxStatus) error { - rand.Seed(time.Now().UnixNano()) - from := uint32(1) - to := mbox.Messages - seqset := new(imap.SeqSet) - moveSet := new(imap.SeqSet) - seqset.AddRange(from, to) - - messages := make(chan *imap.Message, 10) - done := make(chan error, 1) - go func() { - done <- c.Fetch(seqset, []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid}, messages) - }() - -messagesLoop: - for msg := range messages { - for _, flag := range msg.Flags { - if flag == imap.FlaggedFlag { - log.Printf("Ignoring %s as flagged\n", msg.Envelope.Subject) - continue messagesLoop - } - } - recipients := append(msg.Envelope.To, msg.Envelope.Cc...) - recipients_ := map[string]struct{}{} - for _, recipient := range recipients { - recipients_[recipient.Address()] = struct{}{} - } - sender := msg.Envelope.From[0] - - senderRows, err := getKnownAddress(db, sender.Address()) - if err != nil { - log.Println(err) - continue - } - lock, err := getAddressLock(db, sender.Address()) - if err != nil { - log.Println(err) - continue - } - - for _, senderRow := range senderRows { - if _, present := recipients_[senderRow.addressTo]; present || senderRow.addressTo == "*" { - if senderRow.ban { - moveMsg(c, msg, config.Tyr.ImapFolderJunk) - log.Printf("%s -> %s is a known offender\n", senderRow.addressFrom, senderRow.addressTo) - } else { - log.Printf("%s -> %s is a known friend\n", senderRow.addressFrom, senderRow.addressTo) - } - continue messagesLoop - } - } - - if !lock.empty() { - now := time.Now() - weekBefore := now.AddDate(0, 0, -7) - if lock.date.Before(weekBefore) { - sendRepeatedQuarantine(sender, lock) - lock.date = time.Now() - updateLock(db, lock) - } else { - log.Printf("lock %+v is still valid\n", lock) - } - log.Printf("moving repeated %v : %s from %s to quarantine\n", msg.Envelope.Date, msg.Envelope.Subject, msg.Envelope.From[0].Address()) - moveSet.AddNum(msg.Uid) - continue - } - - sendQuarantine(sender) - lock = NewLock(sender.Address()) - insertLock(db, lock) - log.Printf("moving %v : %s from %s to quarantine\n", msg.Envelope.Date, msg.Envelope.Subject, msg.Envelope.From[0].Address()) - moveSet.AddNum(msg.Uid) - } - - if err := <-done; err != nil { - return err - } - return moveMultiple(c, moveSet, config.Tyr.ImapFolderQuarantine) -} - -func tyr(db *sql.DB, config Config) { - c, err := client.DialTLS(config.Tyr.ImapAddress, nil) - if err != nil { - log.Fatalln(err) - } - log.Println("Connected") - defer c.Logout() - if err := c.Login(config.Tyr.ImapUsername, config.Tyr.ImapPassword); err != nil { - log.Fatalln(err) - } - log.Println("Logged in") - - mbox, err := c.Select(config.Tyr.ImapFolderSent, false) - if err != nil { - log.Fatalln(err) - } - err = addSentTo(db, c, mbox) - if err != nil { - log.Fatalln(err) - } - - inboxes, err := listInboxes(c, config) - if err != nil { - log.Fatalln(err) - } -inboxLoop: - for _, inbox := range inboxes { - for _, attribute := range inbox.Attributes { - if attribute == "\\Noselect" { - continue inboxLoop - } - } - mbox, err := c.Select(inbox.Name, false) - if err != nil { - log.Fatalln(err) - } - checkInbox(db, config, c, mbox) - } -} - -func tyr_lists_locks(db *sql.DB) { - locks, err := listLocks(db) - if err != nil { - log.Fatalln(err) - } - if len(locks) == 0 { - fmt.Println("no locks") - } - for _, lock := range locks { - fmt.Printf("%s: %s\n", lock.token, lock.address) - } -} - -func tyr_release(db *sql.DB, config Config, token, addressTo, dest string) { - lock, err := getLock(db, token) - if err != nil { - log.Fatalln(err) - } - err = releaseQuarantine(db, config, lock, addressTo, dest) - if err != nil { - log.Fatalln(err) - } -} - -type TyrData struct { - Address string - Token string - Captcha string - Error string -} - -func releaseQuarantine(db *sql.DB, config Config, lock Lock, addressTo, dest string) error { - deleteLock(db, lock) - knownAddress := KnownAddress{ - addressFrom: lock.address, - addressTo: addressTo, - ban: false, - } - if dest == config.Tyr.ImapFolderJunk { - knownAddress.ban = true - } - err := insertKnownAddress(db, knownAddress) - if err != nil { - return err - } - c, err := client.DialTLS(config.Tyr.ImapAddress, nil) - if err != nil { - return err - } - defer c.Logout() - if err := c.Login(config.Tyr.ImapUsername, config.Tyr.ImapPassword); err != nil { - log.Fatalln(err) - } - mbox, err := c.Select(config.Tyr.ImapFolderQuarantine, false) - if err != nil { - log.Fatalln(err) - } - moveFromQuarantine(c, mbox, lock.address, dest) - return nil -} - -func tyr_serve(db *sql.DB, config Config) func(w http.ResponseWriter, r *http.Request) { - return func(w http.ResponseWriter, r *http.Request) { - r.ParseForm() - formAddress := r.Form.Get("address") - formToken := r.Form.Get("token") - formError := r.Form.Get("error") - if r.Method == "GET" { - tyrData := TyrData{ - Address: formAddress, - Token: formToken, - Captcha: "$696a04444feea781aeca9c546e220e0981aff4a8db0b2998decdf13265a95c31", // todo with salt and randomised time - Error: formError, - } - t, _ := template.ParseFiles("templates/tyr.html") - b := bytes.NewBuffer([]byte{}) - _ = t.Execute(b, tyrData) - io.Copy(w, b) - } else if r.Method == "POST" { - formCaptcha := r.Form.Get("captcha") - captchaResult := strings.Split(r.Form.Get("captcha_result"), "$") - shaCaptcha := sha256.Sum256([]byte(formCaptcha + captchaResult[0])) - hexCaptcha := fmt.Sprintf("%x", shaCaptcha) - if hexCaptcha != captchaResult[1] { - w.Header().Add("Location", "?error=captcha") - w.WriteHeader(303) - return - } - - lock, err := getAddressLock(db, "*") - if err != nil { - w.WriteHeader(500) - w.Write([]byte(err.Error())) - return - } - if lock.token != "" && lock.token == formToken { - lock.address = formAddress - err = releaseQuarantine(db, config, lock, "*", config.Tyr.ImapFolderInbox) - if err != nil { - w.WriteHeader(500) - w.Write([]byte(err.Error())) - } else { - w.Header().Add("Location", "?error=success") - w.WriteHeader(303) - } - return - } - - lock, err = getAddressLock(db, formAddress) - if err != nil { - w.WriteHeader(500) - w.Write([]byte(err.Error())) - return - } - if lock.token == "" { - w.Header().Add("Location", "?error=address&address="+formAddress) - w.WriteHeader(303) - return - } - if lock.token != formToken { - w.Header().Add("Location", "?error=token&address="+formAddress) - w.WriteHeader(303) - return - } - err = releaseQuarantine(db, config, lock, "*", config.Tyr.ImapFolderInbox) - if err != nil { - w.WriteHeader(500) - w.Write([]byte(err.Error())) - } else { - w.Header().Add("Location", "?error=success") - w.WriteHeader(303) - } - return - } else { - w.WriteHeader(405) - } - } -} diff --git a/vor/vor.go b/vor/vor.go new file mode 100644 index 0000000000000000000000000000000000000000..8ccb8f0d76044d30c1a2c4a852d5adc7c1b3250f --- /dev/null +++ b/vor/vor.go @@ -0,0 +1,4 @@ +package main + +func vor() { +} diff --git a/vor.go b/vor.go deleted file mode 100644 index 8ccb8f0d76044d30c1a2c4a852d5adc7c1b3250f..0000000000000000000000000000000000000000 --- a/vor.go +++ /dev/null @@ -1,4 +0,0 @@ -package main - -func vor() { -}