├── .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 |

{{if .Email.Body.Title }}{{ .Email.Body.Title }}{{ else }}{{ .Email.Body.Greeting }} {{ .Email.Body.Name }},{{ end }}

114 | ``` 115 | 116 | ## Intro Injection 117 | 118 | The following will inject the intro text (string or array) into the e-mail: 119 | 120 | ```html 121 | {{ with .Email.Body.Intros }} 122 | {{ if gt (len .) 0 }} 123 | {{ range $line := . }} 124 |

{{ $line }}

125 | {{ end }} 126 | {{ end }} 127 | {{ end }} 128 | ``` 129 | 130 | ## Dictionary Injection 131 | 132 | The following will inject a `
` of key-value pairs into the e-mail: 133 | 134 | ```html 135 | {{ with .Email.Body.Dictionary }} 136 | {{ if gt (len .) 0 }} 137 |
138 | {{ range $entry := . }} 139 |
{{ $entry.Key }}:
140 |
{{ $entry.Value }}
141 | {{ end }} 142 |
143 | {{ end }} 144 | {{ end }} 145 | ``` 146 | 147 | It's a good idea to add this to the top of the template to improve the styling of the dictionary: 148 | 149 | ```css 150 | /* Dictionary */ 151 | .dictionary { 152 | width: 100%; 153 | overflow: hidden; 154 | margin: 0 auto; 155 | padding: 0; 156 | } 157 | .dictionary dt { 158 | clear: both; 159 | color: #000; 160 | font-weight: bold; 161 | margin-right: 4px; 162 | } 163 | .dictionary dd { 164 | margin: 0 0 10px 0; 165 | } 166 | ``` 167 | 168 | ## Table Injection 169 | 170 | The following will inject the table into the e-mail: 171 | 172 | ```html 173 | 174 | {{ with .Email.Body.Table }} 175 | {{ $data := .Data }} 176 | {{ $columns := .Columns }} 177 | {{ if gt (len $data) 0 }} 178 | 179 | 180 | 219 | 220 |
181 | 182 | 183 | {{ $col := index $data 0 }} 184 | {{ range $entry := $col }} 185 | 199 | {{ end }} 200 | 201 | {{ range $row := $data }} 202 | 203 | {{ range $cell := $row }} 204 | 214 | {{ end }} 215 | 216 | {{ end }} 217 |
197 |

{{ $entry.Key }}

198 |
212 | {{ $cell.Value }} 213 |
218 |
221 | {{ end }} 222 | {{ end }} 223 | ``` 224 | 225 | It's a good idea to add this to the top of the template to improve the styling of the table: 226 | 227 | ```css 228 | /* Table */ 229 | .data-wrapper { 230 | width: 100%; 231 | margin: 0; 232 | padding: 35px 0; 233 | } 234 | .data-table { 235 | width: 100%; 236 | margin: 0; 237 | } 238 | .data-table th { 239 | text-align: left; 240 | padding: 0px 5px; 241 | padding-bottom: 8px; 242 | border-bottom: 1px solid #DEDEDE; 243 | } 244 | .data-table th p { 245 | margin: 0; 246 | font-size: 12px; 247 | } 248 | .data-table td { 249 | text-align: left; 250 | padding: 10px 5px; 251 | font-size: 15px; 252 | line-height: 18px; 253 | } 254 | ``` 255 | 256 | ## Action Injection 257 | 258 | The following will inject the action link (or button) into the e-mail: 259 | 260 | ```html 261 | {{ with .Email.Body.Actions }} 262 | {{ if gt (len .) 0 }} 263 | {{ range $action := . }} 264 |

{{ $action.Instructions }}

265 | 266 | 267 | 272 | 273 |
268 | 271 |
274 | {{ end }} 275 | {{ end }} 276 | {{ end }} 277 | ``` 278 | 279 | A good practice is to describe action in footer in case of problem when displaying button and CSS. The text for the description is provided through the `TroubleText` field of the `Product` struct. The text may contain a placeholder `{ACTION}` which is expected to be replaced with the text of the button. The default value of `TroubleText` is `If you’re having trouble with the button '{ACTION}', copy and paste the URL below into your web browser.` 280 | 281 | ```html 282 | {{ with .Email.Body.Actions }} 283 | 284 | 285 | {{ range $action := . }} 286 | 290 | {{ end }} 291 | 292 | 293 |
287 |

{{$.Hermes.Product.TroubleText | replace "{ACTION}" $action.Button.Text}}

288 |

{{ $action.Button.Link }}

289 |
294 | {{ end }} 295 | ``` 296 | 297 | Be aware that Outlook HTML engine is very old and is not compatible with many CSS features. 298 | It means, if you want to create a button, the best solution is to create a case only for Outlook. For example, in flat theme, the following code is used to create a button which is a Microsoft VML rectangle. It will not be as perfect as pure CSS interpreted by recent engines, but it will do the work. 299 | 300 | ``` 301 | {{safe "" }} 317 | ``` 318 | 319 | When the action is an invite code, use this kind of code: 320 | 321 | ```html 322 | {{ if $action.InviteCode }} 323 |
324 | 325 | 326 | 335 | 336 |
327 | 328 | 329 | 332 | 333 |
330 | {{ $action.InviteCode }} 331 |
334 |
337 |
338 | {{ end }} 339 | ``` 340 | 341 | ## Outro Injection 342 | 343 | The following will inject the outro text (string or array) into the e-mail: 344 | 345 | ```html 346 | {{ with .Email.Body.Outros }} 347 | {{ if gt (len .) 0 }} 348 | {{ range $line := . }} 349 |

{{ $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 |
362 | {{.Hermes.Product.Name}} 363 | ``` 364 | 365 | ## Copyright Injection 366 | 367 | The following will inject the copyright notice into the e-mail: 368 | 369 | ```html 370 | {{.Hermes.Product.Copyright}} 371 | ``` 372 | 373 | ## Text Direction Injection 374 | 375 | In order to support generating RTL e-mails, inject the `textDirection` variable into the `` tag: 376 | 377 | ```html 378 | 379 | ``` 380 | 381 | ## FreeMarkdown Injection 382 | 383 | In order to support Markdown free content, inject the following code: 384 | 385 | ````html 386 | {{ if (ne .Email.Body.FreeMarkdown "") }} 387 | {{ .Email.Body.FreeMarkdown.ToHTML }} 388 | {{ else }} 389 | [... Here is the templating for dictionary, table and actions] 390 | {{ end }} 391 | ``` 392 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2025 Hermes - Mathieu Cornic 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hermes 2 | 3 | [![Build Status](https://github.com/matcornic/hermes/actions/workflows/main.yml/badge.svg)](https://github.com/matcornic/hermes/actions/workflows/main.yml) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/matcornic/hermes)](https://goreportcard.com/report/github.com/matcornic/hermes) 5 | [![Godoc](https://godoc.org/github.com/matcornic/hermes?status.svg)](https://godoc.org/github.com/matcornic/hermes) 6 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fmatcornic%2Fhermes.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2Fmatcornic%2Fhermes?ref=badge_shield) 7 | 8 | Hermes is the Go port of the great [mailgen](https://github.com/eladnava/mailgen) engine for Node.js. Check their work, it's awesome! 9 | It's a package that generates clean, responsive HTML e-mails for sending transactional e-mails (welcome e-mails, reset password e-mails, receipt e-mails and so on), and associated plain text fallback. 10 | 11 | # Demo 12 | 13 | 14 | 15 | # Usage 16 | 17 | First install the package: 18 | 19 | ``` 20 | go get github.com/matcornic/hermes@v1.3.0 21 | ``` 22 | 23 | ## Migrate back from `v2` to `v1.3.0` 24 | 25 | At the time of `v2` creation, Go modules logic and best practices were still unsure. v1 did not use modules. 26 | Having a dedicated `v2` module is meant to be used for projects hosting and maintaining `v1` and `v2` at the same time, so people can use both versions at the same time. There is no need for that in this kind of project. 27 | Now that go modules usage is standard, in `v1`, starting from `v1.3.0` tags, we decided to migrate back to `github.com/matcornic/hermes` instead of `github.com/matcornic/hermes/v2`. 28 | `v2` tags will still use `github.com/matcornic/hermes/v2` as the import path, normal way will use `github.com/matcornic/hermes` as the import path. 29 | 30 | So, just replace your import path from `github.com/matcornic/hermes/v2` to `github.com/matcornic/hermes` and run `go get github.com/matcornic/hermes@v1.3.0` (or newer) to update the dependency. 31 | 32 | ## Use Hermes 33 | 34 | Then, start using the package by importing and configuring it: 35 | 36 | ```go 37 | // Configure hermes by setting a theme and your product info 38 | h := hermes.Hermes{ 39 | // Optional Theme 40 | // Theme: new(Default) 41 | Product: hermes.Product{ 42 | // Appears in header & footer of e-mails 43 | Name: "Hermes", 44 | Link: "https://example-hermes.com/", 45 | // Optional product logo 46 | Logo: "http://www.duchess-france.org/wp-content/uploads/2016/01/gopher.png", 47 | }, 48 | } 49 | ``` 50 | 51 | Next, generate an e-mail using the following code: 52 | 53 | ```go 54 | email := hermes.Email{ 55 | Body: hermes.Body{ 56 | Name: "Jon Snow", 57 | Intros: []string{ 58 | "Welcome to Hermes! We're very excited to have you on board.", 59 | }, 60 | Actions: []hermes.Action{ 61 | { 62 | Instructions: "To get started with Hermes, please click here:", 63 | Button: hermes.Button{ 64 | Color: "#22BC66", // Optional action button color 65 | Text: "Confirm your account", 66 | Link: "https://hermes-example.com/confirm?token=d9729feb74992cc3482b350163a1a010", 67 | }, 68 | }, 69 | }, 70 | Outros: []string{ 71 | "Need help, or have questions? Just reply to this email, we'd love to help.", 72 | }, 73 | }, 74 | } 75 | 76 | // Generate an HTML email with the provided contents (for modern clients) 77 | emailBody, err := h.GenerateHTML(email) 78 | if err != nil { 79 | panic(err) // Tip: Handle error with something else than a panic ;) 80 | } 81 | 82 | // Generate the plaintext version of the e-mail (for clients that do not support xHTML) 83 | emailText, err := h.GeneratePlainText(email) 84 | if err != nil { 85 | panic(err) // Tip: Handle error with something else than a panic ;) 86 | } 87 | 88 | // Optionally, preview the generated HTML e-mail by writing it to a local file 89 | err = os.WriteFile("preview.html", []byte(emailBody), 0644) 90 | if err != nil { 91 | panic(err) // Tip: Handle error with something else than a panic ;) 92 | } 93 | ``` 94 | 95 | This code would output the following HTML template: 96 | 97 | 98 | 99 | And the following plain text: 100 | 101 | ``` 102 | 103 | ------------ 104 | Hi Jon Snow, 105 | ------------ 106 | 107 | Welcome to Hermes! We're very excited to have you on board. 108 | 109 | To get started with Hermes, please click here: https://hermes-example.com/confirm?token=d9729feb74992cc3482b350163a1a010 110 | 111 | Need help, or have questions? Just reply to this email, we'd love to help. 112 | 113 | Yours truly, 114 | Hermes - https://example-hermes.com/ 115 | 116 | Copyright © 2025 Hermes. All rights reserved. 117 | ``` 118 | 119 | > Theme templates will be embedded in your application binary. If you want to use external templates (for configuration), use your own theme by implementing `hermes.Theme` interface with code searching for your files. 120 | 121 | ## More Examples 122 | 123 | * [Welcome with button](examples/welcome.go) 124 | * [Welcome with invite code](examples/invite_code.go) 125 | * [Receipt](examples/receipt.go) 126 | * [Password Reset](examples/reset.go) 127 | * [Maintenance](examples/maintenance.go) 128 | 129 | To run the examples, go to `examples` folder, then run `go run -a *.go`. HTML and Plaintext example should be created in given theme folders. 130 | 131 | Optionaly you can set the following variables to send automatically the emails to one your mailbox. Nice for testing template in real email clients. 132 | 133 | * `HERMES_SEND_EMAILS=true` 134 | * `HERMES_SMTP_SERVER=` : for Gmail it's `smtp.gmail.com` 135 | * `HERMES_SMTP_PORT=` : for Gmail it's `465` 136 | * `HERMES_SENDER_EMAIL=` 137 | * `HERMES_SENDER_IDENTITY=` 138 | * `HERMES_SMTP_USER=` : usually the same than `HERMES_SENDER_EMAIL` 139 | * `HERMES_TO=`: split by commas like `myadress@test.com,somethingelse@gmail.com` 140 | 141 | The program will ask for your SMTP password. If needed, you can set it with `HERMES_SMTP_PASSWORD` variable (but be careful where you put this information !) 142 | 143 | ## Plaintext E-mails 144 | 145 | To generate a [plaintext version of the e-mail](https://litmus.com/blog/best-practices-for-plain-text-emails-a-look-at-why-theyre-important), simply call `GeneratePlainText` function: 146 | 147 | ```go 148 | // Generate plaintext email using hermes 149 | emailText, err := h.GeneratePlainText(email) 150 | if err != nil { 151 | panic(err) // Tip: Handle error with something else than a panic ;) 152 | } 153 | ``` 154 | 155 | ## Supported Themes 156 | 157 | The following open-source themes are bundled with this package: 158 | 159 | * `default` by [Postmark Transactional Email Templates](https://github.com/wildbit/postmark-templates) 160 | 161 | 162 | 163 | * `flat`, slightly modified from [Postmark Transactional Email Templates](https://github.com/wildbit/postmark-templates) 164 | 165 | 166 | 167 | ## RTL Support 168 | 169 | To change the default text direction (left-to-right), simply override it as follows: 170 | 171 | ```go 172 | // Configure hermes by setting a theme and your product info 173 | h := hermes.Hermes { 174 | // Custom text direction 175 | TextDirection: hermes.TDRightToLeft, 176 | } 177 | ``` 178 | 179 | ## Language Customizations 180 | 181 | To customize the e-mail's greeting ("Hi") or signature ("Yours truly"), supply custom strings within the e-mail's `Body`: 182 | 183 | ```go 184 | email := hermes.Email{ 185 | Body: hermes.Body{ 186 | Greeting: "Dear", 187 | Signature: "Sincerely", 188 | }, 189 | } 190 | ``` 191 | 192 | To use a custom title string rather than a greeting/name introduction, provide it instead of `Name`: 193 | 194 | ```go 195 | email := hermes.Email{ 196 | Body: hermes.Body{ 197 | // Title will override `Name` 198 | Title: "Welcome to Hermes", 199 | }, 200 | } 201 | ``` 202 | 203 | To customize the `Copyright`, override it when initializing `Hermes` within your `Product` as follows: 204 | 205 | ```go 206 | // Configure hermes by setting a theme and your product info 207 | h := hermes.Hermes{ 208 | // Optional Theme 209 | // Theme: new(Default) 210 | Product: hermes.Product{ 211 | // Appears in header & footer of e-mails 212 | Name: "Hermes", 213 | Link: "https://example-hermes.com/", 214 | // Custom copyright notice 215 | Copyright: "Copyright © 2025 Dharma Initiative. All rights reserved." 216 | }, 217 | } 218 | ``` 219 | 220 | To use a custom fallback text at the end of the email, change the `TroubleText` field of the `hermes.Product` struct. The default value is `If you’re having trouble with the button '{ACTION}', copy and paste the URL below into your web browser.`. The `{ACTION}` placeholder will be replaced with the corresponding text of the supplied action button: 221 | 222 | ```go 223 | // Configure hermes by setting a theme and your product info 224 | h := hermes.Hermes{ 225 | // Optional Theme 226 | // Theme: new(Default) 227 | Product: hermes.Product{ 228 | // Custom trouble text 229 | TroubleText: "If the {ACTION}-button is not working for you, just copy and paste the URL below into your web browser." 230 | }, 231 | } 232 | ``` 233 | 234 | Since `v2.1.0`, Hermes is automatically inlining all CSS to improve compatibility with email clients, thanks to [Premailer](https://github.com/vanng822/go-premailer/premailer). 235 | You can disable this feature by setting `DisableCSSInlining` of `Hermes` struct to `true`. 236 | 237 | ```go 238 | h := hermes.Hermes{ 239 | ... 240 | DisableCSSInlining: true, 241 | } 242 | ``` 243 | 244 | ## Elements 245 | 246 | Hermes supports injecting custom elements such as dictionaries, tables and action buttons into e-mails. 247 | 248 | ### Action 249 | 250 | To inject an action button in to the e-mail, supply the `Actions` object as follows: 251 | 252 | ```go 253 | email := hermes.Email{ 254 | Body: hermes.Body{ 255 | Actions: []hermes.Action{ 256 | { 257 | Instructions: "To get started with Hermes, please click here:", 258 | Button: hermes.Button{ 259 | Color: "#22BC66", // Optional action button color 260 | Text: "Confirm your account", 261 | Link: "https://hermes-example.com/confirm?token=d9729feb74992cc3482b350163a1a010", 262 | }, 263 | }, 264 | }, 265 | }, 266 | } 267 | ``` 268 | 269 | Alternatively, instead of having a button, an action can be an invite code as follows: 270 | 271 | ```go 272 | email := hermes.Email{ 273 | Body: hermes.Body{ 274 | Actions: []hermes.Action{ 275 | { 276 | Instructions: "To get started with Hermes, please use the invite code:", 277 | InviteCode: "123456", 278 | }, 279 | }, 280 | }, 281 | } 282 | ``` 283 | 284 | To inject multiple action buttons in to the e-mail, supply another struct in Actions slice `Action`. 285 | 286 | ### Table 287 | 288 | To inject a table into the e-mail, supply the `Table` object as follows: 289 | 290 | ```go 291 | email := hermes.Email{ 292 | Body: hermes.Body{ 293 | Table: hermes.Table{ 294 | Data: [][]hermes.Entry{ 295 | // List of rows 296 | { 297 | // Key is the column name, Value is the cell value 298 | // First object defines what columns will be displayed 299 | {Key: "Item", Value: "Golang"}, 300 | {Key: "Description", Value: "Open source programming language that makes it easy to build simple, reliable, and efficient software"}, 301 | {Key: "Price", Value: "$10.99"}, 302 | }, 303 | { 304 | {Key: "Item", Value: "Hermes"}, 305 | {Key: "Description", Value: "Programmatically create beautiful e-mails using Golang."}, 306 | {Key: "Price", Value: "$1.99"}, 307 | }, 308 | }, 309 | Columns: hermes.Columns{ 310 | // Custom style for each rows 311 | CustomWidth: map[string]string{ 312 | "Item": "20%", 313 | "Price": "15%", 314 | }, 315 | CustomAlignment: map[string]string{ 316 | "Price": "right", 317 | }, 318 | }, 319 | }, 320 | }, 321 | } 322 | ``` 323 | 324 | ### Dictionary 325 | 326 | To inject key-value pairs of data into the e-mail, supply the `Dictionary` object as follows: 327 | 328 | ```go 329 | email := hermes.Email{ 330 | Body: hermes.Body{ 331 | Dictionary: []hermes.Entry{ 332 | {Key: "Date", Value: "20 November 1887"}, 333 | {Key: "Address", Value: "221B Baker Street, London"}, 334 | }, 335 | }, 336 | } 337 | ``` 338 | 339 | ### Free Markdown 340 | 341 | If you need more flexibility in the content of your generated e-mail, while keeping the same format than any other e-mail, use Markdown content. Supply the `FreeMarkdown` object as follows: 342 | 343 | ```go 344 | email := hermes.Email{ 345 | Body: hermes.Body{ 346 | FreeMarkdown: ` 347 | > _Hermes_ service will shutdown the **1st August 2025** for maintenance operations. 348 | 349 | Services will be unavailable based on the following schedule: 350 | 351 | | Services | Downtime | 352 | | :------:| :-----------: | 353 | | Service A | 2AM to 3AM | 354 | | Service B | 4AM to 5AM | 355 | | Service C | 5AM to 6AM | 356 | 357 | --- 358 | 359 | 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/) 360 | 361 | `, 362 | }, 363 | } 364 | } 365 | ``` 366 | 367 | This code would output the following HTML template: 368 | 369 | 370 | 371 | And the following plaintext: 372 | 373 | ``` 374 | ------------ 375 | Hi Jon Snow, 376 | ------------ 377 | 378 | > 379 | > 380 | > 381 | > Hermes service will shutdown the *1st August 2025* for maintenance 382 | > operations. 383 | > 384 | > 385 | 386 | Services will be unavailable based on the following schedule: 387 | 388 | +-----------+------------+ 389 | | SERVICES | DOWNTIME | 390 | +-----------+------------+ 391 | | Service A | 2AM to 3AM | 392 | | Service B | 4AM to 5AM | 393 | | Service C | 5AM to 6AM | 394 | +-----------+------------+ 395 | 396 | Feel free to contact us for any question regarding this matter at support@hermes-example.com ( support@hermes-example.com ) or in our Gitter ( https://gitter.im/ ) 397 | 398 | Yours truly, 399 | Hermes - https://example-hermes.com/ 400 | 401 | Copyright © 2025 Hermes. All rights reserved. 402 | ``` 403 | 404 | Be aware that this content will replace existing tables, dictionary and actions. Only intros, outros, header and footer will be kept. 405 | 406 | This is helpful when your application needs sending e-mails, wrote on-the-fly by adminstrators. 407 | 408 | > Markdown is rendered with [Blackfriday](https://github.com/russross/blackfriday), so every thing Blackfriday can do, Hermes can do it as well. 409 | 410 | ## Troubleshooting 411 | 412 | 1. After sending multiple e-mails to the same Gmail / Inbox address, they become grouped and truncated since they contain similar text, breaking the responsive e-mail layout. 413 | 414 | > Simply sending the `X-Entity-Ref-ID` header with your e-mails will prevent grouping / truncation. 415 | 416 | ## Contributing 417 | 418 | See [CONTRIBUTING.md](CONTRIBUTING.md) 419 | 420 | ## License 421 | 422 | Apache 2.0 423 | 424 | 425 | 426 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fmatcornic%2Fhermes.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Fmatcornic%2Fhermes?ref=badge_large) 427 | -------------------------------------------------------------------------------- /examples/default/default.invite_code.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 23 | 24 | 25 | 26 | 157 | 158 | 159 | 160 | 161 | -------------------------------------------------------------------------------- /examples/default/default.invite_code.txt: -------------------------------------------------------------------------------- 1 | ------------ 2 | Hi Jon Snow, 3 | ------------ 4 | 5 | Welcome to Hermes! We're very excited to have you on board. 6 | 7 | Please copy your invite code: 123456 8 | 9 | Need help, or have questions? Just reply to this email, we'd love to help. 10 | 11 | Yours truly, 12 | Hermes - https://example-hermes.com/ 13 | 14 | Copyright © 2025 Hermes. All rights reserved. -------------------------------------------------------------------------------- /examples/default/default.maintenance.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 23 | 24 | 25 | 26 | 112 | 113 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /examples/default/default.maintenance.txt: -------------------------------------------------------------------------------- 1 | ------------ 2 | Hi Jon Snow, 3 | ------------ 4 | 5 | > 6 | > 7 | > 8 | > Hermes service will shutdown the *1st August 2017* for maintenance 9 | > operations. 10 | > 11 | > 12 | 13 | Services will be unavailable based on the following schedule: 14 | 15 | +-----------+------------+ 16 | | SERVICES | DOWNTIME | 17 | +-----------+------------+ 18 | | Service A | 2AM to 3AM | 19 | | Service B | 4AM to 5AM | 20 | | Service C | 5AM to 6AM | 21 | +-----------+------------+ 22 | 23 | Feel free to contact us for any question regarding this matter at support@hermes-example.com or in our Gitter ( https://gitter.im/ ) 24 | 25 | Yours truly, 26 | Hermes - https://example-hermes.com/ 27 | 28 | Copyright © 2025 Hermes. All rights reserved. -------------------------------------------------------------------------------- /examples/default/default.receipt.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 23 | 24 | 25 | 26 | 217 | 218 | 219 | 220 | 221 | -------------------------------------------------------------------------------- /examples/default/default.receipt.txt: -------------------------------------------------------------------------------- 1 | ------------ 2 | Hi Jon Snow, 3 | ------------ 4 | 5 | Your order has been processed successfully. 6 | 7 | +--------+--------------------------------+--------+ 8 | | ITEM | DESCRIPTION | PRICE | 9 | +--------+--------------------------------+--------+ 10 | | Golang | Open source programming | $10.99 | 11 | | | language that makes it easy | | 12 | | | to build simple, reliable, and | | 13 | | | efficient software | | 14 | | Hermes | Programmatically create | $1.99 | 15 | | | beautiful e-mails using | | 16 | | | Golang. | | 17 | +--------+--------------------------------+--------+ 18 | 19 | You can check the status of your order and more in your dashboard: https://hermes-example.com/dashboard 20 | 21 | Yours truly, 22 | Hermes - https://example-hermes.com/ 23 | 24 | Copyright © 2025 Hermes. All rights reserved. -------------------------------------------------------------------------------- /examples/default/default.reset.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 23 | 24 | 25 | 26 | 165 | 166 | 167 | 168 | 169 | -------------------------------------------------------------------------------- /examples/default/default.reset.txt: -------------------------------------------------------------------------------- 1 | ------------ 2 | Hi Jon Snow, 3 | ------------ 4 | 5 | You have received this email because a password reset request for Hermes account was received. 6 | 7 | Click the button below to reset your password: https://hermes-example.com/reset-password?token=d9729feb74992cc3482b350163a1a010 8 | 9 | If you did not request a password reset, no further action is required on your part. 10 | 11 | Thanks, 12 | Hermes - https://example-hermes.com/ 13 | 14 | Copyright © 2025 Hermes. All rights reserved. -------------------------------------------------------------------------------- /examples/default/default.welcome.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 23 | 24 | 25 | 26 | 180 | 181 | 182 | 183 | 184 | -------------------------------------------------------------------------------- /examples/default/default.welcome.txt: -------------------------------------------------------------------------------- 1 | ------------ 2 | Hi Jon Snow, 3 | ------------ 4 | 5 | Welcome to Hermes! We're very excited to have you on board. 6 | 7 | * Firstname: Jon 8 | * Lastname: Snow 9 | * Birthday: 01/01/283 10 | 11 | To get started with Hermes, please click here: https://hermes-example.com/confirm?token=d9729feb74992cc3482b350163a1a010 12 | 13 | Need help, or have questions? Just reply to this email, we'd love to help. 14 | 15 | Yours truly, 16 | Hermes - https://example-hermes.com/ 17 | 18 | Copyright © 2025 Hermes. All rights reserved. -------------------------------------------------------------------------------- /examples/flat/flat.invite_code.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 18 | 19 | 20 | 21 | 149 | 150 | 151 | 152 | 153 | -------------------------------------------------------------------------------- /examples/flat/flat.invite_code.txt: -------------------------------------------------------------------------------- 1 | ------------ 2 | Hi Jon Snow, 3 | ------------ 4 | 5 | Welcome to Hermes! We're very excited to have you on board. 6 | 7 | Please copy your invite code: 123456 8 | 9 | Need help, or have questions? Just reply to this email, we'd love to help. 10 | 11 | Yours truly, 12 | Hermes - https://example-hermes.com/ 13 | 14 | Copyright © 2025 Hermes. All rights reserved. -------------------------------------------------------------------------------- /examples/flat/flat.maintenance.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 18 | 19 | 20 | 21 | 107 | 108 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /examples/flat/flat.maintenance.txt: -------------------------------------------------------------------------------- 1 | ------------ 2 | Hi Jon Snow, 3 | ------------ 4 | 5 | > 6 | > 7 | > 8 | > Hermes service will shutdown the *1st August 2017* for maintenance 9 | > operations. 10 | > 11 | > 12 | 13 | Services will be unavailable based on the following schedule: 14 | 15 | +-----------+------------+ 16 | | SERVICES | DOWNTIME | 17 | +-----------+------------+ 18 | | Service A | 2AM to 3AM | 19 | | Service B | 4AM to 5AM | 20 | | Service C | 5AM to 6AM | 21 | +-----------+------------+ 22 | 23 | Feel free to contact us for any question regarding this matter at support@hermes-example.com or in our Gitter ( https://gitter.im/ ) 24 | 25 | Yours truly, 26 | Hermes - https://example-hermes.com/ 27 | 28 | Copyright © 2025 Hermes. All rights reserved. -------------------------------------------------------------------------------- /examples/flat/flat.receipt.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 18 | 19 | 20 | 21 | 209 | 210 | 211 | 212 | 213 | -------------------------------------------------------------------------------- /examples/flat/flat.receipt.txt: -------------------------------------------------------------------------------- 1 | ------------ 2 | Hi Jon Snow, 3 | ------------ 4 | 5 | Your order has been processed successfully. 6 | 7 | +--------+--------------------------------+--------+ 8 | | ITEM | DESCRIPTION | PRICE | 9 | +--------+--------------------------------+--------+ 10 | | Golang | Open source programming | $10.99 | 11 | | | language that makes it easy | | 12 | | | to build simple, reliable, and | | 13 | | | efficient software | | 14 | | Hermes | Programmatically create | $1.99 | 15 | | | beautiful e-mails using | | 16 | | | Golang. | | 17 | +--------+--------------------------------+--------+ 18 | 19 | You can check the status of your order and more in your dashboard: https://hermes-example.com/dashboard 20 | 21 | Yours truly, 22 | Hermes - https://example-hermes.com/ 23 | 24 | Copyright © 2025 Hermes. All rights reserved. -------------------------------------------------------------------------------- /examples/flat/flat.reset.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 18 | 19 | 20 | 21 | 157 | 158 | 159 | 160 | 161 | -------------------------------------------------------------------------------- /examples/flat/flat.reset.txt: -------------------------------------------------------------------------------- 1 | ------------ 2 | Hi Jon Snow, 3 | ------------ 4 | 5 | You have received this email because a password reset request for Hermes account was received. 6 | 7 | Click the button below to reset your password: https://hermes-example.com/reset-password?token=d9729feb74992cc3482b350163a1a010 8 | 9 | If you did not request a password reset, no further action is required on your part. 10 | 11 | Thanks, 12 | Hermes - https://example-hermes.com/ 13 | 14 | Copyright © 2025 Hermes. All rights reserved. -------------------------------------------------------------------------------- /examples/flat/flat.welcome.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 18 | 19 | 20 | 21 | 172 | 173 | 174 | 175 | 176 | -------------------------------------------------------------------------------- /examples/flat/flat.welcome.txt: -------------------------------------------------------------------------------- 1 | ------------ 2 | Hi Jon Snow, 3 | ------------ 4 | 5 | Welcome to Hermes! We're very excited to have you on board. 6 | 7 | * Firstname: Jon 8 | * Lastname: Snow 9 | * Birthday: 01/01/283 10 | 11 | To get started with Hermes, please click here: https://hermes-example.com/confirm?token=d9729feb74992cc3482b350163a1a010 12 | 13 | Need help, or have questions? Just reply to this email, we'd love to help. 14 | 15 | Yours truly, 16 | Hermes - https://example-hermes.com/ 17 | 18 | Copyright © 2025 Hermes. All rights reserved. -------------------------------------------------------------------------------- /examples/gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matcornic/hermes/db8901938bc20e5c1980731141e6845621ec625e/examples/gopher.png -------------------------------------------------------------------------------- /examples/invite_code.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/matcornic/hermes" 5 | ) 6 | 7 | type inviteCode struct { 8 | } 9 | 10 | func (w *inviteCode) Name() string { 11 | return "invite_code" 12 | } 13 | 14 | func (w *inviteCode) Email() hermes.Email { 15 | return hermes.Email{ 16 | Body: hermes.Body{ 17 | Name: "Jon Snow", 18 | Intros: []string{ 19 | "Welcome to Hermes! We're very excited to have you on board.", 20 | }, 21 | Actions: []hermes.Action{ 22 | { 23 | Instructions: "Please copy your invite code:", 24 | InviteCode: "123456", 25 | }, 26 | }, 27 | Outros: []string{ 28 | "Need help, or have questions? Just reply to this email, we'd love to help.", 29 | }, 30 | }, 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "net/mail" 8 | "os" 9 | "strconv" 10 | 11 | "github.com/go-gomail/gomail" 12 | "golang.org/x/term" 13 | 14 | "github.com/matcornic/hermes" 15 | ) 16 | 17 | var ( 18 | errEmptyServerConfig = errors.New("SMTP server config is empty") 19 | errEmptyPort = errors.New("SMTP port config is empty") 20 | errEmptyUser = errors.New("SMTP user is empty") 21 | errEmptySenderIdentity = errors.New("SMTP sender identity is empty") 22 | errEmptySenderEmail = errors.New("SMTP sender email is empty") 23 | errEmptyReceiverEmails = errors.New("no receiver emails configured") 24 | ) 25 | 26 | type example interface { 27 | Email() hermes.Email 28 | Name() string 29 | } 30 | 31 | func main() { 32 | 33 | h := hermes.Hermes{ 34 | Product: hermes.Product{ 35 | Name: "Hermes", 36 | Link: "https://example-hermes.com/", 37 | Logo: "https://github.com/matcornic/hermes/blob/master/examples/gopher.png?raw=true", 38 | }, 39 | } 40 | sendEmails := os.Getenv("HERMES_SEND_EMAILS") == "true" 41 | 42 | examples := []example{ 43 | new(welcome), 44 | new(reset), 45 | new(receipt), 46 | new(maintenance), 47 | new(inviteCode), 48 | } 49 | 50 | themes := []hermes.Theme{ 51 | new(hermes.Default), 52 | new(hermes.Flat), 53 | } 54 | 55 | // Generate emails 56 | for _, theme := range themes { 57 | h.Theme = theme 58 | for _, e := range examples { 59 | generateEmails(h, e.Email(), e.Name()) 60 | } 61 | } 62 | 63 | // Send emails only when requested 64 | if sendEmails { 65 | port, _ := strconv.Atoi(os.Getenv("HERMES_SMTP_PORT")) 66 | password := os.Getenv("HERMES_SMTP_PASSWORD") 67 | SMTPUser := os.Getenv("HERMES_SMTP_USER") 68 | if password == "" { 69 | log.Printf("Enter SMTP password of '%s' account: ", SMTPUser) 70 | bytePassword, _ := term.ReadPassword(0) 71 | password = string(bytePassword) 72 | } 73 | smtpConfig := smtpAuthentication{ 74 | Server: os.Getenv("HERMES_SMTP_SERVER"), 75 | Port: port, 76 | SenderEmail: os.Getenv("HERMES_SENDER_EMAIL"), 77 | SenderIdentity: os.Getenv("HERMES_SENDER_IDENTITY"), 78 | SMTPPassword: password, 79 | SMTPUser: SMTPUser, 80 | } 81 | options := sendOptions{ 82 | To: os.Getenv("HERMES_TO"), 83 | } 84 | for _, theme := range themes { 85 | h.Theme = theme 86 | for _, e := range examples { 87 | options.Subject = "Hermes | " + h.Theme.Name() + " | " + e.Name() 88 | log.Printf("Sending email '%s'...\n", options.Subject) 89 | htmlBytes, err := os.ReadFile(fmt.Sprintf("%v/%v.%v.html", h.Theme.Name(), h.Theme.Name(), e.Name())) 90 | if err != nil { 91 | panic(err) 92 | } 93 | txtBytes, err := os.ReadFile(fmt.Sprintf("%v/%v.%v.txt", h.Theme.Name(), h.Theme.Name(), e.Name())) 94 | if err != nil { 95 | panic(err) 96 | } 97 | err = send(smtpConfig, options, string(htmlBytes), string(txtBytes)) 98 | if err != nil { 99 | panic(err) 100 | } 101 | } 102 | } 103 | } 104 | } 105 | 106 | func generateEmails(h hermes.Hermes, email hermes.Email, example string) { 107 | // Generate the HTML template and save it 108 | res, err := h.GenerateHTML(email) 109 | if err != nil { 110 | panic(err) 111 | } 112 | err = os.MkdirAll(h.Theme.Name(), 0750) 113 | if err != nil { 114 | panic(err) 115 | } 116 | htmlFile := fmt.Sprintf("%v/%v.%v.html", h.Theme.Name(), h.Theme.Name(), example) 117 | err = os.WriteFile(htmlFile, []byte(res), 0600) 118 | if err != nil { 119 | panic(err) 120 | } 121 | 122 | // Generate the plaintext template and save it 123 | res, err = h.GeneratePlainText(email) 124 | if err != nil { 125 | panic(err) 126 | } 127 | plaintextFile := fmt.Sprintf("%v/%v.%v.txt", h.Theme.Name(), h.Theme.Name(), example) 128 | err = os.WriteFile(plaintextFile, []byte(res), 0600) 129 | if err != nil { 130 | panic(err) 131 | } 132 | } 133 | 134 | type smtpAuthentication struct { 135 | Server string 136 | Port int 137 | SenderEmail string 138 | SenderIdentity string 139 | SMTPUser string 140 | SMTPPassword string 141 | } 142 | 143 | // sendOptions are options for sending an email 144 | type sendOptions struct { 145 | To string 146 | Subject string 147 | } 148 | 149 | // send sends the email 150 | func send(smtpConfig smtpAuthentication, options sendOptions, htmlBody string, txtBody string) error { 151 | 152 | if smtpConfig.Server == "" { 153 | return errEmptyServerConfig 154 | } 155 | 156 | if smtpConfig.Port == 0 { 157 | return errEmptyPort 158 | } 159 | 160 | if smtpConfig.SMTPUser == "" { 161 | return errEmptyUser 162 | } 163 | 164 | if smtpConfig.SenderIdentity == "" { 165 | return errEmptySenderIdentity 166 | } 167 | 168 | if smtpConfig.SenderEmail == "" { 169 | return errEmptySenderEmail 170 | } 171 | 172 | if options.To == "" { 173 | return errEmptyReceiverEmails 174 | } 175 | 176 | from := mail.Address{ 177 | Name: smtpConfig.SenderIdentity, 178 | Address: smtpConfig.SenderEmail, 179 | } 180 | 181 | m := gomail.NewMessage() 182 | m.SetHeader("From", from.String()) 183 | m.SetHeader("To", options.To) 184 | m.SetHeader("Subject", options.Subject) 185 | 186 | m.SetBody("text/plain", txtBody) 187 | m.AddAlternative("text/html", htmlBody) 188 | 189 | d := gomail.NewDialer(smtpConfig.Server, smtpConfig.Port, smtpConfig.SMTPUser, smtpConfig.SMTPPassword) 190 | 191 | return d.DialAndSend(m) 192 | } 193 | -------------------------------------------------------------------------------- /examples/maintenance.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/matcornic/hermes" 5 | ) 6 | 7 | type maintenance struct { 8 | } 9 | 10 | func (w *maintenance) Name() string { 11 | return "maintenance" 12 | } 13 | 14 | func (w *maintenance) Email() hermes.Email { 15 | return hermes.Email{ 16 | Body: hermes.Body{ 17 | Name: "Jon Snow", 18 | FreeMarkdown: ` 19 | > _Hermes_ service will shutdown the **1st August 2025** for maintenance operations. 20 | 21 | Services will be unavailable based on the following schedule: 22 | 23 | | Services | Downtime | 24 | | :------:| :-----------: | 25 | | Service A | 2AM to 3AM | 26 | | Service B | 4AM to 5AM | 27 | | Service C | 5AM to 6AM | 28 | 29 | 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/) 30 | 31 | `, 32 | }, 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/receipt.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/matcornic/hermes" 5 | ) 6 | 7 | type receipt struct { 8 | } 9 | 10 | func (r *receipt) Name() string { 11 | return "receipt" 12 | } 13 | 14 | func (r *receipt) Email() hermes.Email { 15 | return hermes.Email{ 16 | Body: hermes.Body{ 17 | Name: "Jon Snow", 18 | Intros: []string{ 19 | "Your order has been processed successfully.", 20 | }, 21 | Table: hermes.Table{ 22 | Data: [][]hermes.Entry{ 23 | { 24 | {Key: "Item", Value: "Golang"}, 25 | {Key: "Description", Value: "Open source programming language that makes it easy to build simple, reliable, and efficient software"}, 26 | {Key: "Price", Value: "$10.99"}, 27 | }, 28 | { 29 | {Key: "Item", Value: "Hermes"}, 30 | {Key: "Description", Value: "Programmatically create beautiful e-mails using Golang."}, 31 | {Key: "Price", Value: "$1.99"}, 32 | }, 33 | }, 34 | Columns: hermes.Columns{ 35 | CustomWidth: map[string]string{ 36 | "Item": "20%", 37 | "Price": "15%", 38 | }, 39 | CustomAlignment: map[string]string{ 40 | "Price": "right", 41 | }, 42 | }, 43 | }, 44 | Actions: []hermes.Action{ 45 | { 46 | Instructions: "You can check the status of your order and more in your dashboard:", 47 | Button: hermes.Button{ 48 | Text: "Go to Dashboard", 49 | Link: "https://hermes-example.com/dashboard", 50 | }, 51 | }, 52 | }, 53 | }, 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /examples/reset.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/matcornic/hermes" 5 | ) 6 | 7 | type reset struct { 8 | } 9 | 10 | func (r *reset) Name() string { 11 | return "reset" 12 | } 13 | 14 | func (r *reset) Email() hermes.Email { 15 | return hermes.Email{ 16 | Body: hermes.Body{ 17 | Name: "Jon Snow", 18 | Intros: []string{ 19 | "You have received this email because a password reset request for Hermes account was received.", 20 | }, 21 | Actions: []hermes.Action{ 22 | { 23 | Instructions: "Click the button below to reset your password:", 24 | Button: hermes.Button{ 25 | Color: "#DC4D2F", 26 | Text: "Reset your password", 27 | Link: "https://hermes-example.com/reset-password?token=d9729feb74992cc3482b350163a1a010", 28 | }, 29 | }, 30 | }, 31 | Outros: []string{ 32 | "If you did not request a password reset, no further action is required on your part.", 33 | }, 34 | Signature: "Thanks", 35 | }, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /examples/welcome.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/matcornic/hermes" 5 | ) 6 | 7 | type welcome struct { 8 | } 9 | 10 | func (w *welcome) Name() string { 11 | return "welcome" 12 | } 13 | 14 | func (w *welcome) Email() hermes.Email { 15 | return hermes.Email{ 16 | Body: hermes.Body{ 17 | Name: "Jon Snow", 18 | Intros: []string{ 19 | "Welcome to Hermes! We're very excited to have you on board.", 20 | }, 21 | Dictionary: []hermes.Entry{ 22 | {Key: "Firstname", Value: "Jon"}, 23 | {Key: "Lastname", Value: "Snow"}, 24 | {Key: "Birthday", Value: "01/01/283"}, 25 | }, 26 | Actions: []hermes.Action{ 27 | { 28 | Instructions: "To get started with Hermes, please click here:", 29 | Button: hermes.Button{ 30 | Text: "Confirm your account", 31 | Link: "https://hermes-example.com/confirm?token=d9729feb74992cc3482b350163a1a010", 32 | }, 33 | }, 34 | }, 35 | Outros: []string{ 36 | "Need help, or have questions? Just reply to this email, we'd love to help.", 37 | }, 38 | }, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /flat.go: -------------------------------------------------------------------------------- 1 | package hermes 2 | 3 | // Flat is a theme 4 | type Flat struct{} 5 | 6 | // Name returns the name of the flat theme 7 | func (dt *Flat) Name() string { 8 | return "flat" 9 | } 10 | 11 | // HTMLTemplate returns a Golang template that will generate an HTML email. 12 | func (dt *Flat) HTMLTemplate() string { 13 | return ` 14 | 15 | 16 | 17 | 18 | 19 | 276 | 277 | 278 | 279 | 280 | 491 | 492 | 493 | 494 | 495 | ` 496 | } 497 | 498 | // PlainTextTemplate returns a Golang template that will generate an plain text email. 499 | func (dt *Flat) PlainTextTemplate() string { 500 | return `

{{if .Email.Body.Title }}{{ .Email.Body.Title }}{{ else }}{{ .Email.Body.Greeting }} {{ .Email.Body.Name }}{{ end }},

501 | {{ with .Email.Body.Intros }} 502 | {{ range $line := . }} 503 |

{{ $line }}

504 | {{ end }} 505 | {{ end }} 506 | {{ if (ne .Email.Body.FreeMarkdown "") }} 507 | {{ .Email.Body.FreeMarkdown.ToHTML }} 508 | {{ else }} 509 | {{ with .Email.Body.Dictionary }} 510 |
    511 | {{ range $entry := . }} 512 |
  • {{ $entry.Key }}: {{ $entry.Value }}
  • 513 | {{ end }} 514 |
515 | {{ end }} 516 | {{ with .Email.Body.Table }} 517 | {{ $data := .Data }} 518 | {{ $columns := .Columns }} 519 | {{ if gt (len $data) 0 }} 520 | 521 | 522 | {{ $col := index $data 0 }} 523 | {{ range $entry := $col }} 524 | 525 | {{ end }} 526 | 527 | {{ range $row := $data }} 528 | 529 | {{ range $cell := $row }} 530 | 533 | {{ end }} 534 | 535 | {{ end }} 536 |
{{ $entry.Key }}
531 | {{ $cell.Value }} 532 |
537 | {{ end }} 538 | {{ end }} 539 | {{ with .Email.Body.Actions }} 540 | {{ range $action := . }} 541 |

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}}

559 | 560 |

{{.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, "2AM to 3AM", "Should find cell content as HTML formatted content") 369 | assert.Contains(t, r, "support@hermes-example.com", "Should find link of mailto as HTML formatted content") 370 | assert.Contains(t, r, "An intro that should be kept even with FreeMarkdown", "Should find intro even with FreeMarkdown") 371 | assert.Contains(t, r, "An outro that should be kept even with FreeMarkdown", "Should find outro even with FreeMarkdown") 372 | assert.NotContains(t, r, "should not be displayed", "Should find any other content that the one from FreeMarkdown object") 373 | } 374 | 375 | func (ed *WithFreeMarkdownContent) assertPlainTextContent(t *testing.T, r string) { 376 | assert.Contains(t, r, "Yours truly", "Should find signature with 'Yours truly' which is default") 377 | assert.Contains(t, r, "Jon Snow", "Should find title with 'Jon Snow'") 378 | assert.Contains(t, r, "> Hermes service will shutdown", "Should find quote as plain text with quote emphaze on sentence") 379 | assert.Contains(t, r, "2AM to 3AM", "Should find cell content as plain text") 380 | assert.Contains(t, r, `+-----------+------------+ 381 | | SERVICES | DOWNTIME | 382 | +-----------+------------+ 383 | | Service A | 2AM to 3AM | 384 | | Service B | 4AM to 5AM | 385 | | Service C | 5AM to 6AM | 386 | +-----------+------------+`, "Should find pretty table as plain text") 387 | assert.Contains(t, r, "support@hermes-example.com", "Should find link of mailto as plain text") 388 | assert.NotContains(t, r, "", "Should not find html table tags") 389 | assert.NotContains(t, r, "", "Should not find html tr tags") 390 | assert.NotContains(t, r, "", "Should not find html link tags") 391 | assert.NotContains(t, r, "should not be displayed", "Should find any other content that the one from FreeMarkdown object") 392 | } 393 | 394 | func TestThemeSimple(t *testing.T) { 395 | t.Parallel() 396 | for _, theme := range testedThemes { 397 | t.Run(theme.Name(), func(t *testing.T) { 398 | t.Parallel() 399 | checkExample(t, &SimpleExample{theme}) 400 | }) 401 | } 402 | } 403 | 404 | func TestThemeWithTitleInsteadOfName(t *testing.T) { 405 | t.Parallel() 406 | for _, theme := range testedThemes { 407 | t.Run(theme.Name(), func(t *testing.T) { 408 | t.Parallel() 409 | checkExample(t, &WithTitleInsteadOfNameExample{theme}) 410 | }) 411 | } 412 | } 413 | 414 | func TestThemeWithGreetingDifferentThanDefault(t *testing.T) { 415 | t.Parallel() 416 | for _, theme := range testedThemes { 417 | t.Run(theme.Name(), func(t *testing.T) { 418 | t.Parallel() 419 | checkExample(t, &WithGreetingDifferentThanDefault{theme}) 420 | }) 421 | } 422 | } 423 | 424 | func TestThemeWithGreetingDiffrentThanDefault(t *testing.T) { 425 | t.Parallel() 426 | for _, theme := range testedThemes { 427 | t.Run(theme.Name(), func(t *testing.T) { 428 | t.Parallel() 429 | checkExample(t, &WithSignatureDifferentThanDefault{theme}) 430 | }) 431 | } 432 | } 433 | 434 | func TestThemeWithFreeMarkdownContent(t *testing.T) { 435 | t.Parallel() 436 | for _, theme := range testedThemes { 437 | t.Run(theme.Name(), func(t *testing.T) { 438 | t.Parallel() 439 | checkExample(t, &WithFreeMarkdownContent{theme}) 440 | }) 441 | } 442 | } 443 | 444 | func TestThemeWithInviteCode(t *testing.T) { 445 | t.Parallel() 446 | for _, theme := range testedThemes { 447 | t.Run(theme.Name(), func(t *testing.T) { 448 | t.Parallel() 449 | checkExample(t, &WithInviteCode{theme}) 450 | }) 451 | } 452 | } 453 | 454 | func checkExample(t *testing.T, ex Example) { 455 | // Given an example 456 | h, email := ex.getExample() 457 | 458 | // When generating HTML template 459 | r, err := h.GenerateHTML(email) 460 | t.Log(r) 461 | require.NoError(t, err) 462 | assert.NotEmpty(t, r) 463 | 464 | // Then asserting HTML is OK 465 | ex.assertHTMLContent(t, r) 466 | 467 | // When generating plain text template 468 | r, err = h.GeneratePlainText(email) 469 | t.Log(r) 470 | require.NoError(t, err) 471 | assert.NotEmpty(t, r) 472 | 473 | // Then asserting plain text is OK 474 | ex.assertPlainTextContent(t, r) 475 | } 476 | 477 | // ====================================== 478 | // Tests on default values for all themes 479 | // It does not check email content 480 | // ====================================== 481 | 482 | func TestHermes_TextDirectionAsDefault(t *testing.T) { 483 | t.Parallel() 484 | 485 | h := Hermes{ 486 | Product: Product{ 487 | Name: "Hermes", 488 | Link: "http://hermes.com", 489 | }, 490 | TextDirection: "not-existing", // Wrong text-direction 491 | DisableCSSInlining: true, 492 | } 493 | 494 | email := Email{ 495 | Body{ 496 | Name: "Jon Snow", 497 | Intros: []string{ 498 | "Welcome to Hermes! We're very excited to have you on board.", 499 | }, 500 | Actions: []Action{ 501 | { 502 | Instructions: "To get started with Hermes, please click here:", 503 | Button: Button{ 504 | Color: "#22BC66", 505 | Text: "Confirm your account", 506 | Link: "https://hermes-example.com/confirm?token=d9729feb74992cc3482b350163a1a010", 507 | }, 508 | }, 509 | }, 510 | Outros: []string{ 511 | "Need help, or have questions? Just reply to this email, we'd love to help.", 512 | }, 513 | }, 514 | } 515 | 516 | _, err := h.GenerateHTML(email) 517 | require.NoError(t, err) 518 | assert.Equal(t, TDLeftToRight, h.TextDirection) 519 | assert.Equal(t, "default", h.Theme.Name()) 520 | } 521 | 522 | func TestHermes_Default(t *testing.T) { 523 | t.Parallel() 524 | 525 | h := Hermes{} 526 | err := setDefaultHermesValues(&h) 527 | require.NoError(t, err) 528 | 529 | email := Email{} 530 | err = setDefaultEmailValues(&email) 531 | require.NoError(t, err) 532 | 533 | assert.Equal(t, TDLeftToRight, h.TextDirection) 534 | assert.Equal(t, new(Default), h.Theme) 535 | assert.Equal(t, "Hermes", h.Product.Name) 536 | assert.Equal(t, "Copyright © 2025 Hermes. All rights reserved.", h.Product.Copyright) 537 | 538 | assert.Empty(t, email.Body.Actions) 539 | assert.Empty(t, email.Body.Dictionary) 540 | assert.Empty(t, email.Body.Intros) 541 | assert.Empty(t, email.Body.Outros) 542 | assert.Empty(t, email.Body.Table.Data) 543 | assert.Empty(t, email.Body.Table.Columns.CustomWidth) 544 | assert.Empty(t, email.Body.Table.Columns.CustomAlignment) 545 | assert.Empty(t, string(email.Body.FreeMarkdown)) 546 | 547 | assert.Equal(t, "Hi", email.Body.Greeting) 548 | assert.Equal(t, "Yours truly", email.Body.Signature) 549 | assert.Empty(t, email.Body.Title) 550 | } 551 | -------------------------------------------------------------------------------- /screens/default/receipt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matcornic/hermes/db8901938bc20e5c1980731141e6845621ec625e/screens/default/receipt.png -------------------------------------------------------------------------------- /screens/default/reset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matcornic/hermes/db8901938bc20e5c1980731141e6845621ec625e/screens/default/reset.png -------------------------------------------------------------------------------- /screens/default/welcome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matcornic/hermes/db8901938bc20e5c1980731141e6845621ec625e/screens/default/welcome.png -------------------------------------------------------------------------------- /screens/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matcornic/hermes/db8901938bc20e5c1980731141e6845621ec625e/screens/demo.png -------------------------------------------------------------------------------- /screens/flat/receipt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matcornic/hermes/db8901938bc20e5c1980731141e6845621ec625e/screens/flat/receipt.png -------------------------------------------------------------------------------- /screens/flat/reset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matcornic/hermes/db8901938bc20e5c1980731141e6845621ec625e/screens/flat/reset.png -------------------------------------------------------------------------------- /screens/flat/welcome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matcornic/hermes/db8901938bc20e5c1980731141e6845621ec625e/screens/flat/welcome.png -------------------------------------------------------------------------------- /screens/free-markdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matcornic/hermes/db8901938bc20e5c1980731141e6845621ec625e/screens/free-markdown.png --------------------------------------------------------------------------------