├── .github
└── workflows
│ ├── lint.yml
│ └── main.yml
├── .gitignore
├── .golangci.yml
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── default.go
├── examples
├── default
│ ├── default.invite_code.html
│ ├── default.invite_code.txt
│ ├── default.maintenance.html
│ ├── default.maintenance.txt
│ ├── default.receipt.html
│ ├── default.receipt.txt
│ ├── default.reset.html
│ ├── default.reset.txt
│ ├── default.welcome.html
│ └── default.welcome.txt
├── flat
│ ├── flat.invite_code.html
│ ├── flat.invite_code.txt
│ ├── flat.maintenance.html
│ ├── flat.maintenance.txt
│ ├── flat.receipt.html
│ ├── flat.receipt.txt
│ ├── flat.reset.html
│ ├── flat.reset.txt
│ ├── flat.welcome.html
│ └── flat.welcome.txt
├── gopher.png
├── invite_code.go
├── main.go
├── maintenance.go
├── receipt.go
├── reset.go
└── welcome.go
├── flat.go
├── go.mod
├── go.sum
├── hermes.go
├── hermes_test.go
└── screens
├── default
├── receipt.png
├── reset.png
└── welcome.png
├── demo.png
├── flat
├── receipt.png
├── reset.png
└── welcome.png
└── free-markdown.png
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: lint
2 | on:
3 | pull_request:
4 | types: [opened, synchronize] # triggered for new PR and new commits in existing PRs
5 | permissions:
6 | contents: read
7 | pull-requests: write
8 | jobs:
9 | golangci:
10 | name: lint
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/setup-go@v5
14 | with:
15 | go-version: "1.24"
16 | - uses: actions/checkout@v4
17 | - name: golangci-lint
18 | uses: golangci/golangci-lint-action@v6
19 | with:
20 | github-token: ${{ secrets.GITHUB_TOKEN }}
21 | version: latest
22 | only-new-issues: true
23 | args: -v
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v4
13 |
14 | - name: Setup Go environment
15 | uses: actions/setup-go@v5
16 | with:
17 | go-version: "1.24"
18 |
19 | - name: Build
20 | run: |
21 | go build
22 | test:
23 | runs-on: ubuntu-latest
24 | steps:
25 | - uses: actions/checkout@v4
26 | - name: Setup Go environment
27 | uses: actions/setup-go@v5
28 | with:
29 | go-version: "1.24"
30 | - name: Unit tests
31 | run: |
32 | go test ./...
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Compiled Object files, Static and Dynamic libs (Shared Objects)
2 | *.o
3 | *.a
4 | *.so
5 |
6 | # Folders
7 | _obj
8 | _test
9 |
10 | # Architecture specific extensions/prefixes
11 | *.[568vq]
12 | [568vq].out
13 |
14 | *.cgo1.go
15 | *.cgo2.c
16 | _cgo_defun.c
17 | _cgo_gotypes.go
18 | _cgo_export.*
19 |
20 | _testmain.go
21 |
22 | *.exe
23 | *.test
24 | *.prof
25 |
26 | .DS_Store
27 | coverage.txt
28 | vendor/
29 | .idea/
30 |
--------------------------------------------------------------------------------
/.golangci.yml:
--------------------------------------------------------------------------------
1 | linters:
2 | enable-all: true
3 | disable:
4 | - gochecknoglobals
5 | - funlen
6 | - exhaustruct
7 | - depguard
8 | - varnamelen
9 | - wsl
10 | - wrapcheck
11 | - whitespace
12 | - thelper
13 | - testpackage
14 | - godot
15 | - mnd
16 | - gomoddirectives
17 |
18 | linters-settings:
19 | gosec:
20 | excludes:
21 | - G203 # Use of unescaped data in HTML templates - this packages generates HTML from a template
22 | lll:
23 | line-length: 150
24 | revive:
25 | rules:
26 | - name: package-comments
27 | disabled: true
28 |
29 | issues:
30 | max-issues-per-linter: 0
31 | max-same-issues: 0
32 | exclude-rules:
33 | - path: _test\.go
34 | linters:
35 | - lll
36 | - path: flat\.go
37 | linters:
38 | - lll
39 | - path: default\.go
40 | linters:
41 | - lll
42 | - path: examples/maintenance\.go
43 | linters:
44 | - lll
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | Thanks so much for wanting to help! We really appreciate it.
4 |
5 | * Have an idea for a new feature?
6 | * Want to add a new built-in theme?
7 |
8 | Excellent! You've come to the right place.
9 |
10 | 1. If you find a bug or wish to suggest a new feature, please create an issue first
11 | 2. Make sure your code & comment conventions are in-line with the project's style (execute gometalinter as in [.travis.yml](.travis.yml) file)
12 | 3. Make your commits and PRs as tiny as possible - one feature or bugfix at a time
13 | 4. Write detailed commit messages, in-line with the project's commit naming conventions
14 |
15 | ## Theming Instructions
16 |
17 | This file contains instructions on adding themes to Hermes:
18 |
19 | * [Using a Custom Theme](#using-a-custom-theme)
20 | * [Creating a Built-In Theme](#creating-a-built-in-theme)
21 |
22 | > We use Golang templates under the hood to inject the e-mail body into themes.
23 | > - [Official guide](https://golang.org/pkg/text/template/)
24 | > - [Tutorial](https://astaxie.gitbooks.io/build-web-application-with-golang/en/07.4.html)
25 | > - [Hugo guide](https://gohugo.io/templates/go-templates/)
26 |
27 | ### Using a Custom Theme
28 |
29 | If you want to supply your own **custom theme** for Hermes to use (but don't want it included with Hermes):
30 |
31 | 1. Create a new struct implementing `Theme` interface ([hermes.go](hermes.go)). A real-life example is in [default.go](default.go)
32 | 2. Supply your new theme at hermes creation
33 |
34 | ```go
35 |
36 | type MyCustomTheme struct{}
37 |
38 | func (dt *MyCustomTheme) Name() string {
39 | return "mycustomthem"
40 | }
41 |
42 | func (dt *MyCustomTheme) HTMLTemplate() string {
43 | // Get the template from a file (if you want to be able to change the template live without retstarting your application)
44 | // Or write the template by returning pure string here (if you want embbeded template and do not bother with external dependencies)
45 | return ""
46 | }
47 |
48 | func (dt *MyCustomTheme) PlainTextTemplate() string {
49 | // Get the template from a file (if you want to be able to change the template live without retstarting your application)
50 | // Or write the template by returning pure string here (if you want embbeded template and do not bother with external dependencies)
51 | return ""
52 | }
53 |
54 | h := hermes.Hermes{
55 | Theme: new(MyCustomTheme) // Set your fresh new theme here
56 | Product: hermes.Product{
57 | Name: "Hermes",
58 | Link: "https://example-hermes.com/",
59 | },
60 | }
61 |
62 | // ...
63 | // Continue with the rest as usual, create your email and generate the content.
64 | // ...
65 | ```
66 |
67 | 3. That's it.
68 |
69 | ### Creating a Built-In Theme
70 |
71 | If you want to create a new **built-in** Hermes theme:
72 |
73 | 1. Fork the repository to your GitHub account and clone it to your computer
74 | 2. Create a new Go file named after your new theme
75 | 3. Copy content of [default.go](default.go) file in new file and make any necessary changes
76 | 4. Scroll down to the [injection snippets](#injection-snippets) and copy and paste each code snippet into the relevant area of your template markup
77 | 5. Test the theme by adding the theme to slice of tested themes (see [hermes_test.go](hermes_test.go)). A set of tests will be run to check that your theme follows features of Hermes.
78 | 6. Create examples in new folder for your theme in `examples` folder and run `go run *.go`. It will generate the different `html` and `plaintext` emails for your different examples. Follow the same examples as default theme (3 examples: Welcome, Reset and Receipt)
79 | 7. Add the theme name, credit, and screenshots to the `README.md` file's [Supported Themes](README.md#supported-themes) section (copy one of the existing themes' markup and modify it accordingly)
80 | 8. Submit a pull request with your changes and we'll let you know if anything's missing!
81 |
82 | Thanks again for your contribution!
83 |
84 | # Injection Snippets
85 |
86 | ## Product Branding Injection
87 |
88 | The following will inject either the product logo or name into the template.
89 |
90 | ```html
91 |
92 | {{ if .Hermes.Product.Logo }}
93 |
94 | {{ else }}
95 | {{ .Hermes.Product.Name }}
96 | {{ end }}
97 |
98 | ```
99 |
100 | It's a good idea to add the following CSS declaration to set `max-height: 50px` for the logo:
101 |
102 | ```css
103 | .email-logo {
104 | max-height: 50px;
105 | }
106 | ```
107 |
108 | ## Title Injection
109 |
110 | The following will inject the e-mail title (Hi John Appleseed,) or a custom title provided by the user:
111 |
112 | ```html
113 |
{{ $line }}
125 | {{ end }} 126 | {{ end }} 127 | {{ end }} 128 | ``` 129 | 130 | ## Dictionary Injection 131 | 132 | The following will inject a `
181 |
|
219 |
{{ $action.Instructions }}
265 |
268 |
269 | {{ $action.Button.Text }}
270 |
271 | |
272 |
287 | {{$.Hermes.Product.TroubleText | replace "{ACTION}" $action.Button.Text}} 288 | 289 | |
290 | {{ end }}
291 |
327 |
|
335 |
{{ $line }}
350 | {{ end }} 351 | {{ end }} 352 | {{ end }} 353 | ``` 354 | 355 | ## Signature Injection 356 | 357 | The following will inject the signature phrase (e.g. Yours truly) along with the product name into the e-mail: 358 | 359 | ```html 360 | {{.Email.Body.Signature}}, 361 |
27 |
|
157 |
27 |
|
112 |
27 |
|
217 |
27 |
|
165 |
27 |
|
180 |
22 |
|
149 |
22 |
|
107 |
22 |
|
209 |
22 |
|
157 |
22 |
|
172 |
281 |
|
491 |
{{ $line }}
504 | {{ end }} 505 | {{ end }} 506 | {{ if (ne .Email.Body.FreeMarkdown "") }} 507 | {{ .Email.Body.FreeMarkdown.ToHTML }} 508 | {{ else }} 509 | {{ with .Email.Body.Dictionary }} 510 |{{ $entry.Key }} | 525 | {{ end }} 526 |
---|
531 | {{ $cell.Value }} 532 | | 533 | {{ end }} 534 |
542 | {{ $action.Instructions }} 543 | {{ if $action.InviteCode }} 544 | {{ $action.InviteCode }} 545 | {{ end }} 546 | {{ if $action.Button.Link }} 547 | {{ $action.Button.Link }} 548 | {{ end }} 549 |
550 | {{ end }} 551 | {{ end }} 552 | {{ end }} 553 | {{ with .Email.Body.Outros }} 554 | {{ range $line := . }} 555 |{{ $line }}
556 | {{ end }} 557 | {{ end }} 558 |
{{.Email.Body.Signature}},
{{.Hermes.Product.Name}} - {{.Hermes.Product.Link}}
{{.Hermes.Product.Copyright}}
561 | ` 562 | } 563 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/matcornic/hermes 2 | 3 | go 1.24.2 4 | 5 | // https://github.com/darccio/mergo?tab=readme-ov-file#100 6 | // for github.com/Masterminds/sprig 7 | replace github.com/imdario/mergo => github.com/imdario/mergo v0.3.16 8 | 9 | require ( 10 | dario.cat/mergo v1.0.1 11 | github.com/Masterminds/sprig v2.22.0+incompatible 12 | github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df 13 | github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 14 | github.com/russross/blackfriday/v2 v2.1.0 15 | github.com/stretchr/testify v1.10.0 16 | github.com/vanng822/go-premailer v1.24.0 17 | golang.org/x/term v0.30.0 18 | ) 19 | 20 | require ( 21 | github.com/Masterminds/goutils v1.1.1 // indirect 22 | github.com/Masterminds/semver v1.5.0 // indirect 23 | github.com/PuerkitoBio/goquery v1.10.2 // indirect 24 | github.com/andybalholm/cascadia v1.3.3 // indirect 25 | github.com/davecgh/go-spew v1.1.1 // indirect 26 | github.com/google/uuid v1.6.0 // indirect 27 | github.com/gorilla/css v1.0.1 // indirect 28 | github.com/huandu/xstrings v1.5.0 // indirect 29 | github.com/imdario/mergo v0.0.0-00010101000000-000000000000 // indirect 30 | github.com/mattn/go-runewidth v0.0.9 // indirect 31 | github.com/mitchellh/copystructure v1.2.0 // indirect 32 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 33 | github.com/olekukonko/tablewriter v0.0.5 // indirect 34 | github.com/pmezard/go-difflib v1.0.0 // indirect 35 | github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect 36 | github.com/vanng822/css v1.0.1 // indirect 37 | golang.org/x/crypto v0.36.0 // indirect 38 | golang.org/x/net v0.37.0 // indirect 39 | golang.org/x/sys v0.31.0 // indirect 40 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect 41 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df // indirect 42 | gopkg.in/yaml.v3 v3.0.1 // indirect 43 | ) 44 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= 2 | dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= 4 | github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= 5 | github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= 6 | github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= 7 | github.com/Masterminds/sprig v2.22.0+incompatible h1:z4yfnGrZ7netVz+0EDJ0Wi+5VZCSYp4Z0m2dk6cEM60= 8 | github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= 9 | github.com/PuerkitoBio/goquery v1.10.2 h1:7fh2BdHcG6VFZsK7toXBT/Bh1z5Wmy8Q9MV9HqT2AM8= 10 | github.com/PuerkitoBio/goquery v1.10.2/go.mod h1:0guWGjcLu9AYC7C1GHnpysHy056u9aEkUHwhdnePMCU= 11 | github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= 12 | github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= 13 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 14 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df h1:Bao6dhmbTA1KFVxmJ6nBoMuOJit2yjEgLJpIMYpop0E= 16 | github.com/go-gomail/gomail v0.0.0-20160411212932-81ebce5c23df/go.mod h1:GJr+FCSXshIwgHBtLglIg9M2l2kQSi6QjVAngtzI08Y= 17 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 18 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 19 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 20 | github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 21 | github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 22 | github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= 23 | github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 24 | github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= 25 | github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= 26 | github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA= 27 | github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= 28 | github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0= 29 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 30 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 31 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 32 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 33 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 34 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 35 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 36 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 37 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 38 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 39 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 40 | github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= 41 | github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= 42 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 43 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 44 | github.com/vanng822/css v1.0.1 h1:10yiXc4e8NI8ldU6mSrWmSWMuyWgPr9DZ63RSlsgDw8= 45 | github.com/vanng822/css v1.0.1/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w= 46 | github.com/vanng822/go-premailer v1.24.0 h1:b4MpHLVdlA7QOwk5OJIEvWnIpCCdEhEDQpJ/AkEYcpo= 47 | github.com/vanng822/go-premailer v1.24.0/go.mod h1:gjLku4P5inmyu+MM7544lOjhaW8F3TdIqboFVcZGwZE= 48 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 49 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 50 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 51 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 52 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 53 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 54 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 55 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 56 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 57 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 58 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 59 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 60 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 61 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 62 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 63 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 64 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 65 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 66 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 67 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 68 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 69 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 70 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 71 | golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= 72 | golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 73 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 74 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 75 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 76 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 77 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 78 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 79 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 80 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 81 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 82 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 83 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 84 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 85 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 86 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 87 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 88 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 89 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 90 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 91 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 92 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 93 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 94 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 95 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 96 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 97 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 98 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 99 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 100 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 101 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 102 | golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 103 | golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 104 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 105 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 106 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 107 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 108 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 109 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 110 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 111 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 112 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 113 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 114 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 115 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 116 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 117 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 118 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 119 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 120 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= 121 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= 122 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 123 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 124 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= 125 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= 126 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 127 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 128 | -------------------------------------------------------------------------------- /hermes.go: -------------------------------------------------------------------------------- 1 | package hermes 2 | 3 | import ( 4 | "bytes" 5 | "html/template" 6 | 7 | "dario.cat/mergo" 8 | "github.com/Masterminds/sprig" 9 | "github.com/jaytaylor/html2text" 10 | "github.com/russross/blackfriday/v2" 11 | "github.com/vanng822/go-premailer/premailer" 12 | ) 13 | 14 | // Hermes is an instance of the hermes email generator 15 | type Hermes struct { 16 | Theme Theme 17 | TextDirection TextDirection 18 | Product Product 19 | DisableCSSInlining bool 20 | } 21 | 22 | // Theme is an interface to implement when creating a new theme 23 | type Theme interface { 24 | Name() string // The name of the theme 25 | HTMLTemplate() string // The golang template for HTML emails 26 | PlainTextTemplate() string // The golang templte for plain text emails (can be basic HTML) 27 | } 28 | 29 | // TextDirection of the text in HTML email 30 | type TextDirection string 31 | 32 | var templateFuncs = template.FuncMap{ 33 | "url": func(s string) template.URL { 34 | return template.URL(s) 35 | }, 36 | } 37 | 38 | // TDLeftToRight is the text direction from left to right (default) 39 | const TDLeftToRight TextDirection = "ltr" 40 | 41 | // TDRightToLeft is the text direction from right to left 42 | const TDRightToLeft TextDirection = "rtl" 43 | 44 | // Product represents your company product (brand) 45 | // Appears in header & footer of e-mails 46 | type Product struct { 47 | Name string 48 | Link string // e.g. https://matcornic.github.io 49 | Logo string // e.g. https://matcornic.github.io/img/logo.png 50 | Copyright string // Copyright © 2019 Hermes. All rights reserved. 51 | // TroubleText is the sentence at the end of the email for users having trouble with the button 52 | // (default to `If you’re having trouble with the button '{ACTION}', 53 | // copy and paste the URL below into your web browser.`) 54 | TroubleText string 55 | } 56 | 57 | // Email is the email containing a body 58 | type Email struct { 59 | Body Body 60 | } 61 | 62 | // Markdown is a HTML template (a string) representing Markdown content 63 | // https://en.wikipedia.org/wiki/Markdown 64 | type Markdown template.HTML 65 | 66 | // Body is the body of the email, containing all interesting data 67 | type Body struct { 68 | Name string // The name of the contacted person 69 | Intros []string // Intro sentences, first displayed in the email 70 | Dictionary []Entry // A list of key+value (useful for displaying parameters/settings/personal info) 71 | Table Table // Table is an table where you can put data (pricing grid, a bill, and so on) 72 | Actions []Action // Actions are a list of actions that the user will be able to execute via a button click 73 | Outros []string // Outro sentences, last displayed in the email 74 | Greeting string // Greeting for the contacted person (default to 'Hi') 75 | Signature string // Signature for the contacted person (default to 'Yours truly') 76 | Title string // Title replaces the greeting+name when set 77 | FreeMarkdown Markdown // Free markdown content that replaces all content other than header and footer 78 | } 79 | 80 | // ToHTML converts Markdown to HTML 81 | func (c Markdown) ToHTML() template.HTML { 82 | return template.HTML(blackfriday.Run([]byte(c))) 83 | } 84 | 85 | // Entry is a simple entry of a map 86 | // Allows using a slice of entries instead of a map 87 | // Because Golang maps are not ordered 88 | type Entry struct { 89 | Key string 90 | Value string 91 | } 92 | 93 | // Table is an table where you can put data (pricing grid, a bill, and so on) 94 | type Table struct { 95 | Data [][]Entry // Contains data 96 | Columns Columns // Contains meta-data for display purpose (width, alignement) 97 | } 98 | 99 | // Columns contains meta-data for the different columns 100 | type Columns struct { 101 | CustomWidth map[string]string 102 | CustomAlignment map[string]string 103 | } 104 | 105 | // Action is anything the user can act on (i.e., click on a button, view an invite code) 106 | type Action struct { 107 | Instructions string 108 | Button Button 109 | InviteCode string 110 | } 111 | 112 | // Button defines an action to launch 113 | type Button struct { 114 | Color string 115 | TextColor string 116 | Text string 117 | Link string 118 | } 119 | 120 | // Template is the struct given to Golang templating 121 | // Root object in a template is this struct 122 | type Template struct { 123 | Hermes Hermes 124 | Email Email 125 | } 126 | 127 | func setDefaultEmailValues(e *Email) error { 128 | // Default values of an email 129 | defaultEmail := Email{ 130 | Body: Body{ 131 | Intros: []string{}, 132 | Dictionary: []Entry{}, 133 | Outros: []string{}, 134 | Signature: "Yours truly", 135 | Greeting: "Hi", 136 | }, 137 | } 138 | // Merge the given email with default one 139 | // Default one overrides all zero values 140 | return mergo.Merge(e, defaultEmail) 141 | } 142 | 143 | // default values of the engine 144 | func setDefaultHermesValues(h *Hermes) error { 145 | defaultTextDirection := TDLeftToRight 146 | defaultHermes := Hermes{ 147 | Theme: new(Default), 148 | TextDirection: defaultTextDirection, 149 | Product: Product{ 150 | Name: "Hermes", 151 | Copyright: "Copyright © 2025 Hermes. All rights reserved.", 152 | TroubleText: "If you’re having trouble with the button '{ACTION}', copy and paste the URL below into your web browser.", 153 | }, 154 | } 155 | // Merge the given hermes engine configuration with default one 156 | // Default one overrides all zero values 157 | err := mergo.Merge(h, defaultHermes) 158 | if err != nil { 159 | return err 160 | } 161 | if h.TextDirection != TDLeftToRight && h.TextDirection != TDRightToLeft { 162 | h.TextDirection = defaultTextDirection 163 | } 164 | 165 | return nil 166 | } 167 | 168 | // GenerateHTML generates the email body from data to an HTML Reader 169 | // This is for modern email clients 170 | func (h *Hermes) GenerateHTML(email Email) (string, error) { 171 | err := setDefaultHermesValues(h) 172 | if err != nil { 173 | return "", err 174 | } 175 | 176 | return h.generateTemplate(email, h.Theme.HTMLTemplate()) 177 | } 178 | 179 | // GeneratePlainText generates the email body from data 180 | // This is for old email clients 181 | func (h *Hermes) GeneratePlainText(email Email) (string, error) { 182 | err := setDefaultHermesValues(h) 183 | if err != nil { 184 | return "", err 185 | } 186 | template, err := h.generateTemplate(email, h.Theme.PlainTextTemplate()) 187 | if err != nil { 188 | return "", err 189 | } 190 | 191 | return html2text.FromString(template, html2text.Options{PrettyTables: true}) 192 | } 193 | 194 | func (h *Hermes) generateTemplate(email Email, tplt string) (string, error) { 195 | err := setDefaultEmailValues(&email) 196 | if err != nil { 197 | return "", err 198 | } 199 | 200 | // Generate the email from Golang template 201 | // Allow usage of simple function from sprig : https://github.com/Masterminds/sprig 202 | t, err := template.New("hermes").Funcs(sprig.FuncMap()).Funcs(templateFuncs).Funcs(template.FuncMap{ 203 | "safe": func(s string) template.HTML { return template.HTML(s) }, // Used for keeping comments in generated template 204 | }).Parse(tplt) 205 | if err != nil { 206 | return "", err 207 | } 208 | 209 | var b bytes.Buffer 210 | err = t.Execute(&b, Template{*h, email}) 211 | if err != nil { 212 | return "", err 213 | } 214 | 215 | res := b.String() 216 | if h.DisableCSSInlining { 217 | return res, nil 218 | } 219 | 220 | // Inlining CSS 221 | prem, err := premailer.NewPremailerFromString(res, premailer.NewOptions()) 222 | if err != nil { 223 | return "", err 224 | } 225 | 226 | html, err := prem.Transform() 227 | if err != nil { 228 | return "", err 229 | } 230 | 231 | return html, nil 232 | } 233 | -------------------------------------------------------------------------------- /hermes_test.go: -------------------------------------------------------------------------------- 1 | package hermes 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | var testedThemes = []Theme{ 11 | // Insert your new theme here 12 | new(Default), 13 | new(Flat), 14 | } 15 | 16 | // =============================================== 17 | // Every theme should display the same information 18 | // Find below the tests to check that 19 | // =============================================== 20 | 21 | // Implement this interface when creating a new example checking a common feature of all themes 22 | type Example interface { 23 | // Create the hermes example with data 24 | // Represents the "Given" step in Given/When/Then Workflow 25 | getExample() (h Hermes, email Email) 26 | // Checks the content of the generated HTML email by asserting content presence or not 27 | assertHTMLContent(t *testing.T, s string) 28 | // Checks the content of the generated Plaintext email by asserting content presence or not 29 | assertPlainTextContent(t *testing.T, s string) 30 | } 31 | 32 | // Scenario 33 | type SimpleExample struct { 34 | theme Theme 35 | } 36 | 37 | func (ed *SimpleExample) getExample() (Hermes, Email) { 38 | h := Hermes{ 39 | Theme: ed.theme, 40 | Product: Product{ 41 | Name: "HermesName", 42 | Link: "http://hermes-link.com", 43 | Copyright: "Copyright © Hermes-Test", 44 | Logo: "http://www.duchess-france.org/wp-content/uploads/2016/01/gopher.png", 45 | }, 46 | TextDirection: TDLeftToRight, 47 | DisableCSSInlining: true, 48 | } 49 | 50 | email := Email{ 51 | Body{ 52 | Name: "Jon Snow", 53 | Intros: []string{ 54 | "Welcome to Hermes! We're very excited to have you on board.", 55 | }, 56 | Dictionary: []Entry{ 57 | {"Firstname", "Jon"}, 58 | {"Lastname", "Snow"}, 59 | {"Birthday", "01/01/283"}, 60 | }, 61 | Table: Table{ 62 | Data: [][]Entry{ 63 | { 64 | {Key: "Item", Value: "Golang"}, 65 | {Key: "Description", Value: "Open source programming language that makes it easy to build simple, reliable, and efficient software"}, 66 | {Key: "Price", Value: "$10.99"}, 67 | }, 68 | { 69 | {Key: "Item", Value: "Hermes"}, 70 | {Key: "Description", Value: "Programmatically create beautiful e-mails using Golang."}, 71 | {Key: "Price", Value: "$1.99"}, 72 | }, 73 | }, 74 | Columns: Columns{ 75 | CustomWidth: map[string]string{ 76 | "Item": "20%", 77 | "Price": "15%", 78 | }, 79 | CustomAlignment: map[string]string{ 80 | "Price": "right", 81 | }, 82 | }, 83 | }, 84 | Actions: []Action{ 85 | { 86 | Instructions: "To get started with Hermes, please click here:", 87 | Button: Button{ 88 | Color: "#22BC66", 89 | Text: "Confirm your account", 90 | Link: "https://hermes-example.com/confirm?token=d9729feb74992cc3482b350163a1a010", 91 | }, 92 | }, 93 | }, 94 | Outros: []string{ 95 | "Need help, or have questions? Just reply to this email, we'd love to help.", 96 | }, 97 | }, 98 | } 99 | 100 | return h, email 101 | } 102 | 103 | func (ed *SimpleExample) assertHTMLContent(t *testing.T, r string) { 104 | // Assert on product 105 | assert.Contains(t, r, "HermesName", "Product: Should find the name of the product in email") 106 | assert.Contains(t, r, "http://hermes-link.com", "Product: Should find the link of the product in email") 107 | assert.Contains(t, r, "Copyright © Hermes-Test", "Product: Should find the Copyright of the product in email") 108 | assert.Contains(t, r, "http://www.duchess-france.org/wp-content/uploads/2016/01/gopher.png", "Product: Should find the logo of the product in email") 109 | assert.Contains(t, r, "If you’re having trouble with the button 'Confirm your account', copy and paste the URL below into your web browser.", "Product: Should find the trouble text in email") 110 | // Assert on email body 111 | assert.Contains(t, r, "Hi Jon Snow", "Name: Should find the name of the person") 112 | assert.Contains(t, r, "Welcome to Hermes", "Intro: Should have intro") 113 | assert.Contains(t, r, "Birthday", "Dictionary: Should have dictionary") 114 | assert.Contains(t, r, "Open source programming language", "Table: Should have table with first row and first column") 115 | assert.Contains(t, r, "Programmatically create beautiful e-mails using Golang", "Table: Should have table with second row and first column") 116 | assert.Contains(t, r, "$10.99", "Table: Should have table with first row and second column") 117 | assert.Contains(t, r, "$1.99", "Table: Should have table with second row and second column") 118 | assert.Contains(t, r, "started with Hermes", "Action: Should have instruction") 119 | assert.Contains(t, r, "Confirm your account", "Action: Should have button of action") 120 | assert.Contains(t, r, "#22BC66", "Action: Button should have given color") 121 | assert.Contains(t, r, "https://hermes-example.com/confirm?token=d9729feb74992cc3482b350163a1a010", "Action: Button should have link") 122 | assert.Contains(t, r, "Need help, or have questions", "Outro: Should have outro") 123 | } 124 | 125 | func (ed *SimpleExample) assertPlainTextContent(t *testing.T, r string) { 126 | // Assert on product 127 | assert.Contains(t, r, "HermesName", "Product: Should find the name of the product in email") 128 | assert.Contains(t, r, "http://hermes-link.com", "Product: Should find the link of the product in email") 129 | assert.Contains(t, r, "Copyright © Hermes-Test", "Product: Should find the Copyright of the product in email") 130 | assert.NotContains(t, r, "http://www.duchess-france.org/wp-content/uploads/2016/01/gopher.png", "Product: Should not find any logo in plain text") 131 | 132 | // Assert on email body 133 | assert.Contains(t, r, "Hi Jon Snow", "Name: Should find the name of the person") 134 | assert.Contains(t, r, "Welcome to Hermes", "Intro: Should have intro") 135 | assert.Contains(t, r, "Birthday", "Dictionary: Should have dictionary") 136 | assert.Contains(t, r, "Open source", "Table: Should have table content") 137 | assert.Contains(t, r, `+--------+--------------------------------+--------+ 138 | | ITEM | DESCRIPTION | PRICE | 139 | +--------+--------------------------------+--------+ 140 | | Golang | Open source programming | $10.99 | 141 | | | language that makes it easy | | 142 | | | to build simple, reliable, and | | 143 | | | efficient software | | 144 | | Hermes | Programmatically create | $1.99 | 145 | | | beautiful e-mails using | | 146 | | | Golang. | | 147 | +--------+--------------------------------+--------`, "Table: Should have pretty table content") 148 | assert.Contains(t, r, "started with Hermes", "Action: Should have instruction") 149 | assert.NotContains(t, r, "Confirm your account", "Action: Should not have button of action in plain text") 150 | assert.NotContains(t, r, "#22BC66", "Action: Button should not have color in plain text") 151 | assert.Contains(t, r, "https://hermes-example.com/confirm?token=d9729feb74992cc3482b350163a1a010", "Action: Even if button is not possible in plain text, it should have the link") 152 | assert.Contains(t, r, "Need help, or have questions", "Outro: Should have outro") 153 | } 154 | 155 | type WithTitleInsteadOfNameExample struct { 156 | theme Theme 157 | } 158 | 159 | func (ed *WithTitleInsteadOfNameExample) getExample() (Hermes, Email) { 160 | h := Hermes{ 161 | Theme: ed.theme, 162 | Product: Product{ 163 | Name: "Hermes", 164 | Link: "http://hermes.com", 165 | }, 166 | DisableCSSInlining: true, 167 | } 168 | 169 | email := Email{ 170 | Body{ 171 | Name: "Jon Snow", 172 | Title: "A new e-mail", 173 | }, 174 | } 175 | 176 | return h, email 177 | } 178 | 179 | func (ed *WithTitleInsteadOfNameExample) assertHTMLContent(t *testing.T, r string) { 180 | assert.NotContains(t, r, "Hi Jon Snow", "Name: should not find greetings from Jon Snow because title should be used") 181 | assert.Contains(t, r, "A new e-mail", "Title should be used instead of name") 182 | } 183 | 184 | func (ed *WithTitleInsteadOfNameExample) assertPlainTextContent(t *testing.T, r string) { 185 | assert.NotContains(t, r, "Hi Jon Snow", "Name: should not find greetings from Jon Snow because title should be used") 186 | assert.Contains(t, r, "A new e-mail", "Title shoud be used instead of name") 187 | } 188 | 189 | type WithGreetingDifferentThanDefault struct { 190 | theme Theme 191 | } 192 | 193 | func (ed *WithGreetingDifferentThanDefault) getExample() (Hermes, Email) { 194 | h := Hermes{ 195 | Theme: ed.theme, 196 | Product: Product{ 197 | Name: "Hermes", 198 | Link: "http://hermes.com", 199 | }, 200 | DisableCSSInlining: true, 201 | } 202 | 203 | email := Email{ 204 | Body{ 205 | Greeting: "Dear", 206 | Name: "Jon Snow", 207 | }, 208 | } 209 | 210 | return h, email 211 | } 212 | 213 | func (ed *WithGreetingDifferentThanDefault) assertHTMLContent(t *testing.T, r string) { 214 | assert.NotContains(t, r, "Hi Jon Snow", "Should not find greetings with 'Hi' which is default") 215 | assert.Contains(t, r, "Dear Jon Snow", "Should have greeting with Dear") 216 | } 217 | 218 | func (ed *WithGreetingDifferentThanDefault) assertPlainTextContent(t *testing.T, r string) { 219 | assert.NotContains(t, r, "Hi Jon Snow", "Should not find greetings with 'Hi' which is default") 220 | assert.Contains(t, r, "Dear Jon Snow", "Should have greeting with Dear") 221 | } 222 | 223 | type WithSignatureDifferentThanDefault struct { 224 | theme Theme 225 | } 226 | 227 | func (ed *WithSignatureDifferentThanDefault) getExample() (Hermes, Email) { 228 | h := Hermes{ 229 | Theme: ed.theme, 230 | Product: Product{ 231 | Name: "Hermes", 232 | Link: "http://hermes.com", 233 | }, 234 | DisableCSSInlining: true, 235 | } 236 | 237 | email := Email{ 238 | Body{ 239 | Name: "Jon Snow", 240 | Signature: "Best regards", 241 | }, 242 | } 243 | 244 | return h, email 245 | } 246 | 247 | func (ed *WithSignatureDifferentThanDefault) assertHTMLContent(t *testing.T, r string) { 248 | assert.NotContains(t, r, "Yours truly", "Should not find signature with 'Yours truly' which is default") 249 | assert.Contains(t, r, "Best regards", "Should have greeting with Dear") 250 | } 251 | 252 | func (ed *WithSignatureDifferentThanDefault) assertPlainTextContent(t *testing.T, r string) { 253 | assert.NotContains(t, r, "Yours truly", "Should not find signature with 'Yours truly' which is default") 254 | assert.Contains(t, r, "Best regards", "Should have greeting with Dear") 255 | } 256 | 257 | type WithInviteCode struct { 258 | theme Theme 259 | } 260 | 261 | func (ed *WithInviteCode) getExample() (Hermes, Email) { 262 | h := Hermes{ 263 | Theme: ed.theme, 264 | Product: Product{ 265 | Name: "Hermes", 266 | Link: "http://hermes.com", 267 | }, 268 | DisableCSSInlining: true, 269 | } 270 | 271 | email := Email{ 272 | Body{ 273 | Name: "Jon Snow", 274 | Actions: []Action{ 275 | { 276 | Instructions: "Here is your invite code:", 277 | InviteCode: "123456", 278 | }, 279 | }, 280 | }, 281 | } 282 | 283 | return h, email 284 | } 285 | 286 | func (ed *WithInviteCode) assertHTMLContent(t *testing.T, r string) { 287 | assert.Contains(t, r, "Here is your invite code", "Should contains the instruction") 288 | assert.Contains(t, r, "123456", "Should contain the short code") 289 | } 290 | 291 | func (ed *WithInviteCode) assertPlainTextContent(t *testing.T, r string) { 292 | assert.Contains(t, r, "Here is your invite code", "Should contains the instruction") 293 | assert.Contains(t, r, "123456", "Should contain the short code") 294 | } 295 | 296 | type WithFreeMarkdownContent struct { 297 | theme Theme 298 | } 299 | 300 | func (ed *WithFreeMarkdownContent) getExample() (Hermes, Email) { 301 | h := Hermes{ 302 | Theme: ed.theme, 303 | Product: Product{ 304 | Name: "Hermes", 305 | Link: "http://hermes.com", 306 | }, 307 | DisableCSSInlining: true, 308 | } 309 | 310 | email := Email{ 311 | Body{ 312 | Name: "Jon Snow", 313 | FreeMarkdown: ` 314 | > _Hermes_ service will shutdown the **1st August 2025** for maintenance operations. 315 | 316 | Services will be unavailable based on the following schedule: 317 | 318 | | Services | Downtime | 319 | | :------:| :-----------: | 320 | | Service A | 2AM to 3AM | 321 | | Service B | 4AM to 5AM | 322 | | Service C | 5AM to 6AM | 323 | 324 | --- 325 | 326 | Feel free to contact us for any question regarding this matter at [support@hermes-example.com](mailto:support@hermes-example.com) or in our [Gitter](https://gitter.im/) 327 | 328 | `, 329 | Intros: []string{ 330 | "An intro that should be kept even with FreeMarkdown", 331 | }, 332 | Dictionary: []Entry{ 333 | {"Dictionary that should not be displayed", "Because of FreeMarkdown"}, 334 | }, 335 | Table: Table{ 336 | Data: [][]Entry{ 337 | { 338 | {Key: "Item", Value: "Golang"}, 339 | }, 340 | { 341 | {Key: "Item", Value: "Hermes"}, 342 | }, 343 | }, 344 | }, 345 | Actions: []Action{ 346 | { 347 | Instructions: "Action that should not be displayed, because of FreeMarkdown:", 348 | Button: Button{ 349 | Color: "#22BC66", 350 | Text: "Button", 351 | Link: "https://hermes-example.com/confirm?token=d9729feb74992cc3482b350163a1a010", 352 | }, 353 | }, 354 | }, 355 | Outros: []string{ 356 | "An outro that should be kept even with FreeMarkdown", 357 | }, 358 | }, 359 | } 360 | 361 | return h, email 362 | } 363 | 364 | func (ed *WithFreeMarkdownContent) assertHTMLContent(t *testing.T, r string) { 365 | assert.Contains(t, r, "Yours truly", "Should find signature with 'Yours truly' which is default") 366 | assert.Contains(t, r, "Jon Snow", "Should find title with 'Jon Snow'") 367 | assert.Contains(t, r, "Hermes service will shutdown", "Should find quote as HTML formatted content") 368 | assert.Contains(t, r, "