Repository: ainsleyclark/go-mail
Branch: main
Commit: 1c0a1e6ec5da
Files: 81
Total size: 167.5 KB
Directory structure:
gitextract_8hbv64jc/
├── .editorconfig
├── .github/
│ ├── CODE_OF_CONDUCT.md
│ ├── CONTRIBUTING.md
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ └── feature_request.md
│ ├── PULL_REQUEST_TEMPLATE.md
│ ├── SECURITY.md
│ └── workflows/
│ ├── codeql-analysis.yml
│ ├── email.yml
│ └── test.yml
├── .gitignore
├── .golangci.yml
├── LICENSE
├── Makefile
├── README.md
├── bin/
│ ├── tag.sh
│ └── tests.sh
├── drivers/
│ ├── drivers.go
│ ├── drivers_test.go
│ ├── mailgun.go
│ ├── mailgun_test.go
│ ├── postal.go
│ ├── postal_test.go
│ ├── postmark.go
│ ├── postmark_test.go
│ ├── sendgrid.go
│ ├── sendgrid_test.go
│ ├── smtp.go
│ ├── smtp_test.go
│ ├── sparkpost.go
│ └── sparkpost_test.go
├── examples/
│ ├── attachments.go
│ ├── mailgun.go
│ ├── postal.go
│ ├── postmark.go
│ ├── sendgrid.go
│ ├── smtp.go
│ └── sparkpost.go
├── go.mod
├── go.sum
├── internal/
│ ├── client/
│ │ ├── client.go
│ │ ├── client_test.go
│ │ ├── util.go
│ │ └── util_test.go
│ ├── errors/
│ │ ├── errors.go
│ │ └── errors_test.go
│ ├── httputil/
│ │ ├── payload.go
│ │ ├── payload_test.go
│ │ ├── request.go
│ │ ├── request_test.go
│ │ └── response.go
│ ├── mime/
│ │ ├── mime.go
│ │ └── mime_test.go
│ └── mocks/
│ ├── client/
│ │ └── Requester.go
│ ├── drivers/
│ │ └── smtpSendFunc.go
│ ├── httputil/
│ │ ├── Payload.go
│ │ └── Responder.go
│ └── mail/
│ └── Mailer.go
├── mail/
│ ├── attachments.go
│ ├── attachments_test.go
│ ├── config.go
│ ├── config_test.go
│ ├── mail.go
│ ├── mail_test.go
│ ├── response.go
│ ├── transmissions.go
│ └── transmissions_test.go
├── mocks/
│ ├── client/
│ │ └── Requester.go
│ ├── clientold/
│ │ └── Requester.go
│ ├── drivers/
│ │ └── smtpSendFunc.go
│ ├── httputil/
│ │ ├── Payload.go
│ │ └── Responder.go
│ └── mail/
│ └── Mailer.go
└── tests/
├── mail_test.go
├── mailgun_test.go
├── postal_test.go
├── postmark_test.go
├── sendgrid_test.go
├── smtp_test.go
└── sparkpost_test.go
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
root = true
[*]
end_of_line = lf
indent_size = 4
indent_style = tab
trim_trailing_whitespace = true
insert_final_newline = true
charset = utf-8
[*.{md,yml}]
indent_size = 2
================================================
FILE: .github/CODE_OF_CONDUCT.md
================================================
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
================================================
FILE: .github/CONTRIBUTING.md
================================================
# Contributing
Hello, we are very happy you decided to contribute to Go Mail. But before you start with your contribution,
please make sure to read through our guidelines:
- [Issue Reporting Guidline](#issue-reporting-guidline)
- [Pull Request Guidlines](#pull-request-guidlines)
## Issue Reporting Guideline
If you find a bug or believe that some important feature is missing you can open a new issue on the Github-Project page,
using our provided issue templates. Before creating a new issue, please make sure that there isn't already an issue
covering this problem or requesting this feature.
## Pull Request Guidelines
- The `main` branch always contains the latest stable released version and doesn't take PRs.
Instead, create dedicated feature branches and submit your PR to our `dev` branch.
- It's okay if your PR contains several small commits as we will squash the PR before merging it.
- Please try to use meaningful commit messages.
- Before creating a new PR, check if your code is linted correctly.
- If you want to add a new feature:
- Add a small but complete description of the new feature.
- Please provide a convincing reason why you think this feature needs to be added.
- If you add a bug fix:
- Please refer the corresponding issue, if one exists, in your PR.
- If no issue exist for the bug you fix you need to provide a detailed description of the error and if possible a live demo. Or create a new issue on our [Github page](https://github.com/ainsleyclark/go-mail/issues)
- Create unit tests for new features and use the `make all` command to test, lint and format.
================================================
FILE: .github/FUNDING.yml
================================================
github: [ainsleyclark]
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug Report
about: Create a report to help us improve Go Mail
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Version**
Please specify a version number you are using.
**Additional context**
Add any other context about the problem here.
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for Go Mail
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
================================================
FILE: .github/PULL_REQUEST_TEMPLATE.md
================================================
# Description
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
Fixes # (issue)
## Type of change
Please delete options that are not relevant.
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change requires a documentation update
# How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration
- [ ] Test A
- [ ] Test B
# Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream modules
================================================
FILE: .github/SECURITY.md
================================================
# Security Policy
## Reporting an Issue
If you need to report a security issue please email the author [here](mailto:info@ainsleyclark.com)
================================================
FILE: .github/workflows/codeql-analysis.yml
================================================
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ main ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
schedule:
- cron: '25 19 * * 1'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'go' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://git.io/codeql-language-support
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
================================================
FILE: .github/workflows/email.yml
================================================
name: Email
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
schedule:
- cron: '30 1 1,15 * *'
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.17
- name: Test
env:
# Email
EMAIL_TO: ${{ secrets.EMAIL_TO }}
EMAIL_CC: ${{ secrets.EMAIL_CC }}
EMAIL_BCC: ${{ secrets.EMAIL_BCC }}
# MailGun
MAILGUN_URL: ${{ secrets.MAILGUN_URL }}
MAILGUN_API_KEY: ${{ secrets.MAILGUN_API_KEY }}
MAILGUN_FROM_ADDRESS: ${{ secrets.MAILGUN_FROM_ADDRESS }}
MAILGUN_FROM_NAME: ${{ secrets.MAILGUN_FROM_NAME }}
MAILGUN_DOMAIN: ${{ secrets.MAILGUN_DOMAIN }}
# Postal
POSTAL_URL: ${{ secrets.POSTAL_URL }}
POSTAL_API_KEY: ${{ secrets.POSTAL_API_KEY }}
POSTAL_FROM_ADDRESS: ${{ secrets.POSTAL_FROM_ADDRESS }}
POSTAL_FROM_NAME: ${{ secrets.POSTAL_FROM_NAME }}
# Postmark
POSTMARK_API_KEY: ${{ secrets.POSTMARK_API_KEY }}
POSTMARK_FROM_ADDRESS: ${{ secrets.POSTMARK_FROM_ADDRESS }}
POSTMARK_FROM_NAME: ${{ secrets.POSTMARK_FROM_NAME }}
# SendGrid
SENDGRID_API_KEY: ${{ secrets.SENDGRID_API_KEY }}
SENDGRID_FROM_ADDRESS: ${{ secrets.SENDGRID_FROM_ADDRESS }}
SENDGRID_FROM_NAME: ${{ secrets.SENDGRID_FROM_NAME }}
# SMTP
SMTP_URL: ${{ secrets.SMTP_URL }}
SMTP_FROM_ADDRESS: ${{ secrets.SMTP_FROM_ADDRESS }}
SMTP_FROM_NAME: ${{ secrets.SMTP_FROM_NAME }}
SMTP_PASSWORD: ${{ secrets.SMTP_PASSWORD }}
SMTP_PORT: ${{ secrets.SMTP_PORT }}
# SparkPost
SPARKPOST_URL: ${{ secrets.SPARKPOST_URL }}
SPARKPOST_API_KEY: ${{ secrets.SPARKPOST_API_KEY }}
SPARKPOST_FROM_ADDRESS: ${{ secrets.SPARKPOST_FROM_ADDRESS }}
SPARKPOST_FROM_NAME: ${{ secrets.SPARKPOST_FROM_NAME }}
run: |
# Make file runnable, might not be necessary
chmod +x ./bin/tests.sh
# Run tests
# Ignore Postal, no server active.
./bin/tests.sh mailgun
./bin/tests.sh postmark
./bin/tests.sh sendgrid
./bin/tests.sh smtp
./bin/tests.sh sparkpost
================================================
FILE: .github/workflows/test.yml
================================================
name: Test
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.17
- name: Format
run: make format
- name: Lint
uses: golangci/golangci-lint-action@v2
with:
version: latest
skip-go-installation: true
skip-pkg-cache: true
args: --verbose
- name: Test
run: make test
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v2.1.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
file: ./coverage.out
- name: Diff
run: git diff
================================================
FILE: .gitignore
================================================
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Editors
.idea
.idea/*
# Sensitive Info
credentials.md
# System Files
.DS_Store
# Go Mail Specifc
.env
================================================
FILE: .golangci.yml
================================================
linters:
enable:
- gofmt
- govet
- gocyclo
- ineffassign
- thelper
- tparallel
- unconvert
- unparam
- wastedassign
- revive
run:
go: '1.17'
skip-dirs:
- res
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2022 Ainsley Clark
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: Makefile
================================================
# Setup
setup:
sudo chmod +x ./bin/tests.sh
go mod tidy
.PHONY: setup
# Run gofmt
format:
go fmt ./...
.PHONY: format
# Run linter
lint:
golangci-lint run ./...
.PHONY: lint
# Test uses race and coverage
test:
go clean -testcache && go test -race $$(go list ./... | grep -v tests | grep -v examples | grep -v res | grep -v mocks) -coverprofile=coverage.out -covermode=atomic
.PHONY: test
# Test with -v
test-v:
go clean -testcache && go test -race -v $$(go list ./... | grep -v tests | grep -v examples | grep -v res | grep -v mocks) -coverprofile=coverage.out -covermode=atomic
.PHONY: test-v
# Runs real world tests for a driver or all drivers.
# See ./bin/tests.sh for example usage.
test-driver:
go clean -testcache && ./bin/tests.sh $(driver)
.PHONY: test-driver
# Run all the tests and opens the coverage report
cover: test
go tool cover -html=coverage.out
.PHONY: cover
# Make mocks keeping directory tree
mocks:
rm -rf internal/mocks && mockery --all --keeptree --output ./internal/mocks && mv ./internal/mocks/internal/* ./internal/mocks
.PHONY: mocks
# Run go doc
doc:
godoc -http localhost:8080
.PHONY: doc
# Make format, lint and test
all:
$(MAKE) format
$(MAKE) lint
$(MAKE) test
================================================
FILE: README.md
================================================
# 📧 Go Mail
A cross-platform mail driver for GoLang. Featuring Mailgun, Postal, Postmark, SendGrid, SparkPost & SMTP.
## Overview
- ✅ Multiple mail drivers for your needs or even create your own custom Mailer.
- ✅ Direct dependency free, all requests are made with the standard lib http.Client.
- ✅ Send attachments with two struct fields, it's extremely simple.
- ✅ Send CC & BCC messages.
- ✅ Extremely lightweight.
## Supported API's
- [Mailgun](https://documentation.mailgun.com/)
- [Postal](https://docs.postalserver.io/)
- [Postmark](https://postmarkapp.com/)
- [SendGrid](https://sendgrid.com/)
- [SparkPost](https://www.sparkpost.com/)
- SMTP
## Introduction
Go Mail aims to unify multiple popular mail APIs into a singular, easy to use interface. Email sending is seriously
simple and great for allowing the developer or end user to choose what platform they use.
```go
cfg := mail.Config{
URL: "https://api.eu.sparkpost.com",
APIKey: "my-key",
FromAddress: "hello@gophers.com",
FromName: "Gopher",
}
mailer, err := drivers.NewSparkPost(cfg)
if err != nil {
log.Fatalln(err)
}
tx := &mail.Transmission{
Recipients: []string{"hello@gophers.com"},
Subject: "My email",
HTML: "
Hello from Go Mail!
",
}
result, err := mailer.Send(tx)
if err != nil {
log.Fatalln(err)
}
fmt.Printf("%+v\n", result)
```
## Installation
```bash
go get -u github.com/ainsleyclark/go-mail
```
## Docs
Documentation can be found at the [Go Docs](https://pkg.go.dev/github.com/ainsleyclark/go-mail), but we have included a
kick-start guide below to get you started.
### Creating a new client:
You can create a new driver by calling the `drivers` package and passing in a configuration type which is required to
create a new mailer. Each platform requires its own data, for example, Mailgun requires a domain, but SparkPost doesn't.
This is based of the requirements for the API. For more details see the [examples](#Examples) below.
```go
cfg := mail.Config{
URL: "https://api.eu.sparkpost.com",
APIKey: "my-key",
FromAddress: "hello@gophers.com",
FromName: "Gopher",
Client: http.DefaultClient, // Client is optional
}
mailer, err := drivers.NewSparkpost(cfg)
if err != nil {
log.Fatalln(err)
}
```
### Sending Data:
A transmission is required to transmit to a mailer as shown below. Once send is called, a `mail.Response` and an `error`
be returned indicating if the transmission was successful.
```go
tx := &mail.Transmission{
Recipients: []string{"hello@gophers.com"},
CC: []string{"cc@gophers.com"},
BCC: []string{"bcc@gophers.com"},
Subject: "My email",
HTML: "
Hello from Go Mail!
",
PlainText: "Hello from Go Mail!",
Headers: map[string]string{
"X-Go-Mail": "Test",
},
}
result, err := mailer.Send(tx)
if err != nil {
log.Fatalln(err)
}
fmt.Printf("%+v\n", result)
```
### Response:
The mail response is used for debugging and inspecting results of the mailer. Below is the `Response` type.
```go
// Response represents the data passed back from a successful transmission.
type Response struct {
StatusCode int // e.g. 200
Body []byte // e.g. {"result: success"}
Headers http.Header // e.g. map[X-Ratelimit-Limit:[600]]
ID string // e.g "100"
Message string // e.g "Email sent successfully"
}
```
### Adding attachments:
Adding attachments to the transmission is as simple as passing a byte slice and filename. Go Mail takes care of the rest
for you.
```go
image, err := ioutil.ReadFile("gopher.jpg")
if err != nil {
log.Fatalln(err)
}
tx := &mail.Transmission{
Recipients: []string{"hello@gophers.com"},
Subject: "My email",
HTML: "
",
PlainText: "Hello from Go Mail!",
}
result, err := mailer.Send(tx)
if err != nil {
log.Fatalln(err)
}
fmt.Printf("%+v\n", result)
```
## Writing a Mailable
You have the ability to create your own custom Mailer by implementing the singular method interface shown below.
```go
type Mailer interface {
// Send accepts a mail.Transmission to send an email through a particular
// driver/provider. Transmissions will be validated before sending.
//
// A mail.Response or an error will be returned. In some circumstances
// the body and status code will be attached to the response for debugging.
Send(t *mail.Transmission) (mail.Response, error)
}
```
## Debugging
To debug any errors or issues you are facing with Go Mail, you are able to change the `Debug` variable in the
`mail` package. This will write the HTTP requests in curl to stdout. Additional information will also be
displayed in the errors such as method operations.
```go
mail.Debug = true
```
## Development
### Setup
To get set up with Go Mail simply clone the repo and run the following:
```bash
go get github.com/vektra/mockery/v2/.../
make setup
make mocks
```
## Env
All secrets are contained within the `.env` file for testing drivers. To begin with, make a copy of the `.env.example`
file and name it `.env`. You can the set the environment variables to match your credentials for the mail drivers.
You can set the recipients of emails by modifying the `EMAIL` variables as show below.
- `EMAIL_TO`: Recipients of test emails in a comma delimited list.
- `EMAIL_CC`: CC recipients of test emails in a comma delimited list.
- `EMAIL_BCC`: BCC recipients of test emails in a comma delimited list.
### Testing
To run all driver tests, execute the following command:
```bash
make test-driver
```
To run a specific driver test, prepend the `driver` flag as show below:
```bash
make test-driver driver=sparkpost
```
The driver flag can be one of the following:
- `mailgun`
- `postal`
- `postmark`
- `sendgrid`
- `smtp`
- `sparkpost`
## Contributing
We welcome contributors, but please read the [contributing document](CONTRIBUTING.md) before making a pull request.
## Credits
Shout out to the incredible [Maria Letta](https://github.com/MariaLetta) for her excellent Gopher illustrations.
## Licence
Code Copyright 2022 Go Mail. Code released under the [MIT Licence](LICENCE).
================================================
FILE: bin/tag.sh
================================================
#!/bin/bash
#
# tag.sh
#
# Set variables
version=$1
message=$2
# Check version is not empty
if [[ $version == "" ]]
then
echo "Add Version number"
exit
fi
# Check commit message is not empty
if [[ $message == "" ]]
then
echo "Add commit message"
exit
fi
echo "Releasing version: " $version
git tag -a "$version" -m "$message"
git push origin $version
================================================
FILE: bin/tests.sh
================================================
#!/usr/bin/bash
# Shell script for executing tests based on input.
# Usage:
# ./tests.sh for all drivers
# ./tests.sh sparkpost for a particular driver
# Author - Ainsley Clark
DRIVER=$1
declare -A tests=(
["mailgun"]="Test_MailGun"
["postal"]="Test_Postal"
["postmark"]="Test_Postmark"
["sendgrid"]="Test_SendGrid"
["smtp"]="Test_SMTP"
["sparkpost"]="Test_SparkPost"
)
if [ -z "$DRIVER" ]
then
for name in "${!tests[@]}";
do go test -v ./tests/ -run "${tests[$name]}";
done
else
go test -v ./tests/ -run "${tests["$DRIVER"]}";
fi
================================================
FILE: drivers/drivers.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package drivers
import "github.com/ainsleyclark/go-mail/internal/httputil"
var (
// newJSONData is an alias for httputil.NewJSONData
// for creating JSON payloads.
newJSONData = httputil.NewJSONData
// formDataFn is an alias for httputil.NewFormData
// for creating form data payloads.
newFormData = httputil.NewFormData
)
================================================
FILE: drivers/drivers_test.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package drivers
import (
"errors"
"github.com/ainsleyclark/go-mail/internal/httputil"
"github.com/ainsleyclark/go-mail/internal/mocks/client"
"github.com/ainsleyclark/go-mail/mail"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"net/http"
"os"
"path/filepath"
"testing"
)
// DriversTestSuite defines the helper used for mail
// testing.
type DriversTestSuite struct {
suite.Suite
base string
}
// Assert testing has begun.
func TestMail(t *testing.T) {
suite.Run(t, new(DriversTestSuite))
}
// Assigns test base.
func (t *DriversTestSuite) SetupSuite() {
wd, err := os.Getwd()
t.NoError(err)
t.base = wd
}
const (
// DataPath defines where the test data resides.
DataPath = "testdata"
)
var (
// Trans is the transmission used for testing.
Trans = &mail.Transmission{
Recipients: []string{"recipient@test.com"},
CC: []string{"cc@test.com"},
BCC: []string{"bcc@test.com"},
Subject: "Subject",
HTML: "
HTML
",
PlainText: "PlainText",
Headers: map[string]string{
"X-Go-Mail": "Test",
},
}
// Trans is the transmission with an
// attachment used for testing.
TransWithAttachment = &mail.Transmission{
Recipients: []string{"recipient@test.com"},
Subject: "Subject",
HTML: "
HTML
",
PlainText: "PlainText",
Attachments: []mail.Attachment{{Filename: "test.jpg"}},
}
// Config is the default configuration used
// for testing.
Comfig = mail.Config{
URL: "my-url",
APIKey: "my-key",
FromAddress: "hello@gophers.com",
FromName: "Gopher",
Domain: "my-domain",
}
)
// Returns a PNG attachment for testing.
func (t *DriversTestSuite) Attachment(name string) mail.Attachment {
path := filepath.Join(t.base, DataPath, name)
file, err := os.ReadFile(path)
if err != nil {
t.Fail("error getting attachment with the path: "+path, err)
}
return mail.Attachment{
Filename: name,
Bytes: file,
}
}
func (t *DriversTestSuite) UtilTestUnmarshal(r httputil.Responder, buf []byte) {
errBuf := []byte("wrong")
err := r.Unmarshal(errBuf)
t.Error(err)
err = r.Unmarshal(buf)
t.NoError(err)
}
func (t *DriversTestSuite) UtilTestMeta(r httputil.Responder, message, id string) {
got := r.Meta()
t.Equal(message, got.Message)
t.Equal(id, got.ID)
}
func (t *DriversTestSuite) UtilTestSend(fn func(m *mocks.Requester) mail.Mailer, json bool) {
res := mail.Response{
StatusCode: http.StatusOK,
Body: []byte("body"),
Headers: nil,
ID: "1",
Message: "success",
}
tt := map[string]struct {
input *mail.Transmission
mock func(m *mocks.Requester)
jsonFn func(obj interface{}) (*httputil.JSONData, error)
want interface{}
}{
"Success": {
Trans,
func(m *mocks.Requester) {
m.On("Do", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(res, nil)
},
httputil.NewJSONData,
res,
},
"With Attachment": {
TransWithAttachment,
func(m *mocks.Requester) {
m.On("Do", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(res, nil)
},
httputil.NewJSONData,
res,
},
"Validation Failed": {
nil,
nil,
httputil.NewJSONData,
"can't validate a nil transmission",
},
"JSON Error": {
Trans,
func(m *mocks.Requester) {
m.On("Do", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(mail.Response{}, errors.New("send error"))
},
func(obj interface{}) (*httputil.JSONData, error) {
return nil, errors.New("json error")
},
"json error",
},
"Send Error": {
Trans,
func(m *mocks.Requester) {
m.On("Do", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(mail.Response{}, errors.New("send error"))
},
httputil.NewJSONData,
"send error",
},
}
for name, test := range tt {
if name == "JSON Error" && !json {
continue
}
t.Run(name, func() {
orig := newJSONData
defer func() { newJSONData = orig }()
newJSONData = test.jsonFn
requester := &mocks.Requester{}
if test.mock != nil {
test.mock(requester)
}
m := fn(requester)
got, err := m.Send(test.input)
if err != nil {
t.Contains(err.Error(), test.want)
return
}
t.Equal(test.want, got)
})
}
}
================================================
FILE: drivers/mailgun.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package drivers
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/ainsleyclark/go-mail/internal/client"
"github.com/ainsleyclark/go-mail/internal/httputil"
"github.com/ainsleyclark/go-mail/mail"
"net/http"
"strings"
)
// mailgun represents the entity for sending mail via the
// Mailgun API.
//
// See:
// https://documentation.mailgun.com/en/latest/api_reference.html
// https://documentation.mailgun.com/en/latest/api-sending.html
type mailGun struct {
cfg mail.Config
client client.Requester
}
const (
// mailgunEndpoint defines the endpoint to POST to.
mailgunEndpoint = "/v3/%s/messages"
)
// NewMailgun creates a new Mailgun client. Configuration
// is validated before initialisation.
func NewMailgun(cfg mail.Config) (mail.Mailer, error) {
err := cfg.Validate()
if err != nil {
return nil, err
}
if cfg.Domain == "" {
return nil, errors.New("driver requires a domain")
}
return &mailGun{
cfg: cfg,
client: client.New(cfg.Client),
}, nil
}
type (
// mailGunResponse defines the data sent back from the MailGun API.
// ID is included on successful transmission.
//
// Example JSON Responses:
// {"id":"<20211229082318.a988bed7abe472bd@sandboxa6807a568a404524b2b216817d7ed775.mailgun.org>","message":"Queued. Thank you."}
// {"message":"Need at least one of 'text' or 'html' parameters specified"}
// {"message":"from parameter is missing"}
mailgunResponse struct {
Message string `json:"message"`
ID string `json:"id,omitempty"`
}
)
func (r *mailgunResponse) Unmarshal(buf []byte) error {
resp := &mailgunResponse{}
err := json.Unmarshal(buf, resp)
if err != nil {
return err
}
*r = *resp
return nil
}
func (r *mailgunResponse) CheckError(response *http.Response, buf []byte) error {
if client.Is2XX(response.StatusCode) {
return nil
}
if len(buf) == 0 {
return mail.ErrEmptyBody
}
return errors.New(r.Message)
}
func (r *mailgunResponse) Meta() httputil.Meta {
return httputil.Meta{
Message: r.Message,
ID: r.ID,
}
}
func (m *mailGun) Send(t *mail.Transmission) (mail.Response, error) {
err := t.Validate()
if err != nil {
return mail.Response{}, err
}
f := newFormData()
f.AddValue("from", fmt.Sprintf("%s <%s>", m.cfg.FromName, m.cfg.FromAddress))
f.AddValue("subject", t.Subject)
f.AddValue("html", t.HTML)
f.AddValue("text", t.PlainText)
for _, to := range t.Recipients {
f.AddValue("to", to)
}
if t.HasCC() {
for _, c := range t.CC {
f.AddValue("cc", c)
}
}
if t.HasBCC() {
for _, b := range t.BCC {
f.AddValue("bcc", b)
}
}
if t.HasAttachments() {
for _, v := range t.Attachments {
f.AddBuffer("attachment", v.Filename, v.Bytes)
}
}
for k, v := range t.Headers {
f.AddValue("h:"+k, v)
}
url := fmt.Sprintf("%s/%s", m.cfg.URL, strings.TrimPrefix(fmt.Sprintf(mailgunEndpoint, m.cfg.Domain), "/"))
req := httputil.NewHTTPRequest(http.MethodPost, url)
req.SetBasicAuth("api", m.cfg.APIKey)
return m.client.Do(context.Background(), req, f, &mailgunResponse{})
}
================================================
FILE: drivers/mailgun_test.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package drivers
import (
"errors"
mocks "github.com/ainsleyclark/go-mail/internal/mocks/client"
"github.com/ainsleyclark/go-mail/mail"
"log"
"net/http"
)
func ExampleNewMailgun() {
cfg := mail.Config{
URL: "https://api.eu.mailgun.net", // Or https://api.mailgun.net
APIKey: "my-key",
FromAddress: "hello@gophers.com",
FromName: "Gopher",
Domain: "my-domain.com",
}
_, err := NewMailgun(cfg)
if err != nil {
log.Fatalln(err)
}
}
func (t *DriversTestSuite) TestNewMailGun() {
tt := map[string]struct {
input mail.Config
want interface{}
}{
"Success": {
mail.Config{
URL: "https://mailgun.example.com",
FromAddress: "addr",
FromName: "name",
APIKey: "key",
Domain: "domain",
},
nil,
},
"Validation Failed": {
mail.Config{},
"driver requires from address",
},
"No Domain": {
mail.Config{
FromName: "name",
FromAddress: "hello@gophers.com",
APIKey: "key",
},
"driver requires a domain",
},
}
for name, test := range tt {
t.Run(name, func() {
got, err := NewMailgun(test.input)
if err != nil {
t.Contains(err.Error(), test.want)
return
}
t.NotNil(got)
})
}
}
func (t *DriversTestSuite) TestMailgunResponse_Unmarshal() {
t.UtilTestUnmarshal(&mailgunResponse{}, []byte(`{"message": "Hello"}`))
}
func (t *DriversTestSuite) TestMailgunResponse_CheckError() {
tt := map[string]struct {
response *http.Response
buf []byte
want error
}{
"2xx": {
&http.Response{StatusCode: http.StatusOK},
[]byte("test"),
nil,
},
"Empty Body": {
&http.Response{StatusCode: http.StatusInternalServerError},
nil,
mail.ErrEmptyBody,
},
"Error": {
&http.Response{StatusCode: http.StatusInternalServerError},
[]byte("test"),
errors.New("error"),
},
}
for name, test := range tt {
t.Run(name, func() {
resp := mailgunResponse{Message: "error"}
err := resp.CheckError(test.response, test.buf)
if err != nil {
t.Contains(err.Error(), test.want.Error())
return
}
t.Equal(test.want, err)
})
}
}
func (t *DriversTestSuite) TestMailgunResponse_Meta() {
d := &mailgunResponse{Message: "Success", ID: "id"}
t.UtilTestMeta(d, d.Message, d.ID)
}
func (t *DriversTestSuite) TestMailGun_Send() {
t.UtilTestSend(func(m *mocks.Requester) mail.Mailer {
return &mailGun{cfg: Comfig, client: m}
}, false)
}
================================================
FILE: drivers/postal.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package drivers
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/ainsleyclark/go-mail/internal/client"
"github.com/ainsleyclark/go-mail/internal/httputil"
"github.com/ainsleyclark/go-mail/mail"
"net/http"
)
// postal represents the entity for sending mail via the
// Postal API.
//
// See:
// https://docs.postalserver.io/developer/api
// https://apiv1.postalserver.io/controllers/send/message.html
type postal struct {
cfg mail.Config
client client.Requester
}
const (
// postalEndpoint defines the endpoint to POST to.
postalEndpoint = "%s/api/v1/send/message"
// postalErrorMessage defines the message when an error occurred
// when sending mail via the Postal API.
postalErrorMessage = "error sending transmission to Postal API"
)
// NewPostal creates a new Postal client. Configuration
// is validated before initialisation.
func NewPostal(cfg mail.Config) (mail.Mailer, error) {
err := cfg.Validate()
if err != nil {
return nil, err
}
return &postal{
cfg: cfg,
client: client.New(cfg.Client),
}, nil
}
type (
// postalTransmission defines the data to be sent to the Postal API.
postalTransmission struct {
To []string `json:"to"`
CC []string `json:"cc"`
BCC []string `json:"bcc"`
From string `json:"from"`
Sender string `json:"sender"`
Subject string `json:"subject"`
HTML string `json:"html_body"`
PlainText string `json:"plain_body"`
Attachments []postalAttachment `json:"attachments"`
Headers map[string]string `json:"headers"`
}
// postalAttachment defines a singular Postal mail attachment.
postalAttachment struct {
Name string `json:"name"`
ContentType string `json:"content_type"`
Data string `json:"data"`
}
// postalResponse defines the data sent back from the Postal API.
// Status can either be "success" or "error" and data is
// dynamic dependent on if an error occurred during processing.
//
// Example JSON Responses:
// {"status":"success","time":0.08,"flags":{},"data":{"message_id":"080c21de-52f9-4be1-9cbe-19d63450949c@rp.postal.example.com","messages":{"info@ainsleyclark.com":{"id":28,"token":"WEjrFfpnynRm"}}}}
// {"status":"error","time":0.0,"flags":{},"data":{"code":"NoRecipients","message":"There are no recipients defined to receive this message"}}
postalResponse struct {
Status string `json:"status"`
Time float32 `json:"time"`
Flags map[string]interface{} `json:"flags"`
Data map[string]interface{} `json:"data"`
}
)
func (r *postalResponse) Unmarshal(buf []byte) error {
resp := &postalResponse{}
err := json.Unmarshal(buf, resp)
if err != nil {
return err
}
*r = *resp
return nil
}
func (r *postalResponse) CheckError(response *http.Response, buf []byte) error {
if r.Status == "success" {
return nil
}
if len(buf) == 0 {
return mail.ErrEmptyBody
}
msg := postalErrorMessage
if code, ok := r.Data["code"]; ok {
msg = fmt.Sprintf("%s - code: %s", msg, code)
}
if message, ok := r.Data["message"]; ok {
msg = fmt.Sprintf("%s, message: %s", msg, message)
}
return errors.New(msg)
}
func (r *postalResponse) Meta() httputil.Meta {
m := httputil.Meta{
Message: "Successfully sent Postal email",
}
if val, ok := r.Data["message_id"]; ok {
m.ID = fmt.Sprintf("%v", val)
}
return m
}
func (d *postal) Send(t *mail.Transmission) (mail.Response, error) {
err := t.Validate()
if err != nil {
return mail.Response{}, err
}
tx := postalTransmission{
To: t.Recipients,
CC: t.CC,
BCC: t.BCC,
From: d.cfg.FromAddress,
Sender: d.cfg.FromName,
Subject: t.Subject,
HTML: t.HTML,
PlainText: t.PlainText,
}
if t.HasAttachments() {
for _, v := range t.Attachments {
tx.Attachments = append(tx.Attachments, postalAttachment{
Name: v.Filename,
ContentType: v.Mime(),
Data: v.B64(),
})
}
}
tx.Headers = t.Headers
pl, err := newJSONData(tx)
if err != nil {
return mail.Response{}, err
}
req := httputil.NewHTTPRequest(http.MethodPost, fmt.Sprintf(postalEndpoint, d.cfg.URL))
req.AddHeader("X-Server-API-Key", d.cfg.APIKey)
return d.client.Do(context.Background(), req, pl, &postalResponse{})
}
================================================
FILE: drivers/postal_test.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package drivers
import (
"fmt"
mocks "github.com/ainsleyclark/go-mail/internal/mocks/client"
"github.com/ainsleyclark/go-mail/mail"
"log"
"net/http"
)
func ExampleNewPostal() {
cfg := mail.Config{
URL: "https://postal.example.com",
APIKey: "my-key",
FromAddress: "hello@gophers.com",
FromName: "Gopher",
}
_, err := NewPostal(cfg)
if err != nil {
log.Fatalln(err)
}
}
func (t *DriversTestSuite) TestNewPostal() {
tt := map[string]struct {
input mail.Config
want interface{}
}{
"Success": {
mail.Config{
URL: "https://postal.example.com",
APIKey: "key",
FromAddress: "addr",
FromName: "name",
},
nil,
},
"Validation Failed": {
mail.Config{},
"driver requires from address",
},
}
for name, test := range tt {
t.Run(name, func() {
got, err := NewPostal(test.input)
if err != nil {
t.Contains(err.Error(), test.want)
return
}
t.NotNil(got)
})
}
}
func (t *DriversTestSuite) TestPostalResponse_Unmarshal() {
t.UtilTestUnmarshal(&postalResponse{}, []byte(`{"status": "success"}`))
}
func (t *DriversTestSuite) TestPostalResponse_CheckError() {
tt := map[string]struct {
input postalResponse
response *http.Response
buf []byte
want error
}{
"Success": {
postalResponse{Status: "success"},
&http.Response{StatusCode: http.StatusOK},
[]byte("test"),
nil,
},
"Empty Body": {
postalResponse{},
&http.Response{StatusCode: http.StatusInternalServerError},
nil,
mail.ErrEmptyBody,
},
"Error": {
postalResponse{Data: map[string]interface{}{"code": "code", "message": "message"}},
&http.Response{StatusCode: http.StatusInternalServerError},
[]byte("test"),
fmt.Errorf("%s - code: code, message: message", postalErrorMessage),
},
}
for name, test := range tt {
t.Run(name, func() {
err := test.input.CheckError(test.response, test.buf)
if err != nil {
t.Contains(err.Error(), test.want.Error())
return
}
t.Equal(test.want, err)
})
}
}
func (t *DriversTestSuite) TestPostalResponse_Meta() {
d := &postalResponse{
Data: map[string]interface{}{"message_id": 10},
}
t.UtilTestMeta(d, "Successfully sent Postal email", "10")
}
func (t *DriversTestSuite) TestPostal_Send() {
t.UtilTestSend(func(m *mocks.Requester) mail.Mailer {
return &postal{cfg: Comfig, client: m}
}, true)
}
================================================
FILE: drivers/postmark.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package drivers
import (
"context"
"encoding/json"
"fmt"
"github.com/ainsleyclark/go-mail/internal/client"
"github.com/ainsleyclark/go-mail/internal/httputil"
"github.com/ainsleyclark/go-mail/mail"
"net/http"
"strings"
"time"
)
// postal represents the entity for sending mail via the
// Postmark API.
//
// See: https://postmarkapp.com/developer/api/email-api
type postmark struct {
cfg mail.Config
client client.Requester
}
const (
// postalEndpoint defines the endpoint to POST to.
postmarkEndpoint = "https://api.postmarkapp.com/email"
// postmarkErrorMessage defines the message when an error occurred
// when sending mail via the Postmark API.
postmarkErrorMessage = "error sending transmission to Postmark API"
)
// NewPostmark creates a new Postmark client. Configuration
// is validated before initialisation.
func NewPostmark(cfg mail.Config) (mail.Mailer, error) {
err := cfg.Validate()
if err != nil {
return nil, err
}
return &postmark{
cfg: cfg,
client: client.New(cfg.Client),
}, nil
}
type (
// postmarkTransmission defines the data to be sent to the Postmark API.
postmarkTransmission struct {
From string `json:"From"`
To string `json:"To"`
CC string `json:"Cc"`
BCC string `json:"Bcc"`
Subject string `json:"Subject"`
Tag string `json:"Tag"`
HTML string `json:"HtmlBody"`
PlainText string `json:"TextBody"`
ReplyTo string `json:"ReplyTo"`
Headers []postmarkHeader `json:"headers"`
TrackOpens bool `json:"TrackOpens"`
TrackLinks string `json:"TrackLinks"`
Attachments []postmarkAttachment `json:"Attachments"`
Metadata struct {
Color string `json:"color"`
ClientID string `json:"client-id"`
} `json:"Metadata"`
MessageStream string `json:"MessageStream"`
}
// postmarkHeaders defines the key value pair of custom headers
// to send with the email.
postmarkHeader struct {
Name string `json:"Name"`
Value string `json:"Value"`
}
// postmarkAttachment defines a singular Postmark mail attachment.
postmarkAttachment struct {
Name string `json:"Name"`
Content string `json:"Content"`
ContentType string `json:"ContentType"`
ContentID string `json:"ContentID,omitempty"`
}
// postmarkResponse defines the data sent back from the Postmark API.
// An error code of 0 represents a successful transmission.
//
// Example JSON Responses:
// {"To":"info@ainsleyclark.com","SubmittedAt":"2021-12-29T15:58:17.8637679Z","MessageID":"947125ed-9e43-4dce-b66c-def49198b3d3","ErrorCode":0,"Message":"OK"}
// {"ErrorCode":300,"Message":"Zero recipients specified"}
postmarkResponse struct {
To string `json:"To"`
SubmittedAt time.Time `json:"SubmittedAt"`
ID string `json:"MessageID"`
ErrorCode int `json:"ErrorCode"`
Message string `json:"Message"`
}
)
func (r *postmarkResponse) Unmarshal(buf []byte) error {
resp := &postmarkResponse{}
err := json.Unmarshal(buf, resp)
if err != nil {
return err
}
*r = *resp
return nil
}
func (r *postmarkResponse) CheckError(response *http.Response, buf []byte) error {
if r.ErrorCode == 0 {
return nil
}
if len(buf) == 0 {
return mail.ErrEmptyBody
}
return fmt.Errorf("%s - code: %d, message: %s", postmarkErrorMessage, r.ErrorCode, r.Message)
}
func (r *postmarkResponse) Meta() httputil.Meta {
return httputil.Meta{
Message: r.Message,
ID: r.ID,
}
}
func (d *postmark) Send(t *mail.Transmission) (mail.Response, error) {
err := t.Validate()
if err != nil {
return mail.Response{}, err
}
tx := postmarkTransmission{
To: strings.Join(t.Recipients, ","),
CC: strings.Join(t.CC, ","),
BCC: strings.Join(t.BCC, ","),
From: fmt.Sprintf("%s <%s>", d.cfg.FromName, d.cfg.FromAddress),
Subject: t.Subject,
HTML: t.HTML,
PlainText: t.PlainText,
MessageStream: "outbound",
}
if t.HasAttachments() {
for _, v := range t.Attachments {
tx.Attachments = append(tx.Attachments, postmarkAttachment{
Name: v.Filename,
ContentType: v.Mime(),
Content: v.B64(),
})
}
}
for k, v := range t.Headers {
tx.Headers = append(tx.Headers, postmarkHeader{
Name: k,
Value: v,
})
}
pl, err := newJSONData(tx)
if err != nil {
return mail.Response{}, err
}
req := httputil.NewHTTPRequest(http.MethodPost, postmarkEndpoint)
req.AddHeader("X-Postmark-Server-Token", d.cfg.APIKey)
return d.client.Do(context.Background(), req, pl, &postmarkResponse{})
}
================================================
FILE: drivers/postmark_test.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package drivers
import (
"fmt"
mocks "github.com/ainsleyclark/go-mail/internal/mocks/client"
"github.com/ainsleyclark/go-mail/mail"
"log"
"net/http"
)
func ExampleNewPostmark() {
cfg := mail.Config{
URL: "https://postal.example.com",
APIKey: "my-key",
FromAddress: "hello@gophers.com",
FromName: "Gopher",
}
_, err := NewPostal(cfg)
if err != nil {
log.Fatalln(err)
}
}
func (t *DriversTestSuite) TestNewPostmark() {
tt := map[string]struct {
input mail.Config
want interface{}
}{
"Success": {
mail.Config{
APIKey: "key",
FromAddress: "addr",
FromName: "name",
},
nil,
},
"Validation Failed": {
mail.Config{},
"driver requires from address",
},
}
for name, test := range tt {
t.Run(name, func() {
got, err := NewPostmark(test.input)
if err != nil {
t.Contains(err.Error(), test.want)
return
}
t.NotNil(got)
})
}
}
func (t *DriversTestSuite) TestPostmarkResponse_Unmarshal() {
t.UtilTestUnmarshal(&postmarkResponse{}, []byte(`{"message": "Hello"}`))
}
func (t *DriversTestSuite) TestPostmarkResponse_CheckError() {
tt := map[string]struct {
input postmarkResponse
response *http.Response
buf []byte
want error
}{
"Success": {
postmarkResponse{ErrorCode: 0},
&http.Response{StatusCode: http.StatusOK},
[]byte("test"),
nil,
},
"Empty Body": {
postmarkResponse{ErrorCode: 10},
&http.Response{StatusCode: http.StatusInternalServerError},
nil,
mail.ErrEmptyBody,
},
"Error": {
postmarkResponse{ErrorCode: 10, Message: "message"},
&http.Response{StatusCode: http.StatusInternalServerError},
[]byte("test"),
fmt.Errorf("%s - code: 10, message: message", postmarkErrorMessage),
},
}
for name, test := range tt {
t.Run(name, func() {
err := test.input.CheckError(test.response, test.buf)
if err != nil {
t.Contains(err.Error(), test.want.Error())
return
}
t.Equal(test.want, err)
})
}
}
func (t *DriversTestSuite) TestPostmarkResponse_Meta() {
d := &postmarkResponse{Message: "Success", ID: "id"}
t.UtilTestMeta(d, d.Message, d.ID)
}
func (t *DriversTestSuite) TestPostmark_Send() {
t.UtilTestSend(func(m *mocks.Requester) mail.Mailer {
return &postmark{cfg: Comfig, client: m}
}, true)
}
================================================
FILE: drivers/sendgrid.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package drivers
import (
"context"
"encoding/json"
"fmt"
"github.com/ainsleyclark/go-mail/internal/client"
"github.com/ainsleyclark/go-mail/internal/httputil"
"github.com/ainsleyclark/go-mail/mail"
"net/http"
)
// sendGrid represents the entity for sending mail via the
// SendGrid API.
//
// See:
// https://docs.sendgrid.com/api-reference/how-to-use-the-sendgrid-v3-api
// https://docs.sendgrid.com/api-reference/mail-send/mail-send
type sendGrid struct {
cfg mail.Config
client client.Requester
}
const (
// sendGridEndpoint defines the endpoint to POST to.
// The host for Web API v3 requests is always https://sendgrid.com/v3/
sendGridEndpoint = "https://api.sendgrid.com/v3/mail/send"
// sendgridErrorMessage defines the message when an error occurred
// when sending mail via the SendGrid API.
sendgridErrorMessage = "error sending transmission to SendGrid API"
)
// NewSendGrid creates a new sendGrid client. Configuration
// is validated before initialisation.
func NewSendGrid(cfg mail.Config) (mail.Mailer, error) {
err := cfg.Validate()
if err != nil {
return nil, err
}
return &sendGrid{
cfg: cfg,
client: client.New(cfg.Client),
}, nil
}
type (
// postalTransmission defines the data to be sent to the sendGrid API.
sgTransmission struct {
From *sgEmail `json:"from,omitempty"`
Subject string `json:"subject,omitempty"`
Personalizations []*sgPersonalization `json:"personalizations,omitempty"`
Content []*sgContent `json:"content,omitempty"`
Attachments []*sgAttachment `json:"attachments,omitempty"`
TemplateID string `json:"template_id,omitempty"`
Sections map[string]string `json:"sections,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Categories []string `json:"categories,omitempty"`
CustomArgs map[string]string `json:"custom_args,omitempty"`
SendAt int `json:"send_at,omitempty"`
BatchID string `json:"batch_id,omitempty"`
IPPoolID string `json:"ip_pool_name,omitempty"`
ReplyTo *sgEmail `json:"reply_to,omitempty"`
}
// sgPersonalization holds the mail body struct.
sgPersonalization struct {
To []*sgEmail `json:"to,omitempty"`
From *sgEmail `json:"from,omitempty"`
CC []*sgEmail `json:"cc,omitempty"`
BCC []*sgEmail `json:"bcc,omitempty"`
Subject string `json:"subject,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Substitutions map[string]string `json:"substitutions,omitempty"`
CustomArgs map[string]string `json:"custom_args,omitempty"`
DynamicTemplateData map[string]interface{} `json:"dynamic_template_data,omitempty"`
Categories []string `json:"categories,omitempty"`
SendAt int `json:"send_at,omitempty"`
}
// sgEmail holds email name and address info.
sgEmail struct {
Name string `json:"name,omitempty"`
Address string `json:"email,omitempty"`
}
// sgContent defines content of the mail body.
sgContent struct {
Type string `json:"type,omitempty"`
Value string `json:"value,omitempty"`
}
// sgAttachment holds attachment information.
sgAttachment struct {
Content string `json:"content,omitempty"`
Type string `json:"type,omitempty"`
Name string `json:"name,omitempty"`
Filename string `json:"filename,omitempty"`
Disposition string `json:"disposition,omitempty"`
ContentID string `json:"content_id,omitempty"`
}
// sgResponse contains the response data from the SendGrid
// API.
// Note: No response data is passed if the response code is 2xx
//
// Example JSON Response:
// {"errors":[{"message":"The from object must be provided for every email send. It is an object that requires the email parameter, but may also contain a name parameter. e.g. {\"email\" : \"example@example.com\"} or {\"email\" : \"example@example.com\", \"name\" : \"Example Recipient\"}.","field":"from.email","help":"http://sendgrid.com/docs/API_Reference/Web_API_v3/Mail/errors.html#message.from"}]}
sgResponse struct {
Errors []sgError `json:"errors"`
}
// sgError defines a singular validation error from the API.
sgError struct {
Message string `json:"message"`
Field string `json:"field"`
Help string `json:"help"`
}
)
func (r *sgResponse) Unmarshal(buf []byte) error {
if len(buf) == 0 {
return nil
}
resp := &sgResponse{}
err := json.Unmarshal(buf, resp)
if err != nil {
return err
}
*r = *resp
return nil
}
func (r *sgResponse) CheckError(response *http.Response, buf []byte) error {
if client.Is2XX(response.StatusCode) {
return nil
}
if len(r.Errors) == 0 {
return nil
}
return fmt.Errorf("%s - message: %s, field: %s, help: %s", sendgridErrorMessage, r.Errors[0].Message, r.Errors[0].Field, r.Errors[0].Help)
}
func (r *sgResponse) Meta() httputil.Meta {
return httputil.Meta{
Message: "Successfully sent SendGrid email",
// No response data from SendGrid
ID: "",
}
}
func (d *sendGrid) Send(t *mail.Transmission) (mail.Response, error) {
err := t.Validate()
if err != nil {
return mail.Response{}, err
}
tx := sgTransmission{
From: &sgEmail{
Name: d.cfg.FromName,
Address: d.cfg.FromAddress,
},
Subject: t.Subject,
Personalizations: []*sgPersonalization{
{Subject: t.Subject},
},
Content: []*sgContent{
{Type: "text/plain", Value: t.PlainText},
{Type: "text/html", Value: t.HTML},
},
Attachments: nil,
}
for _, r := range t.Recipients {
tx.Personalizations[0].To = append(tx.Personalizations[0].To, &sgEmail{
Address: r,
})
}
if t.HasCC() {
for _, c := range t.CC {
tx.Personalizations[0].CC = append(tx.Personalizations[0].CC, &sgEmail{
Address: c,
})
}
}
if t.HasBCC() {
for _, b := range t.BCC {
tx.Personalizations[0].BCC = append(tx.Personalizations[0].BCC, &sgEmail{
Address: b,
})
}
}
if t.HasAttachments() {
for _, v := range t.Attachments {
tx.Attachments = append(tx.Attachments, &sgAttachment{
Content: v.B64(),
Type: v.Mime(),
Name: "",
Filename: v.Filename,
Disposition: "attachment",
})
}
}
tx.Headers = t.Headers
pl, err := newJSONData(tx)
if err != nil {
return mail.Response{}, err
}
req := httputil.NewHTTPRequest(http.MethodPost, sendGridEndpoint)
req.AddHeader("Authorization", "Bearer "+d.cfg.APIKey)
return d.client.Do(context.Background(), req, pl, &sgResponse{})
}
================================================
FILE: drivers/sendgrid_test.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package drivers
import (
"fmt"
mocks "github.com/ainsleyclark/go-mail/internal/mocks/client"
"github.com/ainsleyclark/go-mail/mail"
"log"
"net/http"
)
func ExampleNewSendGrid() {
cfg := mail.Config{
APIKey: "my-key",
FromAddress: "hello@gophers.com",
FromName: "Gopher",
}
_, err := NewSendGrid(cfg)
if err != nil {
log.Fatalln(err)
}
}
func (t *DriversTestSuite) TestNewSendGrid() {
tt := map[string]struct {
input mail.Config
want interface{}
}{
"Success": {
mail.Config{
URL: "https://sendgrid.example.com",
APIKey: "key",
FromAddress: "addr",
FromName: "name",
},
nil,
},
"Validation Failed": {
mail.Config{},
"driver requires from address",
},
}
for name, test := range tt {
t.Run(name, func() {
got, err := NewSendGrid(test.input)
if err != nil {
t.Contains(err.Error(), test.want)
return
}
t.NotNil(got)
})
}
}
func (t *DriversTestSuite) TestSendGridResponse_Unmarshal() {
t.UtilTestUnmarshal(&sgResponse{}, []byte(`{"errors": []}`))
res := sgResponse{}
err := res.Unmarshal(nil)
t.NoError(err)
}
func (t *DriversTestSuite) TestSendGridResponse_CheckError() {
tt := map[string]struct {
input sgResponse
response *http.Response
buf []byte
want error
}{
"Success": {
sgResponse{Errors: nil},
&http.Response{StatusCode: http.StatusOK},
[]byte("test"),
nil,
},
"No Errors": {
sgResponse{},
&http.Response{StatusCode: http.StatusInternalServerError},
nil,
nil,
},
"Error": {
sgResponse{Errors: []sgError{{Message: "message", Field: "field", Help: "help"}}},
&http.Response{StatusCode: http.StatusInternalServerError},
[]byte("test"),
fmt.Errorf("%s - message: message, field: field, help: help", sendgridErrorMessage),
},
}
for name, test := range tt {
t.Run(name, func() {
err := test.input.CheckError(test.response, test.buf)
if err != nil {
t.Contains(err.Error(), test.want.Error())
return
}
t.Equal(test.want, err)
})
}
}
func (t *DriversTestSuite) TestSendGridResponse_Meta() {
d := &sgResponse{}
t.UtilTestMeta(d, "Successfully sent SendGrid email", "")
}
func (t *DriversTestSuite) TestSendGrid_Send() {
t.UtilTestSend(func(m *mocks.Requester) mail.Mailer {
return &sendGrid{cfg: Comfig, client: m}
}, true)
}
================================================
FILE: drivers/smtp.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package drivers
import (
"bytes"
"errors"
"fmt"
"github.com/ainsleyclark/go-mail/mail"
"mime/multipart"
"net/http"
"net/smtp"
"strconv"
"strings"
)
// smtpClient represents the data for sending mail via
// plain ol SMTP. Configuration, the client and the
// main send function are parsed for sending
// data.
type smtpClient struct {
cfg mail.Config
send smtpSendFunc
}
// smtpSendFunc defines the function for ending
// SMTP mail.Transmissions.
type smtpSendFunc func(addr string, a smtp.Auth, from string, to []string, msg []byte) error
// NewSMTP creates a new smtp client. Configuration
// is validated before initialisation.
func NewSMTP(cfg mail.Config) (mail.Mailer, error) {
if cfg.URL == "" {
return nil, errors.New("driver requires a url")
}
if cfg.FromAddress == "" {
return nil, errors.New("driver requires from address")
}
if cfg.FromName == "" {
return nil, errors.New("driver requires from name")
}
if cfg.Password == "" {
return nil, errors.New("driver requires a password")
}
return &smtpClient{
cfg: cfg,
send: smtp.SendMail,
}, nil
}
// Send mail via plain SMTP. mail.Transmissions are validated
// before sending and attachments are added. Returns
// an error upon failure.
func (m *smtpClient) Send(t *mail.Transmission) (mail.Response, error) {
err := t.Validate()
if err != nil {
return mail.Response{}, err
}
auth := smtp.PlainAuth("", m.cfg.FromAddress, m.cfg.Password, m.cfg.URL)
err = m.send(m.cfg.URL+":"+strconv.Itoa(m.cfg.Port), auth, m.cfg.FromAddress, m.getTo(t), m.bytes(t))
if err != nil {
return mail.Response{}, err
}
return mail.Response{
StatusCode: http.StatusOK,
Message: "Email sent successfully",
}, nil
}
// getTo returns the merged mail.Transmission recipients, CC and
// BCC email addresses.
func (m *smtpClient) getTo(t *mail.Transmission) []string {
var to []string
to = append(t.Recipients, t.CC...)
to = append(to, t.BCC...)
return to
}
// Processes the mail.Transmission and returns the bytes for
// sending. Mime types are set dependent on the
// content passed.
// See: https://gist.github.com/tylermakin/d820f65eb3c9dd98d58721c7fb1939a8?permalink_comment_id=2703291
func (m *smtpClient) bytes(t *mail.Transmission) []byte {
buf := bytes.NewBuffer(nil)
for k, v := range t.Headers {
buf.WriteString(fmt.Sprintf("%s: %s\n", k, v))
}
buf.WriteString("MIME-Version: 1.0\n")
writer := multipart.NewWriter(buf)
boundary := writer.Boundary()
if t.HasAttachments() {
buf.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=%s\r\n", boundary))
} else {
buf.WriteString(fmt.Sprintf("Content-Type: text/html; charset=UTF-8; boundary=%s\r\n", boundary))
}
buf.WriteString(fmt.Sprintf("Subject: %s\n", t.Subject))
buf.WriteString(fmt.Sprintf("To: %s\n", strings.Join(t.Recipients, ",")))
if t.HasCC() {
buf.WriteString(fmt.Sprintf("CC: %s\n", strings.Join(t.CC, ",")))
}
buf.WriteString("\n")
if t.PlainText != "" {
buf.WriteString(fmt.Sprintf("--%s\r\n", boundary))
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
buf.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
buf.WriteString(fmt.Sprintf("\r\n%s\r\n\n", strings.TrimSpace(t.PlainText)))
}
if t.HTML != "" {
buf.WriteString(fmt.Sprintf("--%s\r\n", boundary))
buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n")
buf.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
buf.WriteString(fmt.Sprintf("\r\n%s\r\n\n", t.HTML))
}
if t.HasAttachments() {
for _, v := range t.Attachments {
buf.WriteString(fmt.Sprintf("--%s\r\n", boundary))
buf.WriteString(fmt.Sprintf("Content-Type: %s\n", v.Mime()))
buf.WriteString("Content-Transfer-Encoding: base64\n")
buf.WriteString(fmt.Sprintf("Content-Disposition: attachment; filename=%s\n", v.Filename))
buf.WriteString(fmt.Sprintf("\r\n--%s", v.B64()))
}
buf.WriteString("--")
}
buf.WriteString(fmt.Sprintf("--%s--\n", boundary))
return buf.Bytes()
}
================================================
FILE: drivers/smtp_test.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package drivers
import (
"errors"
"fmt"
"github.com/ainsleyclark/go-mail/mail"
"net/smtp"
)
func (t *DriversTestSuite) TestNewSMTP() {
tt := map[string]struct {
input mail.Config
want interface{}
}{
"Success": {
mail.Config{
URL: "https://smtp.example.com",
FromAddress: "addr",
FromName: "name",
Password: "password",
},
nil,
},
"No url": {
mail.Config{},
"driver requires a url",
},
"No From Address": {
mail.Config{
URL: "https://smtp.example.com",
},
"driver requires from address",
},
"No From Name": {
mail.Config{
URL: "https://smtp.example.com",
FromAddress: "hello@gophers.com",
},
"driver requires from name",
},
"No Password": {
mail.Config{
URL: "https://smtp.example.com",
FromAddress: "hello@gophers.com",
FromName: "name",
},
"driver requires a password",
},
}
for name, test := range tt {
t.Run(name, func() {
got, err := NewSMTP(test.input)
if err != nil {
t.Contains(err.Error(), test.want)
return
}
t.NotNil(got)
})
}
}
func (t *DriversTestSuite) TestSMTP_Send() {
tt := map[string]struct {
input *mail.Transmission
send smtpSendFunc
want interface{}
}{
"Success": {
Trans,
func(addr string, a smtp.Auth, from string, to []string, msg []byte) error {
return nil
},
mail.Response{
StatusCode: 200,
Message: "Email sent successfully",
},
},
"With Attachment": {
TransWithAttachment,
func(addr string, a smtp.Auth, from string, to []string, msg []byte) error {
return nil
},
mail.Response{
StatusCode: 200,
Message: "Email sent successfully",
},
},
"Validation Failed": {
nil,
func(addr string, a smtp.Auth, from string, to []string, msg []byte) error {
return nil
},
"can't validate a nil transmission",
},
"Send Error": {
Trans,
func(addr string, a smtp.Auth, from string, to []string, msg []byte) error {
return errors.New("send error")
},
"send error",
},
}
for name, test := range tt {
t.Run(name, func() {
spark := smtpClient{
cfg: mail.Config{
FromAddress: "from",
},
send: test.send,
}
resp, err := spark.Send(test.input)
if err != nil {
t.Contains(err.Error(), test.want)
return
}
t.Equal(test.want, resp)
})
}
}
func (t *DriversTestSuite) TestSMTP_Bytes() {
t.T().Skip()
m := smtpClient{}
got := m.bytes(&mail.Transmission{
Recipients: []string{"hello@gmail.com"},
Subject: "Subject",
HTML: "
Hey!
",
PlainText: "Hey!",
//Attachments: []mail.Attachment{
// {
// Filename: "test.jpg",
// Bytes: []byte("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg=="),
// },
//},
})
fmt.Println(string(got))
}
================================================
FILE: drivers/sparkpost.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package drivers
import (
"context"
"encoding/json"
"fmt"
"github.com/ainsleyclark/go-mail/internal/client"
"github.com/ainsleyclark/go-mail/internal/httputil"
"github.com/ainsleyclark/go-mail/mail"
"net/http"
"strings"
"time"
)
// postal represents the entity for sending mail via the
// Postal API.
//
// See:
// https://developers.sparkpost.com/api/
// https://developers.sparkpost.com/api/transmissions/#transmissions-create-a-transmission
type sparkPost struct {
cfg mail.Config
client client.Requester
}
const (
// sparkpostEndpoint defines the endpoint to POST to.
// See: https://www.sparkpost.com/api#/reference/transmissions
sparkpostEndpoint = "%s/api/v1/transmissions"
// sparkpostErrorMessage defines the message when an error occurred
// when sending mail via the SparkPost API.
sparkpostErrorMessage = "error sending transmission to SparkPost API"
)
// NewSparkPost creates a new SparkPost client. Configuration
// is validated before initialisation.
func NewSparkPost(cfg mail.Config) (mail.Mailer, error) {
err := cfg.Validate()
if err != nil {
return nil, err
}
return &sparkPost{
cfg: cfg,
client: client.New(cfg.Client),
}, nil
}
type (
// spTransmission is the JSON structure accepted by and returned
// from the SparkPost Transmissions API.
spTransmission struct {
ID string `json:"id,omitempty"`
State string `json:"state,omitempty"`
Options *spTransmissionOptions `json:"options,omitempty"`
Recipients []spRecipient `json:"recipients"`
CampaignID string `json:"campaign_id,omitempty"`
Description string `json:"description,omitempty"`
Metadata interface{} `json:"metadata,omitempty"`
SubstitutionData interface{} `json:"substitution_data,omitempty"`
ReturnPath string `json:"return_path,omitempty"`
Content spContent `json:"content"`
TotalRecipients *int `json:"total_recipients,omitempty"`
NumGenerated *int `json:"num_generated,omitempty"`
NumFailedGeneration *int `json:"num_failed_generation,omitempty"`
NumInvalidRecipients *int `json:"num_invalid_recipients,omitempty"`
}
// spTransmissionOptions specifies settings to apply to this Transmission.
// If not specified, and present in TmplOptions, those values will be used.
spTransmissionOptions struct {
OpenTracking *bool `json:"open_tracking,omitempty"`
ClickTracking *bool `json:"click_tracking,omitempty"`
Transactional *bool `json:"transactional,omitempty"`
StartTime *time.Time `json:"start_time,omitempty"`
Sandbox *bool `json:"sandbox,omitempty"`
SkipSuppression *bool `json:"skip_suppression,omitempty"`
IPPool string `json:"ip_pool,omitempty"`
InlineCSS *bool `json:"inline_css,omitempty"`
PerformSubstitutions *bool `json:"perform_substitutions,omitempty"`
}
// spContent is what will be sent to recipients.
// Knowledge of SparkPost's substitution/templating capabilities will come in handy here.
// https://www.sparkpost.com/api#/introduction/substitutions-reference
spContent struct {
HTML string `json:"html,omitempty"`
Text string `json:"text,omitempty"`
Subject string `json:"subject,omitempty"`
From spFrom `json:"from,omitempty"`
ReplyTo string `json:"reply_to,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
EmailRFC822 string `json:"email_rfc822,omitempty"`
Attachments []spAttachment `json:"attachments,omitempty"`
InlineImages []interface{} `json:"inline_images,omitempty"`
}
// spFrom describes the nested object way of specifying the `From` header.
// Content.From can be specified this way, or as a plain string.
spFrom struct {
Email string `json:"email"`
Name string `json:"name"`
}
// spResponse contains information about the last HTTP response from
// the SparkPost API.
//
// Example JSON Response:
// {"results":{"total_rejected_recipients":0,"total_accepted_recipients":1,"id":"7029753512321354395"}}
// {"errors":[{"message":"content.subject is a required field","code":"1400"}]}
spResponse struct {
Results map[string]interface{} `json:"results,omitempty"`
Errors []spError `json:"errors,omitempty"`
}
// spError mirrors the error format returned by SparkPost APIs.
spError struct {
Message string `json:"message"`
Code string `json:"code"`
Description string `json:"description"`
Part string `json:"part,omitempty"`
Line int `json:"line,omitempty"`
}
// spRecipient represents one email (you guessed it) recipient.
spRecipient struct {
Address spAddress `json:"address"`
ReturnPath string `json:"return_path,omitempty"`
Tags []string `json:"tags,omitempty"`
Metadata interface{} `json:"metadata,omitempty"`
SubstitutionData interface{} `json:"substitution_data,omitempty"`
}
// spAddress describes the nested object way of specifying the
// Recipient's email address. Recipient.Address can also be
// a plain string.
spAddress struct {
Email string `json:"email"`
Name string `json:"name,omitempty"`
HeaderTo string `json:"header_to,omitempty"`
}
// spAttachment contains metadata and the contents of the
// file to attach.
spAttachment struct {
MIMEType string `json:"type"`
Filename string `json:"name"`
B64Data string `json:"data"`
}
)
func (r *spResponse) Unmarshal(buf []byte) error {
resp := &spResponse{}
err := json.Unmarshal(buf, resp)
if err != nil {
return err
}
*r = *resp
return nil
}
func (r *spResponse) CheckError(response *http.Response, buf []byte) error {
if len(r.Errors) == 0 {
return nil
}
if len(buf) == 0 {
return mail.ErrEmptyBody
}
return fmt.Errorf("%s - code: %s, message: %s", sparkpostErrorMessage, r.Errors[0].Code, r.Errors[0].Message)
}
func (r *spResponse) Meta() httputil.Meta {
m := httputil.Meta{
Message: "Successfully sent SparkPost email",
}
if val, ok := r.Results["id"]; ok {
m.ID = fmt.Sprintf("%v", val)
}
return m
}
func (d *sparkPost) Send(t *mail.Transmission) (mail.Response, error) {
err := t.Validate()
if err != nil {
return mail.Response{}, err
}
headerTo := strings.Join(t.Recipients, ",")
tx := spTransmission{
Content: spContent{
HTML: t.HTML,
Text: t.PlainText,
Subject: t.Subject,
From: spFrom{
Email: d.cfg.FromAddress,
Name: d.cfg.FromName,
},
ReplyTo: "",
Headers: make(map[string]string),
},
}
for _, r := range t.Recipients {
tx.Recipients = append(tx.Recipients, spRecipient{
Address: spAddress{Email: r, HeaderTo: headerTo},
})
}
if t.HasCC() {
for _, c := range t.CC {
tx.Recipients = append(tx.Recipients, spRecipient{
Address: spAddress{Email: c, HeaderTo: headerTo},
})
tx.Content.Headers["cc"] = strings.Join(t.CC, ",")
}
}
if t.HasBCC() {
for _, b := range t.BCC {
tx.Recipients = append(tx.Recipients, spRecipient{
Address: spAddress{Email: b, HeaderTo: headerTo},
})
}
}
if t.HasAttachments() {
for _, v := range t.Attachments {
tx.Content.Attachments = append(tx.Content.Attachments, spAttachment{
MIMEType: v.Mime(),
Filename: v.Filename,
B64Data: v.B64(),
})
}
}
tx.Content.Headers = t.Headers
pl, err := newJSONData(tx)
if err != nil {
return mail.Response{}, err
}
req := httputil.NewHTTPRequest(http.MethodPost, fmt.Sprintf(sparkpostEndpoint, d.cfg.URL))
req.AddHeader("Authorization", d.cfg.APIKey)
return d.client.Do(context.Background(), req, pl, &spResponse{})
}
================================================
FILE: drivers/sparkpost_test.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package drivers
import (
"fmt"
mocks "github.com/ainsleyclark/go-mail/internal/mocks/client"
"github.com/ainsleyclark/go-mail/mail"
"log"
"net/http"
)
func ExampleNewSparkPost() {
cfg := mail.Config{
URL: "https://api.eu.sparkpost.com", // Or https://api.sparkpost.com/api/v1
APIKey: "my-key",
FromAddress: "hello@gophers.com",
FromName: "Gopher",
}
_, err := NewSparkPost(cfg)
if err != nil {
log.Fatalln(err)
}
}
func (t *DriversTestSuite) TestNewSparkPost() {
tt := map[string]struct {
input mail.Config
want interface{}
}{
"Success": {
mail.Config{
URL: "https://api.eu.sparkpost.com",
APIKey: "key",
FromAddress: "addr",
FromName: "name",
},
nil,
},
"Validation Failed": {
mail.Config{},
"driver requires from address",
},
"Error": {
mail.Config{
URL: "http://",
APIKey: "key",
FromAddress: "addr",
FromName: "name",
},
"API base url must be https!",
},
}
for name, test := range tt {
t.Run(name, func() {
got, err := NewSparkPost(test.input)
if err != nil {
t.Contains(err.Error(), test.want)
return
}
t.NotNil(got)
})
}
}
func (t *DriversTestSuite) TestSSparkPostResponse_Unmarshal() {
t.UtilTestUnmarshal(&spResponse{}, []byte(`{"results": {}}`))
}
func (t *DriversTestSuite) TestSparkPostResponse_CheckError() {
tt := map[string]struct {
input spResponse
response *http.Response
buf []byte
want error
}{
"Success": {
spResponse{Errors: nil},
&http.Response{StatusCode: http.StatusOK},
[]byte("test"),
nil,
},
"No Errors": {
spResponse{Errors: []spError{{Message: "message", Code: "code"}}},
&http.Response{StatusCode: http.StatusInternalServerError},
nil,
mail.ErrEmptyBody,
},
"Error": {
spResponse{Errors: []spError{{Message: "message", Code: "code"}}},
&http.Response{StatusCode: http.StatusInternalServerError},
[]byte("test"),
fmt.Errorf("%s - code: code, message: message", sparkpostErrorMessage),
},
}
for name, test := range tt {
t.Run(name, func() {
err := test.input.CheckError(test.response, test.buf)
if err != nil {
t.Contains(err.Error(), test.want.Error())
return
}
t.Equal(test.want, err)
})
}
}
func (t *DriversTestSuite) TestSparkPostResponse_Meta() {
d := &spResponse{
Results: map[string]interface{}{"id": "10"},
Errors: nil,
}
t.UtilTestMeta(d, "Successfully sent SparkPost email", "10")
}
func (t *DriversTestSuite) TestSparkPost_Send() {
t.UtilTestSend(func(m *mocks.Requester) mail.Mailer {
return &sparkPost{cfg: Comfig, client: m}
}, true)
}
================================================
FILE: examples/attachments.go
================================================
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mail
import (
"fmt"
"github.com/ainsleyclark/go-mail/drivers"
"github.com/ainsleyclark/go-mail/mail"
"io/ioutil"
"log"
)
// Attachments example for Go Mail
func Attachments() {
cfg := mail.Config{
URL: "https://api.eu.sparkpost.com",
APIKey: "my-key",
FromAddress: "hello@gophers.com",
FromName: "Gopher",
}
mailer, err := drivers.NewSparkPost(cfg)
if err != nil {
log.Fatalln(err)
}
image, err := ioutil.ReadFile("gopher.jpg")
if err != nil {
log.Fatalln(err)
}
tx := &mail.Transmission{
Recipients: []string{"hello@gophers.com"},
Subject: "My email",
HTML: "
Hello from Go Mail!
",
PlainText: "plain text",
Attachments: []mail.Attachment{
{
Filename: "gopher.jpg",
Bytes: image,
},
},
}
result, err := mailer.Send(tx)
if err != nil {
log.Fatalln(err)
}
fmt.Printf("%+v\n", result)
}
================================================
FILE: examples/mailgun.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mail
import (
"fmt"
"github.com/ainsleyclark/go-mail/drivers"
"github.com/ainsleyclark/go-mail/mail"
"log"
)
// MailGun example for Go Mail
func MailGun() {
cfg := mail.Config{
URL: "my-url",
APIKey: "my-key",
FromAddress: "hello@gophers.com",
FromName: "Gopher",
Domain: "my-domain",
}
mailer, err := drivers.NewMailGun(cfg)
if err != nil {
log.Fatalln(err)
}
tx := &mail.Transmission{
Recipients: []string{"hello@gophers.com"},
Subject: "My email",
HTML: "
Hello from Go Mail!
",
PlainText: "plain text",
}
result, err := mailer.Send(tx)
if err != nil {
log.Fatalln(err)
}
fmt.Printf("%+v\n", result)
}
================================================
FILE: examples/postal.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mail
import (
"fmt"
"github.com/ainsleyclark/go-mail/drivers"
"github.com/ainsleyclark/go-mail/mail"
"log"
)
// Postal example for Go Mail
func Postal() {
cfg := mail.Config{
URL: "https://postal.example.com",
APIKey: "my-key",
FromAddress: "hello@gophers.com",
FromName: "Gopher",
}
mailer, err := drivers.NewPostal(cfg)
if err != nil {
log.Fatalln(err)
}
tx := &mail.Transmission{
Recipients: []string{"hello@gophers.com"},
Subject: "My email",
HTML: "
Hello from Go Mail!
",
PlainText: "plain text",
}
result, err := mailer.Send(tx)
if err != nil {
log.Fatalln(err)
}
fmt.Printf("%+v\n", result)
}
================================================
FILE: examples/postmark.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mail
import (
"fmt"
"github.com/ainsleyclark/go-mail/drivers"
"github.com/ainsleyclark/go-mail/mail"
"log"
)
// Postmark example for Go Mail
func Postmark() {
cfg := mail.Config{
APIKey: "my-key",
FromAddress: "hello@gophers.com",
FromName: "Gopher",
}
mailer, err := drivers.NewPostmark(cfg)
if err != nil {
log.Fatalln(err)
}
tx := &mail.Transmission{
Recipients: []string{"hello@gophers.com"},
Subject: "My email",
HTML: "
Hello from Go Mail!
",
PlainText: "plain text",
}
result, err := mailer.Send(tx)
if err != nil {
log.Fatalln(err)
}
fmt.Printf("%+v\n", result)
}
================================================
FILE: examples/sendgrid.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mail
import (
"fmt"
"github.com/ainsleyclark/go-mail/drivers"
"github.com/ainsleyclark/go-mail/mail"
"log"
)
// SendGrid example for Go Mail
func SendGrid() {
cfg := mail.Config{
APIKey: "my-key",
FromAddress: "hello@gophers.com",
FromName: "Gopher",
}
mailer, err := drivers.NewSendGrid(cfg)
if err != nil {
log.Fatalln(err)
}
tx := &mail.Transmission{
Recipients: []string{"hello@gophers.com"},
Subject: "My email",
HTML: "
Hello from Go Mail!
",
PlainText: "plain text",
}
result, err := mailer.Send(tx)
if err != nil {
log.Fatalln(err)
}
fmt.Printf("%+v\n", result)
}
================================================
FILE: examples/smtp.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mail
import (
"fmt"
"github.com/ainsleyclark/go-mail/drivers"
"github.com/ainsleyclark/go-mail/mail"
"log"
)
// SMTP example for Go Mail
func SMTP() {
cfg := mail.Config{
URL: "smtp.gmail.com",
FromAddress: "hello@gophers.com",
FromName: "Gopher",
Password: "my-password",
Port: 587,
}
mailer, err := drivers.NewSMTP(cfg)
if err != nil {
log.Fatalln(err)
}
tx := &mail.Transmission{
Recipients: []string{"hello@gophers.com"},
Subject: "My email",
HTML: "
Hello from Go Mail!
",
PlainText: "plain text",
}
result, err := mailer.Send(tx)
if err != nil {
log.Fatalln(err)
}
fmt.Printf("%+v\n", result)
}
================================================
FILE: examples/sparkpost.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mail
import (
"fmt"
"github.com/ainsleyclark/go-mail/drivers"
"github.com/ainsleyclark/go-mail/mail"
"log"
)
// Sparkpost example for Go Mail
func Sparkpost() {
cfg := mail.Config{
URL: "https://api.eu.sparkpost.com",
APIKey: "my-key",
FromAddress: "hello@gophers.com",
FromName: "Gopher",
}
mailer, err := drivers.NewSparkPost(cfg)
if err != nil {
log.Fatalln(err)
}
tx := &mail.Transmission{
Recipients: []string{"hello@gophers.com"},
Subject: "My email",
HTML: "
Hello from Go Mail!
",
PlainText: "plain text",
}
result, err := mailer.Send(tx)
if err != nil {
log.Fatalln(err)
}
fmt.Printf("%+v\n", result)
}
================================================
FILE: go.mod
================================================
module github.com/ainsleyclark/go-mail
go 1.18
require (
github.com/joho/godotenv v1.4.0
github.com/stretchr/testify v1.9.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
================================================
FILE: go.sum
================================================
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
================================================
FILE: internal/client/client.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package client
import (
"context"
"fmt"
"github.com/ainsleyclark/go-mail/internal/errors"
"github.com/ainsleyclark/go-mail/internal/httputil"
"github.com/ainsleyclark/go-mail/mail"
"io"
"net/http"
"strings"
"time"
)
// Requester defines the method used for interacting with
// a Mailable API.
type Requester interface {
// Do accepts a message, url endpoint and optional Headers to POST data
// to a drivers API.
// Returns an error if data could not be marshalled/unmarshalled
// or if the request could not be processed.
Do(ctx context.Context, r *httputil.Request, payload httputil.Payload, responder httputil.Responder) (mail.Response, error)
}
// New creates a new Client with a stdlib http.Client.
func New(client *http.Client) *Client {
if client == nil {
client = &http.Client{
Timeout: Timeout,
}
}
return &Client{
Client: client,
bodyReader: io.ReadAll,
}
}
const (
// Timeout is the amount of time to wait before
// a mail request is cancelled.
Timeout = time.Second * 10
)
// Client defines a http.Client to interact with the mail
// drivers API's. It acts as a reusable helper to send
// data to the drivers endpoints.
type Client struct {
Client *http.Client
bodyReader func(r io.Reader) ([]byte, error)
}
// Do accepts a message, Request and a Payload to POST data
// to a drivers API.
// Logs Curl output if mail.debug is set to true.
//
// Returns an error if data could not be marshalled/unmarshalled
// or if the request could not be processed.
func (c *Client) Do(ctx context.Context, r *httputil.Request, payload httputil.Payload, responder httputil.Responder) (mail.Response, error) {
const op = "Client.Do"
req, err := c.makeRequest(ctx, r, payload)
if err != nil {
return mail.Response{}, err
}
resp, err := c.Client.Do(req)
if err != nil {
return mail.Response{}, &errors.Error{Code: errors.API, Message: "Error doing request", Operation: op, Err: err}
}
defer resp.Body.Close()
response := mail.Response{
StatusCode: resp.StatusCode,
}
buf, err := c.bodyReader(resp.Body)
if err != nil {
return response, &errors.Error{Code: errors.INTERNAL, Message: "Error reading response body", Operation: op, Err: err}
}
response.Body = buf
err = responder.Unmarshal(buf)
if err != nil {
return response, &errors.Error{Code: errors.INVALID, Message: "Error unmarshalling response error", Operation: op, Err: err}
}
err = responder.CheckError(resp, buf)
if err != nil {
return response, &errors.Error{Code: errors.API, Message: "Error performing mail request", Operation: op, Err: err}
}
meta := responder.Meta()
return mail.Response{
StatusCode: resp.StatusCode,
Body: buf,
Headers: resp.Header,
ID: meta.ID,
Message: meta.Message,
}, nil
}
// makeRequest creates a stdlib http.Request.
// Content-Type, BasicAuth and headers are attached to the request.
// Returns an error if the request could not be created.
func (c *Client) makeRequest(ctx context.Context, r *httputil.Request, payload httputil.Payload) (*http.Request, error) {
const op = "Client.MakeRequest"
var body io.Reader
if payload != nil {
b, err := payload.Buffer()
if err != nil {
return nil, err
}
body = b
}
req, err := http.NewRequest(r.Method, r.URL, body)
if err != nil {
return nil, &errors.Error{Code: errors.INVALID, Message: "Error creating http request", Operation: op, Err: err}
}
if mail.Debug {
fmt.Println(c.curlString(req, payload))
}
req = req.WithContext(ctx)
if payload != nil && payload.ContentType() != "" {
req.Header.Add("Content-Type", payload.ContentType())
}
if r.BasicAuthUser != "" && r.BasicAuthPassword != "" {
req.SetBasicAuth(r.BasicAuthUser, r.BasicAuthPassword)
}
for header, value := range r.Headers {
req.Header.Add(header, value)
}
return req, nil
}
// curlString constructs a string used for posting the
// request via Curl.
func (c *Client) curlString(req *http.Request, p httputil.Payload) string {
parts := []string{"curl", "-i", "-X", req.Method, req.URL.String()}
for key, value := range req.Header {
parts = append(parts, fmt.Sprintf("-H \"%s: %s\"", key, value[0]))
}
if p != nil {
for key, value := range p.Values() {
parts = append(parts, fmt.Sprintf(" -F %s='%s'", key, value))
}
}
return strings.Join(parts, " ")
}
================================================
FILE: internal/client/client_test.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package client
import (
"bytes"
"context"
"github.com/ainsleyclark/go-mail/internal/errors"
"github.com/ainsleyclark/go-mail/internal/httputil"
mocks "github.com/ainsleyclark/go-mail/internal/mocks/httputil"
"github.com/ainsleyclark/go-mail/mail"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
)
func TestNewClient(t *testing.T) {
got := New(nil)
assert.NotNil(t, got.bodyReader)
c := &http.Client{}
withClient := New(c)
assert.Equal(t, withClient.Client, c)
}
func TestClient_Do(t *testing.T) {
successHandler := func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, err := w.Write([]byte("buf"))
assert.NoError(t, err)
}
tt := map[string]struct {
input *httputil.Request
handler http.HandlerFunc
responder func(m *mocks.Responder)
bodyReader func(r io.Reader) ([]byte, error)
want interface{}
}{
"Success": {
handler: successHandler,
responder: func(m *mocks.Responder) {
m.On("Unmarshal", mock.Anything).
Return(nil)
m.On("CheckError", mock.Anything, []byte("buf")).
Return(nil)
m.On("Meta", mock.Anything).
Return(httputil.Meta{Message: "message", ID: "10"})
},
bodyReader: io.ReadAll,
want: mail.Response{
StatusCode: http.StatusOK,
Body: []byte("buf"),
Headers: nil,
ID: "10",
Message: "message",
},
},
"Bad Request": {
input: &httputil.Request{URL: "@#@#$$%$"},
want: "Error creating http request",
},
"Do Error": {
input: &httputil.Request{URL: "wrong"},
want: "Error doing request",
},
"Body Read Error": {
handler: successHandler,
bodyReader: func(r io.Reader) ([]byte, error) {
return nil, errors.New("body read error")
},
want: "Error reading response body",
},
"Unmarshal Error": {
handler: successHandler,
responder: func(m *mocks.Responder) {
m.On("Unmarshal", mock.Anything).
Return(errors.New("unmarshal error"))
},
bodyReader: io.ReadAll,
want: "Error unmarshalling response error",
},
"Responder Error": {
handler: successHandler,
responder: func(m *mocks.Responder) {
m.On("Unmarshal", mock.Anything).
Return(nil)
m.On("CheckError", mock.Anything, []byte("buf")).
Return(errors.New("response error"))
},
bodyReader: io.ReadAll,
want: "Error performing mail request",
},
}
for name, test := range tt {
t.Run(name, func(t *testing.T) {
server := httptest.NewServer(test.handler)
defer server.Close()
if test.input == nil {
test.input = &httputil.Request{}
}
if test.input.URL == "" {
test.input.URL = server.URL
}
responder := &mocks.Responder{}
if test.responder != nil {
test.responder(responder)
}
c := Client{
Client: server.Client(),
bodyReader: test.bodyReader,
}
got, err := c.Do(context.Background(), test.input, nil, responder)
if err != nil {
assert.Contains(t, errors.Message(err), test.want)
return
}
want := test.want.(mail.Response)
assert.Equal(t, want.StatusCode, got.StatusCode)
assert.Equal(t, want.Body, got.Body)
assert.NotEmpty(t, got.Headers)
assert.Equal(t, want.ID, got.ID)
assert.Equal(t, want.Message, got.Message)
})
}
}
func TestClient_MakeRequest(t *testing.T) {
uri, err := url.Parse("https://gomail.example.com")
assert.NoError(t, err)
tt := map[string]struct {
request *httputil.Request
payload func(m *mocks.Payload)
want interface{}
}{
"Success": {
&httputil.Request{
Method: http.MethodPost,
URL: "https://gomail.example.com",
BasicAuthUser: "user",
BasicAuthPassword: "password",
Headers: map[string]string{"header": "Value"},
},
func(m *mocks.Payload) {
m.On("Buffer").
Return(&bytes.Buffer{}, nil)
m.On("ContentType").
Return(httputil.JSONContentType)
m.On("Values").
Return(map[string]string{"key": "value"})
},
&http.Request{
Method: http.MethodPost,
URL: uri,
Header: map[string][]string{
"Authorization": {"Basic dXNlcjpwYXNzd29yZA=="},
"Content-Type": {httputil.JSONContentType},
"Header": {"Value"},
},
},
},
"Buffer Error": {
&httputil.Request{},
func(m *mocks.Payload) {
m.On("Buffer").
Return(nil, &errors.Error{Message: "buffer error"})
},
"buffer error",
},
"Request Error": {
&httputil.Request{
URL: "@#@#$$%$",
},
func(m *mocks.Payload) {
m.On("Buffer").
Return(&bytes.Buffer{}, nil)
},
"Error creating http request",
},
}
for name, test := range tt {
t.Run(name, func(t *testing.T) {
defer func() { mail.Debug = false }()
mail.Debug = true
c := New(nil)
mock := &mocks.Payload{}
if test.payload != nil {
test.payload(mock)
}
request, err := c.makeRequest(context.Background(), test.request, mock)
if err != nil {
assert.Contains(t, errors.Message(err), test.want)
return
}
want := test.want.(*http.Request)
assert.Equal(t, want.Method, request.Method)
assert.Equal(t, want.URL, request.URL)
assert.Equal(t, want.Header, request.Header)
})
}
}
func TestClient_CurlString(t *testing.T) {
uri, err := url.Parse("https://gomail.example.com")
assert.NoError(t, err)
req := &http.Request{
Method: http.MethodGet,
URL: uri,
Header: map[string][]string{"header": {"value"}},
}
payload := mocks.Payload{}
payload.On("Values").
Return(map[string]string{"key": "value"})
c := Client{}
got := c.curlString(req, &payload)
want := "curl -i -X GET https://gomail.example.com -H \"header: value\" -F key='value'"
assert.Equal(t, want, got)
}
================================================
FILE: internal/client/util.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package client
// Is2XX returns true if the provided HTTP response code is
// in the range 200-299.
func Is2XX(code int) bool {
if code < 300 && code >= 200 {
return true
}
return false
}
================================================
FILE: internal/client/util_test.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package client
import (
"github.com/stretchr/testify/assert"
"net/http"
"testing"
)
func TestIs2XX(t *testing.T) {
tt := map[string]struct {
input int
want bool
}{
"< 200": {
http.StatusContinue,
false,
},
"200": {
http.StatusOK,
true,
},
"300 >": {
http.StatusMultipleChoices,
false,
},
}
for name, test := range tt {
t.Run(name, func(t *testing.T) {
got := Is2XX(test.input)
assert.Equal(t, test.want, got)
})
}
}
================================================
FILE: internal/errors/errors.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package errors
import (
"bytes"
"errors"
"fmt"
"github.com/ainsleyclark/go-mail/mail"
)
// Application error codes.
const (
// CONFLICT - An action cannot be performed.
CONFLICT = "conflict"
// INTERNAL - Error within Go Mail
INTERNAL = "internal" // Internal error
// INVALID - Validation failed
INVALID = "invalid" // Validation failed
// API - Error in the http request.
API = "api"
// Prefix is the string prefixed to an error message.
Prefix = "go-mail"
// GlobalError is a general message when no error message
// has been found.
GlobalError = "An error has occurred."
)
// Error defines a standard application error.
type Error struct {
Code string `json:"code"`
Message string `json:"message"`
Operation string `json:"operation"`
Err error `json:"error"`
}
// Error returns the string representation of the error
// message.
func (e *Error) Error() string {
var buf bytes.Buffer
// Print the prefix
fmt.Fprintf(&buf, "%s: ", Prefix)
// Print the current operation in our stack, if any.
if e.Operation != "" && mail.Debug {
fmt.Fprintf(&buf, "%s: ", e.Operation)
}
// If wrapping an error, print its Error() message.
// Otherwise, print the error code & message.
if e.Err != nil {
buf.WriteString(e.Err.Error())
} else {
if e.Code != "" {
_, _ = fmt.Fprintf(&buf, "<%s> ", e.Code)
}
buf.WriteString(e.Message)
}
return buf.String()
}
// Code returns the code of the root error, if available.
// Otherwise, returns INTERNAL.
func Code(err error) string {
if err == nil {
return ""
} else if e, ok := err.(*Error); ok && e.Code != "" {
return e.Code
} else if ok && e.Err != nil {
return Code(e.Err)
}
return INTERNAL
}
// Message returns the human-readable message of the error,
// if available. Otherwise, returns a generic error
// message.
func Message(err error) string {
if err == nil {
return ""
} else if e, ok := err.(*Error); ok && e.Message != "" {
return e.Message
} else if ok && e.Err != nil {
return Message(e.Err)
}
return GlobalError
}
// ToError Returns a Go Mail error from input. If The type
// is not of type Error, nil will be returned.
func ToError(err interface{}) *Error {
switch v := err.(type) {
case *Error:
return v
case Error:
return &v
case error:
return &Error{Err: fmt.Errorf(v.Error())}
case string:
return &Error{Err: fmt.Errorf(v)}
default:
return nil
}
}
// New is a wrapper for the stdlib new function.
func New(text string) error {
return errors.New(text)
}
================================================
FILE: internal/errors/errors_test.go
================================================
package errors
import (
"fmt"
"github.com/ainsleyclark/go-mail/mail"
"github.com/stretchr/testify/assert"
"testing"
)
func TestError_Error(t *testing.T) {
tt := map[string]struct {
input *Error
debug bool
want string
}{
"Normal": {
&Error{Code: INTERNAL, Message: "test", Operation: "op", Err: fmt.Errorf("err")},
false,
fmt.Sprintf("%s: err", Prefix),
},
"Debug": {
&Error{Code: INTERNAL, Message: "test", Operation: "op", Err: fmt.Errorf("err")},
true,
fmt.Sprintf("%s: op: err", Prefix),
},
"Nil Operation": {
&Error{Code: INTERNAL, Message: "test", Operation: "", Err: fmt.Errorf("err")},
false,
fmt.Sprintf("%s: err", Prefix),
},
"Nil Err": {
&Error{Code: INTERNAL, Message: "test", Operation: "", Err: nil},
false,
fmt.Sprintf("%s: test", Prefix),
},
}
for name, test := range tt {
t.Run(name, func(t *testing.T) {
defer func() { mail.Debug = false }()
mail.Debug = test.debug
assert.Equal(t, test.want, test.input.Error())
})
}
}
func TestError_Code(t *testing.T) {
tt := map[string]struct {
input error
want string
}{
"Normal": {
&Error{Code: INTERNAL, Message: "test", Operation: "op", Err: fmt.Errorf("err")},
"internal",
},
"Nil Input": {
nil,
"",
},
"Nil Code": {
&Error{Code: "", Message: "test", Operation: "op", Err: fmt.Errorf("err")},
"internal",
},
}
for name, test := range tt {
t.Run(name, func(t *testing.T) {
assert.Equal(t, test.want, Code(test.input))
})
}
}
func Test_Message(t *testing.T) {
tt := map[string]struct {
input error
want string
}{
"Normal": {
&Error{Code: INTERNAL, Message: "test", Operation: "op", Err: fmt.Errorf("err")},
"test",
},
"Nil Input": {
nil,
"",
},
"Nil Message": {
&Error{Code: "", Message: "", Operation: "op", Err: fmt.Errorf("err")},
GlobalError,
},
}
for name, test := range tt {
t.Run(name, func(t *testing.T) {
assert.Equal(t, test.want, Message(test.input))
})
}
}
func TestError_ToError(t *testing.T) {
tt := map[string]struct {
input interface{}
want *Error
}{
"Pointer": {
&Error{Code: INTERNAL, Message: "test", Operation: "op", Err: fmt.Errorf("err")},
&Error{Code: INTERNAL, Message: "test", Operation: "op", Err: fmt.Errorf("err")},
},
"Non Pointer": {
Error{Code: INTERNAL, Message: "test", Operation: "op", Err: fmt.Errorf("err")},
&Error{Code: INTERNAL, Message: "test", Operation: "op", Err: fmt.Errorf("err")},
},
"Error": {
fmt.Errorf("err"),
&Error{Err: fmt.Errorf("err")},
},
"String": {
"err",
&Error{Err: fmt.Errorf("err")},
},
"Default": {
nil,
nil,
},
}
for name, test := range tt {
t.Run(name, func(t *testing.T) {
assert.Equal(t, test.want, ToError(test.input))
})
}
}
func TestNew(t *testing.T) {
want := fmt.Errorf("error")
got := New("error")
assert.Errorf(t, want, got.Error())
}
================================================
FILE: internal/httputil/payload.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package httputil
import (
"bytes"
"encoding/json"
"fmt"
"github.com/ainsleyclark/go-mail/internal/errors"
"io"
"mime/multipart"
)
// Payload defines the methods used for creating HTTP payload
// helper.
type Payload interface {
// Buffer returns the byte buffer used for making the
// HTTP Request
Buffer() (*bytes.Buffer, error)
// ContentType returns the `Content-Type` header used for
// making the HTTP Request
ContentType() string
// Values returns a map of key - value pairs used for testing
// and debugging.
Values() map[string]string
}
const (
// JSONContentType is the Content-Type header for
// JSON payloads.
JSONContentType = "application/json"
)
// JSONData defines the payload for JSON types.
type JSONData struct {
original interface{}
values map[string]interface{}
}
// NewJSONData creates a new JSON Data Payload type.
// It adds a struct type to the JSON Data payload.
// Returns an error if the struct could not be marshalled or unmarshalled.
func NewJSONData(obj interface{}) (*JSONData, error) {
const op = "HTTPUtil.NewJSONData"
buf, err := json.Marshal(obj)
if err != nil {
return nil, &errors.Error{Code: errors.INTERNAL, Message: "Error marshalling payload", Operation: op, Err: err}
}
m := make(map[string]interface{})
err = json.Unmarshal(buf, &m)
if err != nil {
return nil, &errors.Error{Code: errors.INTERNAL, Message: "Error unmarshalling payload", Operation: op, Err: err}
}
return &JSONData{
original: obj,
values: m,
}, nil
}
// Buffer returns the byte buffer for making the request.
func (j *JSONData) Buffer() (*bytes.Buffer, error) {
const op = "JSONData.Buffer"
buf, err := json.Marshal(j.values)
if err != nil {
return nil, &errors.Error{Code: errors.INTERNAL, Message: "Error marshalling values", Operation: op, Err: err}
}
return bytes.NewBuffer(buf), nil
}
// ContentType returns the `Content-Type` header.
func (j *JSONData) ContentType() string {
return JSONContentType
}
// Values returns a map of key - value pairs used for testing
// and debugging.
func (j *JSONData) Values() map[string]string {
m := make(map[string]string)
for key, value := range j.values {
m[key] = fmt.Sprintf("%v", value)
}
return m
}
// FormData defines the payload for URL encoded types.
type FormData struct {
contentType string
values map[string]string
buffers []keyNameBuff
}
// keyNameBuff defines the buffer for multipart attachments.
type keyNameBuff struct {
key string
name string
value []byte
}
// NewFormData creates a new Form Data Payload type.
func NewFormData() *FormData {
return &FormData{}
}
// AddValue adds a key - value string pair to the Payload.
func (f *FormData) AddValue(key, value string) {
if len(f.values) == 0 {
f.values = make(map[string]string)
}
f.values[key] = value
}
// AddBuffer adds a file buffer to the Payload with a filename.
func (f *FormData) AddBuffer(key, fileName string, buff []byte) {
f.buffers = append(f.buffers, keyNameBuff{
key: key,
name: fileName,
value: buff,
})
}
var newWriter = multipart.NewWriter
// Buffer returns the byte buffer for making the request.
func (f *FormData) Buffer() (*bytes.Buffer, error) {
const op = "FormData.Buffer"
data := &bytes.Buffer{}
writer := newWriter(data)
defer writer.Close()
for key, val := range f.values {
if tmp, err := writer.CreateFormField(key); err == nil {
tmp.Write([]byte(val)) // nolint
} else {
return nil, &errors.Error{Code: errors.INTERNAL, Message: "Error creating form field", Operation: op, Err: err}
}
}
for _, buff := range f.buffers {
if tmp, err := writer.CreateFormFile(buff.key, buff.name); err == nil {
r := bytes.NewReader(buff.value)
io.Copy(tmp, r) // nolint
} else {
return nil, &errors.Error{Code: errors.INTERNAL, Message: "Error creating form file", Operation: op, Err: err}
}
}
f.contentType = writer.FormDataContentType()
return data, nil
}
// ContentType returns the `Content-Type` header.
func (f *FormData) ContentType() string {
if f.contentType == "" {
f.Buffer() // nolint
}
return f.contentType
}
// Values returns a map of key - value pairs used for testing
// and debugging.
func (f *FormData) Values() map[string]string {
return f.values
}
================================================
FILE: internal/httputil/payload_test.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package httputil
import (
"github.com/ainsleyclark/go-mail/internal/errors"
"github.com/stretchr/testify/assert"
"io"
"mime/multipart"
"testing"
)
func TestNewJSONData(t *testing.T) {
tt := map[string]struct {
input interface{}
want interface{}
}{
"Success": {
map[string]interface{}{"test": 1},
map[string]interface{}{"test": float64(1)},
},
"Marshal Error": {
map[string]interface{}{"test": make(chan struct{})},
"Error marshalling payload",
},
"Unmarshal Error": {
1,
"Error unmarshalling payload",
},
}
for name, test := range tt {
t.Run(name, func(t *testing.T) {
pl, err := NewJSONData(test.input)
if err != nil {
assert.Contains(t, errors.Message(err), test.want)
return
}
assert.Equal(t, test.want, pl.values)
assert.NotNil(t, pl.original)
})
}
}
func TestJSONData_Buffer(t *testing.T) {
tt := map[string]struct {
input JSONData
want interface{}
}{
"Success": {
JSONData{values: map[string]interface{}{"test": 1}},
`{"test":1}`,
},
"Marshal Error": {
JSONData{values: map[string]interface{}{"test": make(chan struct{})}},
"unsupported type",
},
}
for name, test := range tt {
t.Run(name, func(t *testing.T) {
got, err := test.input.Buffer()
if err != nil {
assert.Contains(t, err.Error(), test.want)
return
}
assert.Equal(t, test.want, got.String())
})
}
}
func TestJsonData_ContentType(t *testing.T) {
pl := JSONData{}
got := pl.ContentType()
assert.Equal(t, JSONContentType, got)
}
func TestJSONData_Values(t *testing.T) {
pl := JSONData{values: map[string]interface{}{"test": 1}}
got := pl.Values()
want := map[string]string{"test": "1"}
assert.Equal(t, want, got)
}
func TestFormData_AddValue(t *testing.T) {
pl := NewFormData()
pl.AddValue("key", "value")
want := map[string]string{"key": "value"}
assert.Equal(t, want, pl.values)
}
func TestFormData_AddBuffer(t *testing.T) {
pl := NewFormData()
pl.AddBuffer("key", "file", []byte("value"))
want := []keyNameBuff{
{key: "key", name: "file", value: []byte("value")},
}
assert.Equal(t, want, pl.buffers)
}
type mockWriterError struct{}
func (m *mockWriterError) Write(p []byte) (n int, err error) {
return 0, errors.New("write error")
}
func TestFormData_Buffer(t *testing.T) {
tt := map[string]struct {
input FormData
writer func(w io.Writer) *multipart.Writer
want interface{}
}{
"Success": {
FormData{
values: map[string]string{
"key": "value",
},
buffers: []keyNameBuff{
{key: "key", name: "file", value: []byte("value")},
},
},
multipart.NewWriter,
"Content-Disposition",
},
"Value Error": {
FormData{
values: map[string]string{"key": "value"},
},
func(w io.Writer) *multipart.Writer {
return multipart.NewWriter(&mockWriterError{})
},
"write error",
},
"Buffer Error": {
FormData{
buffers: []keyNameBuff{
{key: "key", name: "file", value: []byte("value")},
},
},
func(w io.Writer) *multipart.Writer {
return multipart.NewWriter(&mockWriterError{})
},
"write error",
},
}
for name, test := range tt {
t.Run(name, func(t *testing.T) {
orig := newWriter
defer func() { newWriter = orig }()
newWriter = test.writer
got, err := test.input.Buffer()
if err != nil {
assert.Contains(t, err.Error(), test.want)
return
}
assert.Contains(t, got.String(), test.want)
})
}
}
func TestFormData_ContentType(t *testing.T) {
pl := FormData{values: map[string]string{"test": "1"}}
got := pl.ContentType()
want := "multipart/form-data"
assert.Contains(t, got, want)
}
func TestFormData_Values(t *testing.T) {
pl := FormData{values: map[string]string{"test": "1"}}
got := pl.Values()
want := map[string]string{"test": "1"}
assert.Equal(t, want, got)
}
================================================
FILE: internal/httputil/request.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package httputil
// A Request represents an HTTP request received by a server
// or to be sent by a client. It is an extension of the
// std http.Request for Go Mail.
type Request struct {
Method string
URL string
Headers map[string]string
BasicAuthUser string
BasicAuthPassword string
}
// NewHTTPRequest returns a new Request given a method and URL.
func NewHTTPRequest(method, url string) *Request {
return &Request{
Method: method,
URL: url,
}
}
// AddHeader adds the key, value pair to the request headers.
func (r *Request) AddHeader(name, value string) {
if r.Headers == nil {
r.Headers = make(map[string]string)
}
r.Headers[name] = value
}
// SetBasicAuth sets the request's Authorization header to use HTTP
// Basic Authentication with the provided username and password.
//
// With HTTP Basic Authentication the provided username and password
// are not encrypted.
func (r *Request) SetBasicAuth(user, password string) {
r.BasicAuthUser = user
r.BasicAuthPassword = password
}
================================================
FILE: internal/httputil/request_test.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package httputil
import (
"github.com/stretchr/testify/assert"
"net/http"
"testing"
)
func TestNewHTTPRequest(t *testing.T) {
req := NewHTTPRequest(http.MethodGet, "https://gomail.example.com")
assert.Equal(t, http.MethodGet, req.Method)
assert.Equal(t, "https://gomail.example.com", req.URL)
}
func TestRequest_AddHeader(t *testing.T) {
req := NewHTTPRequest(http.MethodGet, "https://gomail.example.com")
req.AddHeader("header", "value")
want := map[string]string{"header": "value"}
assert.Equal(t, want, req.Headers)
}
func TestRequest_SetBasicAuth(t *testing.T) {
req := NewHTTPRequest(http.MethodGet, "https://gomail.example.com")
req.SetBasicAuth("user", "pass")
assert.Equal(t, "user", req.BasicAuthUser)
assert.Equal(t, "pass", req.BasicAuthPassword)
}
================================================
FILE: internal/httputil/response.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package httputil
import "net/http"
// Responder defines the methods used for a response back
// from a mailer's API.
type Responder interface {
Unmarshal(buf []byte) error
CheckError(response *http.Response, buf []byte) error
Meta() Meta
}
// Meta defines the data used for creating a mail.Response.
type Meta struct {
Message string
ID string
}
================================================
FILE: internal/mime/mime.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mime
import (
"bytes"
"net/http"
)
const (
// sniffLength is the amount of bytes to read to
// detect the MIME type.
sniffLength uint32 = 512
)
// DetectBuffer returns the MIME type found from the provided byte slice.
//
// The result is always a valid MIME type, with application/octet-stream
// returned when identification failed.
// Uses http.DetectContentType with a layer for SVG detection.
func DetectBuffer(buf []byte) string {
header := make([]byte, sniffLength)
copy(header, buf)
// Detect for SVGs
// See https://github.com/golang/go/issues/15888
if bytes.Contains(header, []byte("`
a := Attachment{
Filename: "circle.svg",
Bytes: []byte(svg),
}
fmt.Println(a.Mime())
// Output: image/svg+xml
}
func (t *MailTestSuite) TestAttachment_Mime() {
tt := map[string]struct {
input string
want string
}{
"PNG": {
PNGName,
"image/png",
},
"JPG": {
JPGName,
"image/jpeg",
},
"SVG": {
SVGName,
"image/svg+xml",
},
}
for name, test := range tt {
t.Run(name, func() {
a := t.Attachment(test.input)
got := a.Mime()
t.Equal(test.want, got)
})
}
}
func ExampleAttachment_B64() {
svg := `
`
a := Attachment{
Filename: "circle.svg",
Bytes: []byte(svg),
}
fmt.Println(a.B64())
// Output: Cjxzdmcgd2lkdGg9IjEwMCIgaGVpZ2h0PSIxMDAiPgogIDxjaXJjbGUgY3g9IjUwIiBjeT0iNTAiIHI9IjQwIiBzdHJva2U9ImdyZWVuIiBzdHJva2Utd2lkdGg9IjQiIGZpbGw9InllbGxvdyIgLz4KPC9zdmc+
}
func (t *MailTestSuite) TestAttachment_B64() {
a := Attachment{
Bytes: []byte("hello"),
}
got := a.B64()
t.Equal("aGVsbG8=", got)
}
================================================
FILE: mail/config.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mail
import (
"errors"
"net/http"
)
// Config represents the configuration passed when a new
// client is constructed. Dependant on what driver is used,
// different options are required to be present.
type Config struct {
URL string
APIKey string
Domain string
FromAddress string
FromName string
Password string
Port int
Client *http.Client
}
// Validate runs sanity checks of a Config struct.
// This is run before a new client is created
// to ensure there are no invalid API
// calls.
func (c *Config) Validate() error {
if c.FromAddress == "" {
return errors.New("driver requires from address")
}
if c.FromName == "" {
return errors.New("driver requires from name")
}
if c.APIKey == "" {
return errors.New("driver requires api key")
}
return nil
}
================================================
FILE: mail/config_test.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mail
import (
"errors"
"fmt"
)
func ExampleConfig_Validate() {
cfg := Config{}
fmt.Println(cfg.Validate())
// Output: driver requires from address
}
func (t *MailTestSuite) TestConfig_Validate() {
tt := map[string]struct {
input Config
want error
}{
"Success": {
Config{
APIKey: "key",
FromAddress: "hello@test.com",
FromName: "Test",
},
nil,
},
"No From Address": {
Config{
APIKey: "key",
FromName: "Test",
},
errors.New("driver requires from address"),
},
"No From Name": {
Config{
APIKey: "key",
FromAddress: "hello@test.com",
},
errors.New("driver requires from name"),
},
"No Key": {
Config{
FromAddress: "hello@test.com",
FromName: "Test",
},
errors.New("driver requires api key"),
},
}
for name, test := range tt {
t.Run(name, func() {
got := test.input.Validate()
t.Equal(test.want, got)
})
}
}
================================================
FILE: mail/mail.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mail
import "errors"
var (
// Debug - Set true to write the HTTP requests in curl to stdout.
// Additional information will also be displayed in the errors such as
// method operations.
Debug = false
// ErrEmptyBody is returned by Send when there is nobody attached to the
// request.
ErrEmptyBody = errors.New("error, empty body")
)
// Mailer defines the sender for go-mail returning a
// Response or error when an email is sent.
//
// Below is an example of creating and sending a transmission:
//
// cfg := mail.Config{
// URL: "https://api.eu.sparkpost.com",
// APIKey: "my-key",
// FromAddress: "hello@gophers.com",
// FromName: "Gopher",
// }
//
// mailer, err := drivers.NewSparkPost(cfg)
// if err != nil {
// log.Fatalln(err)
// }
//
// tx := &mail.Transmission{
// Recipients: []string{"hello@gophers.com"},
// Subject: "My email",
// HTML: "
Hello from Go Mail!
",
// }
//
// result, err := mailer.Send(tx)
// if err != nil {
// log.Fatalln(err)
// }
//
// fmt.Printf("%+v\n", result)
type Mailer interface {
// Send accepts a mail.Transmission to send an email through a particular
// driver/provider. Transmissions will be validated before sending.
//
// A mail.Response or an error will be returned. In some circumstances
// the body and status code will be attached to the response for debugging.
Send(t *Transmission) (Response, error)
}
================================================
FILE: mail/mail_test.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mail
import (
"github.com/stretchr/testify/suite"
"os"
"path/filepath"
"testing"
)
// MailTestSuite defines the helper used for mail
// testing.
type MailTestSuite struct {
suite.Suite
base string
}
// Assert testing has begun.
func TestMail(t *testing.T) {
suite.Run(t, new(MailTestSuite))
}
// Assigns test base.
func (t *MailTestSuite) SetupSuite() {
wd, err := os.Getwd()
t.NoError(err)
t.base = wd
}
const (
// DataPath defines where the test data resides.
DataPath = "testdata"
// PNGName defines the PNG name for testing.
PNGName = "gopher.png"
// JPGName defines the JPG name for testing.
JPGName = "gopher.jpg"
// SVGName defines the SVG name testing.
SVGName = "gopher.svg"
)
// Returns a PNG attachment for testing.
func (t *MailTestSuite) Attachment(name string) Attachment {
path := filepath.Join(filepath.Dir(t.base), DataPath, name)
file, err := os.ReadFile(path)
if err != nil {
t.Fail("error getting attachment with the path: "+path, err)
}
return Attachment{
Filename: name,
Bytes: file,
}
}
================================================
FILE: mail/response.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mail
import "net/http"
// Response represents the data passed back from a successful transmission.
type Response struct {
StatusCode int // e.g. 200
Body []byte // e.g. {"result: success"}
Headers http.Header // e.g. map[X-Ratelimit-Limit:[600]]
ID string // e.g "100"
Message string // e.g "Email sent successfully"
}
================================================
FILE: mail/transmissions.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mail
import (
"errors"
)
// Transmission represents the JSON structure accepted by
// and returned from the driver's API. Recipients,
// HTML and a subject is required to send the
// email.
type Transmission struct {
Recipients []string
CC []string
BCC []string
Subject string
HTML string
PlainText string
Attachments []Attachment
Headers map[string]string
}
// Validate runs sanity checks of a Transmission struct.
// This is run before any email sending to ensure
// there are no invalid API calls.
func (t *Transmission) Validate() error {
if t == nil {
return errors.New("can't validate a nil transmission")
}
if len(t.Recipients) == 0 {
return errors.New("transmission requires recipients")
}
if t.Subject == "" {
return errors.New("transmission requires a subject")
}
if t.HTML == "" {
return errors.New("transmission requires html content")
}
return nil
}
// HasCC determines if there are any CC recipients
// attached to the transmission.
func (t *Transmission) HasCC() bool {
return len(t.CC) != 0
}
// HasBCC determines if there are any BCC recipients
// attached to the transmission.
func (t *Transmission) HasBCC() bool {
return len(t.BCC) != 0
}
// HasAttachments determines if there are any attachments in
// the transmission.
func (t Transmission) HasAttachments() bool {
return len(t.Attachments) != 0
}
================================================
FILE: mail/transmissions_test.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mail
import (
"errors"
"fmt"
)
func ExampleTransmission_Validate() {
t := Transmission{}
fmt.Println(t.Validate())
// Output: transmission requires recipients
}
func (t *MailTestSuite) TestTransmission_Validate() {
tt := map[string]struct {
input *Transmission
want error
}{
"Success": {
&Transmission{
Recipients: []string{"hello@test.com"},
Subject: "subject",
HTML: "
Hello
",
},
nil,
},
"Nil": {
nil,
errors.New("can't validate a nil transmission"),
},
"No Recipients": {
&Transmission{
HTML: "html",
Subject: "subject",
},
errors.New("transmission requires recipients"),
},
"No Subject": {
&Transmission{
Recipients: []string{"hello@test.com"},
HTML: "html",
},
errors.New("transmission requires a subject"),
},
"No HTML": {
&Transmission{
Recipients: []string{"hello@test.com"},
Subject: "subject",
},
errors.New("transmission requires html content"),
},
}
for name, test := range tt {
t.Run(name, func() {
got := test.input.Validate()
t.Equal(test.want, got)
})
}
}
func ExampleTransmission_HasCC() {
t := Transmission{
CC: []string{"cc@gophers.com"},
}
fmt.Println(t.HasCC())
// Output: true
}
func (t *MailTestSuite) TestConfig_HasCC() {
tt := map[string]struct {
input Transmission
want bool
}{
"With": {
Transmission{CC: []string{"hello@test.com"}},
true,
},
"Without": {
Transmission{},
false,
},
}
for name, test := range tt {
t.Run(name, func() {
got := test.input.HasCC()
t.Equal(test.want, got)
})
}
}
func ExampleTransmission_HasBCC() {
t := Transmission{
BCC: []string{"bcc@gophers.com"},
}
fmt.Println(t.HasBCC())
// Output: true
}
func (t *MailTestSuite) TestConfig_HasBCC() {
tt := map[string]struct {
input Transmission
want bool
}{
"With": {
Transmission{BCC: []string{"hello@test.com"}},
true,
},
"Without": {
Transmission{},
false,
},
}
for name, test := range tt {
t.Run(name, func() {
got := test.input.HasBCC()
t.Equal(test.want, got)
})
}
}
func ExampleTransmission_HasAttachments() {
t := Transmission{
Attachments: []Attachment{
{
Filename: "gopher.svg",
Bytes: []byte("svg"),
},
},
}
fmt.Println(t.HasAttachments())
// Output: true
}
func (t *MailTestSuite) TestTransmission_HasAttachments() {
tt := map[string]struct {
input Transmission
want bool
}{
"Exists": {
Transmission{
Attachments: []Attachment{{Filename: PNGName}},
},
true,
},
"Nil": {
Transmission{},
false,
},
}
for name, test := range tt {
t.Run(name, func() {
got := test.input.HasAttachments()
t.Equal(test.want, got)
})
}
}
================================================
FILE: mocks/client/Requester.go
================================================
// Code generated by mockery v0.0.0-dev. DO NOT EDIT.
package mocks
import (
context "context"
httputil "github.com/ainsleyclark/go-mail/internal/httputil"
mail "github.com/ainsleyclark/go-mail/mail"
mock "github.com/stretchr/testify/mock"
)
// Requester is an autogenerated mock type for the Requester type
type Requester struct {
mock.Mock
}
// Do provides a mock function with given fields: ctx, r, payload, responder
func (_m *Requester) Do(ctx context.Context, r *httputil.Request, payload httputil.Payload, responder httputil.Responder) (mail.Response, error) {
ret := _m.Called(ctx, r, payload, responder)
var r0 mail.Response
if rf, ok := ret.Get(0).(func(context.Context, *httputil.Request, httputil.Payload, httputil.Responder) mail.Response); ok {
r0 = rf(ctx, r, payload, responder)
} else {
r0 = ret.Get(0).(mail.Response)
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, *httputil.Request, httputil.Payload, httputil.Responder) error); ok {
r1 = rf(ctx, r, payload, responder)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
================================================
FILE: mocks/clientold/Requester.go
================================================
// Code generated by mockery v0.0.0-dev. DO NOT EDIT.
package mocks
import (
http "net/http"
mock "github.com/stretchr/testify/mock"
)
// Requester is an autogenerated mock type for the Requester type
type Requester struct {
mock.Mock
}
// Do provides a mock function with given fields: message, url, headers
func (_m *Requester) Do(message interface{}, url string, headers http.Header) ([]byte, *http.Response, error) {
ret := _m.Called(message, url, headers)
var r0 []byte
if rf, ok := ret.Get(0).(func(interface{}, string, http.Header) []byte); ok {
r0 = rf(message, url, headers)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]byte)
}
}
var r1 *http.Response
if rf, ok := ret.Get(1).(func(interface{}, string, http.Header) *http.Response); ok {
r1 = rf(message, url, headers)
} else {
if ret.Get(1) != nil {
r1 = ret.Get(1).(*http.Response)
}
}
var r2 error
if rf, ok := ret.Get(2).(func(interface{}, string, http.Header) error); ok {
r2 = rf(message, url, headers)
} else {
r2 = ret.Error(2)
}
return r0, r1, r2
}
================================================
FILE: mocks/drivers/smtpSendFunc.go
================================================
// Code generated by mockery v0.0.0-dev. DO NOT EDIT.
package mocks
import (
smtp "net/smtp"
mock "github.com/stretchr/testify/mock"
)
// smtpSendFunc is an autogenerated mock type for the smtpSendFunc type
type smtpSendFunc struct {
mock.Mock
}
// Execute provides a mock function with given fields: addr, a, from, to, msg
func (_m *smtpSendFunc) Execute(addr string, a smtp.Auth, from string, to []string, msg []byte) error {
ret := _m.Called(addr, a, from, to, msg)
var r0 error
if rf, ok := ret.Get(0).(func(string, smtp.Auth, string, []string, []byte) error); ok {
r0 = rf(addr, a, from, to, msg)
} else {
r0 = ret.Error(0)
}
return r0
}
================================================
FILE: mocks/httputil/Payload.go
================================================
// Code generated by mockery v0.0.0-dev. DO NOT EDIT.
package mocks
import (
bytes "bytes"
mock "github.com/stretchr/testify/mock"
)
// Payload is an autogenerated mock type for the Payload type
type Payload struct {
mock.Mock
}
// Buffer provides a mock function with given fields:
func (_m *Payload) Buffer() (*bytes.Buffer, error) {
ret := _m.Called()
var r0 *bytes.Buffer
if rf, ok := ret.Get(0).(func() *bytes.Buffer); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*bytes.Buffer)
}
}
var r1 error
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// ContentType provides a mock function with given fields:
func (_m *Payload) ContentType() string {
ret := _m.Called()
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// Values provides a mock function with given fields:
func (_m *Payload) Values() map[string]string {
ret := _m.Called()
var r0 map[string]string
if rf, ok := ret.Get(0).(func() map[string]string); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(map[string]string)
}
}
return r0
}
================================================
FILE: mocks/httputil/Responder.go
================================================
// Code generated by mockery v0.0.0-dev. DO NOT EDIT.
package mocks
import (
http "net/http"
httputil "github.com/ainsleyclark/go-mail/internal/httputil"
mock "github.com/stretchr/testify/mock"
)
// Responder is an autogenerated mock type for the Responder type
type Responder struct {
mock.Mock
}
// CheckError provides a mock function with given fields: response, buf
func (_m *Responder) CheckError(response *http.Response, buf []byte) error {
ret := _m.Called(response, buf)
var r0 error
if rf, ok := ret.Get(0).(func(*http.Response, []byte) error); ok {
r0 = rf(response, buf)
} else {
r0 = ret.Error(0)
}
return r0
}
// Meta provides a mock function with given fields:
func (_m *Responder) Meta() httputil.Meta {
ret := _m.Called()
var r0 httputil.Meta
if rf, ok := ret.Get(0).(func() httputil.Meta); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(httputil.Meta)
}
return r0
}
// Unmarshal provides a mock function with given fields: buf
func (_m *Responder) Unmarshal(buf []byte) error {
ret := _m.Called(buf)
var r0 error
if rf, ok := ret.Get(0).(func([]byte) error); ok {
r0 = rf(buf)
} else {
r0 = ret.Error(0)
}
return r0
}
================================================
FILE: mocks/mail/Mailer.go
================================================
// Code generated by mockery v0.0.0-dev. DO NOT EDIT.
package mocks
import (
mail "github.com/ainsleyclark/go-mail/mail"
mock "github.com/stretchr/testify/mock"
)
// Mailer is an autogenerated mock type for the Mailer type
type Mailer struct {
mock.Mock
}
// Send provides a mock function with given fields: t
func (_m *Mailer) Send(t *mail.Transmission) (mail.Response, error) {
ret := _m.Called(t)
var r0 mail.Response
if rf, ok := ret.Get(0).(func(*mail.Transmission) mail.Response); ok {
r0 = rf(t)
} else {
r0 = ret.Get(0).(mail.Response)
}
var r1 error
if rf, ok := ret.Get(1).(func(*mail.Transmission) error); ok {
r1 = rf(t)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
================================================
FILE: tests/mail_test.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mail
import (
"fmt"
"github.com/ainsleyclark/go-mail/mail"
"github.com/joho/godotenv"
"github.com/stretchr/testify/assert"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
)
const (
// DataPath defines where the test data resides.
DataPath = "testdata"
// PNGName defines the PNG name for testing.
PNGName = "gopher.png"
)
// Load the Env variables for testing.
func LoadEnv(t *testing.T) {
t.Helper()
wd, err := os.Getwd()
assert.NoError(t, err)
path := filepath.Join(filepath.Dir(wd), "/.env")
err = godotenv.Load(path)
if err != nil {
fmt.Println(err)
fmt.Printf("Error loading .env file with path: %s, using system defaults.\n", path)
}
}
// Returns a dummy transition for testing with an
// attachment.
func GetTransmission(t *testing.T) *mail.Transmission {
t.Helper()
wd, err := os.Getwd()
assert.NoError(t, err)
path := filepath.Join(filepath.Dir(wd), DataPath, PNGName)
file, err := os.ReadFile(path)
if err != nil {
t.Fatal("Error getting attachment with the path: "+path, err)
}
return &mail.Transmission{
Recipients: strings.Split(os.Getenv("EMAIL_TO"), ","),
CC: strings.Split(os.Getenv("EMAIL_CC"), ","),
BCC: strings.Split(os.Getenv("EMAIL_BCC"), ","),
Subject: "Test - Go Mail",
HTML: "
Hello from Go Mail!
",
PlainText: "Hello from Go Mail!",
Headers: map[string]string{
"X-Go-Mail": "Test",
},
Attachments: []mail.Attachment{
{
Filename: "gopher.png",
Bytes: file,
},
},
}
}
// UtilTestSend is a helper function for performing live mailing
// tests for the drivers.
func UtilTestSend(t *testing.T, fn func(cfg mail.Config) (mail.Mailer, error), cfg mail.Config, driver string) {
t.Helper()
tx := GetTransmission(t)
mailer, err := fn(cfg)
if err != nil {
t.Fatal("Error creating client: " + err.Error())
}
result, err := mailer.Send(tx)
if err != nil {
t.Fatalf("Error sending %s email: %s", strings.Title(driver), err.Error()) //nolint
}
// Print for sanity
fmt.Println(string(result.Body))
assert.InDelta(t, result.StatusCode, http.StatusOK, 299)
assert.NotEmpty(t, result.Message)
}
================================================
FILE: tests/mailgun_test.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mail
import (
"github.com/ainsleyclark/go-mail/drivers"
"github.com/ainsleyclark/go-mail/mail"
"os"
"testing"
)
func Test_MailGun(t *testing.T) {
LoadEnv(t)
cfg := mail.Config{
URL: os.Getenv("MAILGUN_URL"),
APIKey: os.Getenv("MAILGUN_API_KEY"),
FromAddress: os.Getenv("MAILGUN_FROM_ADDRESS"),
FromName: os.Getenv("MAILGUN_FROM_NAME"),
Domain: os.Getenv("MAILGUN_DOMAIN"),
}
UtilTestSend(t, drivers.NewMailgun, cfg, "MailGun")
}
================================================
FILE: tests/postal_test.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mail
import (
"github.com/ainsleyclark/go-mail/drivers"
"github.com/ainsleyclark/go-mail/mail"
"os"
"testing"
)
func Test_Postal(t *testing.T) {
LoadEnv(t)
cfg := mail.Config{
URL: os.Getenv("POSTAL_URL"),
APIKey: os.Getenv("POSTAL_API_KEY"),
FromAddress: os.Getenv("POSTAL_FROM_ADDRESS"),
FromName: os.Getenv("POSTAL_FROM_NAME"),
}
UtilTestSend(t, drivers.NewPostal, cfg, "Postal")
}
================================================
FILE: tests/postmark_test.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mail
import (
"github.com/ainsleyclark/go-mail/drivers"
"github.com/ainsleyclark/go-mail/mail"
"os"
"testing"
)
func Test_Postmark(t *testing.T) {
LoadEnv(t)
cfg := mail.Config{
APIKey: os.Getenv("POSTMARK_API_KEY"),
FromAddress: os.Getenv("POSTMARK_FROM_ADDRESS"),
FromName: os.Getenv("POSTMARK_FROM_NAME"),
}
UtilTestSend(t, drivers.NewPostmark, cfg, "Postmark")
}
================================================
FILE: tests/sendgrid_test.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mail
import (
"github.com/ainsleyclark/go-mail/drivers"
"github.com/ainsleyclark/go-mail/mail"
"os"
"testing"
)
func Test_SendGrid(t *testing.T) {
LoadEnv(t)
cfg := mail.Config{
APIKey: os.Getenv("SENDGRID_API_KEY"),
FromAddress: os.Getenv("SENDGRID_FROM_ADDRESS"),
FromName: os.Getenv("SENDGRID_FROM_NAME"),
}
UtilTestSend(t, drivers.NewSendGrid, cfg, "SendGrid")
}
================================================
FILE: tests/smtp_test.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mail
import (
"github.com/ainsleyclark/go-mail/drivers"
"github.com/ainsleyclark/go-mail/mail"
"os"
"strconv"
"testing"
)
func Test_SMTP(t *testing.T) {
LoadEnv(t)
port, err := strconv.Atoi(os.Getenv("SMTP_PORT"))
if err != nil {
t.Fatal("Error parsing SMTP port")
}
cfg := mail.Config{
URL: os.Getenv("SMTP_URL"),
FromAddress: os.Getenv("SMTP_FROM_ADDRESS"),
FromName: os.Getenv("SMTP_FROM_NAME"),
Password: os.Getenv("SMTP_PASSWORD"),
Port: port,
}
UtilTestSend(t, drivers.NewSMTP, cfg, "SMTP")
}
================================================
FILE: tests/sparkpost_test.go
================================================
// Copyright 2022 Ainsley Clark. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package mail
import (
"github.com/ainsleyclark/go-mail/drivers"
"github.com/ainsleyclark/go-mail/mail"
"os"
"testing"
)
func Test_SparkPost(t *testing.T) {
LoadEnv(t)
cfg := mail.Config{
URL: os.Getenv("SPARKPOST_URL"),
APIKey: os.Getenv("SPARKPOST_API_KEY"),
FromAddress: os.Getenv("SPARKPOST_FROM_ADDRESS"),
FromName: os.Getenv("SPARKPOST_FROM_NAME"),
}
UtilTestSend(t, drivers.NewSparkPost, cfg, "SparkPost")
}