Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Test

on:
pull_request:
push:
branches: [master]

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- run: go test -race ./...
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ You can either set this project up manually or with a simple docker compose setu
Please referer to the software design document before starting development: `DESIGN.md`

See issues for suggested features.
### Tests

Run the unit tests and race detector with `go test -race ./...`. Tests use
Testify assertions, isolated configuration files, and a fake mail service;
they do not require Google credentials or a running service. CI runs the same
command for every pull request, including stacked branches.

### Manual
Make sure you have golang installed and you `$GOPATH` setup.
1. Follow the steps in [Setup](#setup) and enable debug mode.
Expand Down Expand Up @@ -124,4 +131,4 @@ services:

```

Other services would then be able to reach this service on `http://gotify:8080/...` with `123abc` as the preshared key
Other services would then be able to reach this service on `http://gotify:8080/...` with `123abc` as the preshared key
29 changes: 17 additions & 12 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,26 @@ import (
)

func loadConfig() error {
viper.SetDefault("port", "8080")
viper.SetDefault("debug-mode", false)
viper.SetDefault("google-mail.keyfile", "gapps.json")
viper.SetDefault("mock-mode", false)
viper.SetDefault("max-mail-size", 20e6)
return readConfig(viper.GetViper(), "/etc/gotify/", "$HOME/.gotify/", ".")
}

// readConfig accepts explicit locations so configuration can be tested in isolation.
func readConfig(config *viper.Viper, paths ...string) error {
config.SetDefault("port", "8080")
config.SetDefault("debug-mode", false)
config.SetDefault("google-mail.keyfile", "gapps.json")
config.SetDefault("mock-mode", false)
config.SetDefault("max-mail-size", 20e6)

viper.SetEnvPrefix("gotify")
viper.AutomaticEnv()
config.SetEnvPrefix("gotify")
config.AutomaticEnv()

viper.SetConfigName("config") // name of config file (without extension)
viper.AddConfigPath("/etc/gotify/") // path to look for the config file in
viper.AddConfigPath("$HOME/.gotify/") // call multiple times to add many search paths
viper.AddConfigPath(".") // optionally look for config in the working directory
config.SetConfigName("config")
for _, path := range paths {
config.AddConfigPath(path)
}

err := viper.ReadInConfig() // Find and read the config file
err := config.ReadInConfig() // Find and read the config file
return err

}
58 changes: 58 additions & 0 deletions cmd/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package main

import (
"os"
"path/filepath"
"testing"

"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestConfigDefaults(t *testing.T) {
clearConfigEnvironment(t)
config := viper.New()
err := readConfig(config, t.TempDir())
var missing viper.ConfigFileNotFoundError
require.ErrorAs(t, err, &missing)
assert.Equal(t, "8080", config.GetString("port"))
assert.Equal(t, int64(20e6), config.GetInt64("max-mail-size"))
assert.Equal(t, "gapps.json", config.GetString("google-mail.keyfile"))
assert.False(t, config.GetBool("debug-mode"))
assert.False(t, config.GetBool("mock-mode"))
}

func TestConfigPrecedence(t *testing.T) {
clearConfigEnvironment(t)
first, second := t.TempDir(), t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(first, "config.toml"), []byte("port = '9000'\npre-shared-key = 'file-key'\nmax-mail-size = 4096\ndebug-mode = true\n[google-mail]\nadmin-mail = 'file@example.invalid'\n"), 0600))
require.NoError(t, os.WriteFile(filepath.Join(second, "config.toml"), []byte("port = '9001'\n"), 0600))
config := viper.New()
require.NoError(t, readConfig(config, first, second))
assert.Equal(t, "9000", config.GetString("port"))
assert.Equal(t, "file-key", config.GetString("pre-shared-key"))
assert.True(t, config.GetBool("debug-mode"))
assert.Equal(t, int64(4096), config.GetInt64("max-mail-size"))
assert.Equal(t, "file@example.invalid", config.GetString("google-mail.admin-mail"))
t.Setenv("GOTIFY_PORT", "9100")
t.Setenv("GOTIFY_PRE-SHARED-KEY", "environment-key")
t.Setenv("GOTIFY_GOOGLE-MAIL.ADMIN-MAIL", "environment@example.invalid")
assert.Equal(t, "9100", config.GetString("port"))
assert.Equal(t, "environment-key", config.GetString("pre-shared-key"))
assert.Equal(t, "environment@example.invalid", config.GetString("google-mail.admin-mail"))
}

func TestInvalidConfig(t *testing.T) {
clearConfigEnvironment(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "config.toml"), []byte("port = ["), 0600))
assert.Error(t, readConfig(viper.New(), dir))
}

func clearConfigEnvironment(t *testing.T) {
t.Helper()
for _, key := range []string{"PORT", "PRE-SHARED-KEY", "MAX-MAIL-SIZE", "DEBUG-MODE", "MOCK-MODE", "GOOGLE-MAIL.KEYFILE", "GOOGLE-MAIL.ADMIN-MAIL"} {
t.Setenv("GOTIFY_"+key, "")
}
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ toolchain go1.23.7
require (
github.com/gocraft/web v0.0.0-20190207150652-9707327fb69b
github.com/spf13/viper v1.20.0
github.com/stretchr/testify v1.10.0
golang.org/x/net v0.37.0
golang.org/x/oauth2 v0.28.0
google.golang.org/api v0.227.0
Expand All @@ -16,6 +17,7 @@ require (
cloud.google.com/go/auth v0.15.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
Expand All @@ -26,6 +28,7 @@ require (
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
Expand Down
15 changes: 9 additions & 6 deletions google_mail/google_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ func NewGoogleMailServiceCreator(keyPath string, adminMail string, debug bool) (
func (g *googleService) SendMail(mail gotify.Mail) (gotify.Mail, error) {

mail.From = g.adminMail
msg := &gmail.Message{
Raw: base64.RawURLEncoding.EncodeToString([]byte(buildRawMessage(mail))),
}
_, err := g.mailService.Users.Messages.Send(mail.From, msg).Do()
return mail, err
}

func buildRawMessage(mail gotify.Mail) string {
var msgRaw string
subject := "=?UTF-8?B?" + base64.StdEncoding.EncodeToString([]byte(mail.Subject)) + "?="

Expand Down Expand Up @@ -92,12 +100,7 @@ func (g *googleService) SendMail(mail gotify.Mail) (gotify.Mail, error) {
mail.Body + "\r\n"
}

msg := &gmail.Message{
Raw: base64.RawURLEncoding.EncodeToString([]byte(msgRaw)),
}
_, err := g.mailService.Users.Messages.Send(mail.From, msg).Do()

return mail, err
return msgRaw
}

func (g *googleService) Destroy() error {
Expand Down
72 changes: 72 additions & 0 deletions google_mail/message_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package google_mail

import (
"encoding/base64"
"io"
"mime"
"mime/multipart"
"net/mail"
"strings"
"testing"

"github.com/cthit/gotify"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestBuildRawMessage(t *testing.T) {
for _, attachments := range []bool{false, true} {
name := "plain"
if attachments {
name = "attachments"
}
t.Run(name, func(t *testing.T) {
input := gotify.Mail{From: "sender@example.invalid", To: "recipient@example.invalid", Subject: "Hallå 世界 ✓", Body: "A UTF-8 body: åäö\nSecond line"}
contents := []string{"hello\nworld", "\x00\x01\xff\xfe"}
if attachments {
input.Attachments = []gotify.Attachment{
{Name: "greeting.txt", ContentType: "text/plain", Data: base64.StdEncoding.EncodeToString([]byte(contents[0]))},
{Name: "binary.dat", ContentType: "application/octet-stream", Data: base64.StdEncoding.EncodeToString([]byte(contents[1]))},
}
}
message, err := mail.ReadMessage(strings.NewReader(buildRawMessage(input)))
require.NoError(t, err)
assert.Equal(t, input.From, message.Header.Get("From"))
assert.Equal(t, input.To, message.Header.Get("To"))
subject, err := new(mime.WordDecoder).DecodeHeader(message.Header.Get("Subject"))
require.NoError(t, err)
assert.Equal(t, input.Subject, subject)
if !attachments {
body, err := io.ReadAll(message.Body)
require.NoError(t, err)
assert.Equal(t, input.Body+"\r\n", string(body))
return
}
kind, params, err := mime.ParseMediaType(message.Header.Get("Content-Type"))
require.NoError(t, err)
assert.Equal(t, "multipart/mixed", kind)
require.NotEmpty(t, params["boundary"])
parts := multipart.NewReader(message.Body, params["boundary"])
part, err := parts.NextPart()
require.NoError(t, err)
assert.Equal(t, "text/plain; charset=UTF-8", part.Header.Get("Content-Type"))
body, err := io.ReadAll(part)
require.NoError(t, err)
assert.Equal(t, input.Body, string(body))
for i, expected := range input.Attachments {
part, err := parts.NextPart()
require.NoError(t, err)
assert.Equal(t, expected.Name, part.FileName())
kind, _, err := mime.ParseMediaType(part.Header.Get("Content-Type"))
require.NoError(t, err)
assert.Equal(t, expected.ContentType, kind)
assert.Equal(t, "base64", part.Header.Get("Content-Transfer-Encoding"))
data, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, part))
require.NoError(t, err)
assert.Equal(t, []byte(contents[i]), data)
}
_, err = parts.NextPart()
assert.ErrorIs(t, err, io.EOF)
})
}
}
67 changes: 67 additions & 0 deletions web/mail_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package web

import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/cthit/gotify"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type fakeMailService struct {
sent []gotify.Mail
err error
destroyed int
}

func (f *fakeMailService) SendMail(mail gotify.Mail) (gotify.Mail, error) {
f.sent = append(f.sent, mail)
mail.From = "sender@example.invalid"
return mail, f.err
}

func (f *fakeMailService) Destroy() error { f.destroyed++; return nil }

func TestMailHandler(t *testing.T) {
viper.Set("max-mail-size", 256)
t.Cleanup(viper.Reset)
for _, tc := range []struct {
name, authorization, body string
serviceError error
status, sends int
}{
{name: "missing key", body: `{}`, status: 401},
{name: "wrong key", authorization: "pre-shared: wrong", body: `{}`, status: 401},
{name: "wrong scheme", authorization: "Bearer secret", body: `{}`, status: 401},
{name: "invalid JSON", authorization: "pre-shared: secret", body: `{`, status: 400},
{name: "wrong field type", authorization: "pre-shared: secret", body: `{"to":42}`, status: 400},
{name: "trailing JSON", authorization: "pre-shared: secret", body: `{} {}`, status: 400},
{name: "too large", authorization: "pre-shared: secret", body: strings.Repeat("x", 257), status: 413},
{name: "upstream failure", authorization: "pre-shared: secret", body: `{}`, serviceError: errors.New("send failed"), status: 500, sends: 1},
{name: "success", authorization: "pre-shared: secret", body: `{"to":"recipient@example.invalid","subject":"Hello","body":"World"}`, status: 200, sends: 1},
} {
t.Run(tc.name, func(t *testing.T) {
service := &fakeMailService{err: tc.serviceError}
handler := Router("secret", func() gotify.MailService { return service }, false)
req := httptest.NewRequest(http.MethodPost, "/mail", strings.NewReader(tc.body))
req.Header.Set("Authorization", tc.authorization)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
assert.Equal(t, tc.status, recorder.Code)
assert.Len(t, service.sent, tc.sends)
assert.Equal(t, 1, service.destroyed)
if tc.status == http.StatusOK {
require.Len(t, service.sent, 1)
assert.Equal(t, "recipient@example.invalid", service.sent[0].To)
assert.JSONEq(t, `{"to":"recipient@example.invalid","from":"sender@example.invalid","subject":"Hello","body":"World","attachments":null}`, recorder.Body.String())
} else {
assert.Empty(t, recorder.Body.String())
}
})
}
}
Loading