make Mailer an interface
This commit is contained in:
parent
dd2412463b
commit
08c09ffee7
10 changed files with 234 additions and 227 deletions
|
|
@ -33,21 +33,16 @@ type AuthStorer interface {
|
||||||
PurgeExpiredToken() error
|
PurgeExpiredToken() error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mailer defines methods to send account emails.
|
|
||||||
type Mailer interface {
|
|
||||||
LoginToken(name, email string, c email.ContentLoginToken) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resource implements passwordless account authentication against a database.
|
// Resource implements passwordless account authentication against a database.
|
||||||
type Resource struct {
|
type Resource struct {
|
||||||
LoginAuth *LoginTokenAuth
|
LoginAuth *LoginTokenAuth
|
||||||
TokenAuth *jwt.TokenAuth
|
TokenAuth *jwt.TokenAuth
|
||||||
Store AuthStorer
|
Store AuthStorer
|
||||||
Mailer Mailer
|
Mailer email.Mailer
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewResource returns a configured authentication resource.
|
// NewResource returns a configured authentication resource.
|
||||||
func NewResource(authStore AuthStorer, mailer Mailer) (*Resource, error) {
|
func NewResource(authStore AuthStorer, mailer email.Mailer) (*Resource, error) {
|
||||||
loginAuth, err := NewLoginTokenAuth()
|
loginAuth, err := NewLoginTokenAuth()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -126,14 +121,17 @@ func (rs *Resource) login(w http.ResponseWriter, r *http.Request) {
|
||||||
tokenURL, _ := url.JoinPath(rs.LoginAuth.loginURL, lt.Token)
|
tokenURL, _ := url.JoinPath(rs.LoginAuth.loginURL, lt.Token)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
content := email.ContentLoginToken{
|
content := ContentLoginToken{
|
||||||
Email: acc.Email,
|
Email: acc.Email,
|
||||||
Name: acc.Name,
|
Name: acc.Name,
|
||||||
URL: tokenURL,
|
URL: tokenURL,
|
||||||
Token: lt.Token,
|
Token: lt.Token,
|
||||||
Expiry: lt.Expiry,
|
Expiry: lt.Expiry,
|
||||||
}
|
}
|
||||||
if err := rs.Mailer.LoginToken(acc.Name, acc.Email, content); err != nil {
|
|
||||||
|
msg := LoginTokenEmail(acc.Name, acc.Email, content)
|
||||||
|
|
||||||
|
if err := rs.Mailer.Send(msg); err != nil {
|
||||||
log(r).WithField("module", "email").Error(err)
|
log(r).WithField("module", "email").Error(err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import (
|
||||||
var (
|
var (
|
||||||
auth *Resource
|
auth *Resource
|
||||||
authStore MockAuthStore
|
authStore MockAuthStore
|
||||||
mailer email.MockMailer
|
mailer *email.MockMailer
|
||||||
ts *httptest.Server
|
ts *httptest.Server
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -35,7 +35,9 @@ func TestMain(m *testing.M) {
|
||||||
viper.SetDefault("log_level", "error")
|
viper.SetDefault("log_level", "error")
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
auth, err = NewResource(&authStore, &mailer)
|
|
||||||
|
mailer = email.NewMockMailer()
|
||||||
|
auth, err = NewResource(&authStore, mailer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|
@ -72,10 +74,6 @@ func TestAuthResource_login(t *testing.T) {
|
||||||
return &a, err
|
return &a, err
|
||||||
}
|
}
|
||||||
|
|
||||||
mailer.LoginTokenFn = func(n, e string, c email.ContentLoginToken) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
email string
|
email string
|
||||||
|
|
@ -105,11 +103,11 @@ func TestAuthResource_login(t *testing.T) {
|
||||||
if tc.err == ErrInvalidLogin && authStore.GetAccountByEmailInvoked {
|
if tc.err == ErrInvalidLogin && authStore.GetAccountByEmailInvoked {
|
||||||
t.Error("GetByLoginToken invoked for invalid email")
|
t.Error("GetByLoginToken invoked for invalid email")
|
||||||
}
|
}
|
||||||
if tc.err == nil && !mailer.LoginTokenInvoked {
|
if tc.err == nil && !mailer.SendInvoked {
|
||||||
t.Error("emailService.LoginToken not invoked")
|
t.Error("emailService.Send not invoked")
|
||||||
}
|
}
|
||||||
authStore.GetAccountByEmailInvoked = false
|
authStore.GetAccountByEmailInvoked = false
|
||||||
mailer.LoginTokenInvoked = false
|
mailer.SendInvoked = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
28
auth/pwdless/emails.go
Normal file
28
auth/pwdless/emails.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package pwdless
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dhax/go-base/email"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContentLoginToken defines content for login token email template.
|
||||||
|
type ContentLoginToken struct {
|
||||||
|
Email string
|
||||||
|
Name string
|
||||||
|
URL string
|
||||||
|
Token string
|
||||||
|
Expiry time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginTokenEmail creates and sends a login token email with provided template content.
|
||||||
|
func LoginTokenEmail(name, address string, content ContentLoginToken) email.Message {
|
||||||
|
return email.Message{
|
||||||
|
From: email.NewEmail(os.Getenv("EMAIL_FROM_NAME"), os.Getenv("EMAIL_FROM_ADDRESS")),
|
||||||
|
To: email.NewEmail(name, address),
|
||||||
|
Subject: "Login Token",
|
||||||
|
Template: "loginToken",
|
||||||
|
Content: content,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,9 +9,7 @@ import (
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var errTokenNotFound = errors.New("login token not found")
|
||||||
errTokenNotFound = errors.New("login token not found")
|
|
||||||
)
|
|
||||||
|
|
||||||
// LoginToken is an in-memory saved token referencing an account ID and an expiry date.
|
// LoginToken is an in-memory saved token referencing an account ID and an expiry date.
|
||||||
type LoginToken struct {
|
type LoginToken struct {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ services:
|
||||||
#AUTH_JWT_EXPIRY: 1h
|
#AUTH_JWT_EXPIRY: 1h
|
||||||
#AUTH_JWT_REFRESH_EXPIRY: 72h
|
#AUTH_JWT_REFRESH_EXPIRY: 72h
|
||||||
#AUTH_JWT_SECRET: my secret
|
#AUTH_JWT_SECRET: my secret
|
||||||
#EMAIL_FROM_ADDRESS: go-base
|
#EMAIL_FROM_ADDRESS: go-base@example.com
|
||||||
#EMAIL_FROM_NAME: Go Base
|
#EMAIL_FROM_NAME: Go Base
|
||||||
#EMAIL_SMTP_HOST:
|
#EMAIL_SMTP_HOST:
|
||||||
#EMAIL_SMTP_PORT: 465
|
#EMAIL_SMTP_PORT: 465
|
||||||
|
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
package email
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// ContentLoginToken defines content for login token email template.
|
|
||||||
type ContentLoginToken struct {
|
|
||||||
Email string
|
|
||||||
Name string
|
|
||||||
URL string
|
|
||||||
Token string
|
|
||||||
Expiry time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoginToken creates and sends a login token email with provided template content.
|
|
||||||
func (m *Mailer) LoginToken(name, address string, content ContentLoginToken) error {
|
|
||||||
msg := &message{
|
|
||||||
from: m.from,
|
|
||||||
to: NewEmail(name, address),
|
|
||||||
subject: "Login Token",
|
|
||||||
template: "loginToken",
|
|
||||||
content: content,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := msg.parse(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return m.Send(msg)
|
|
||||||
}
|
|
||||||
170
email/email.go
170
email/email.go
|
|
@ -1,170 +0,0 @@
|
||||||
// Package email provides email sending functionality.
|
|
||||||
package email
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"fmt"
|
|
||||||
"html/template"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-mail/mail"
|
|
||||||
"github.com/jaytaylor/html2text"
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
"github.com/vanng822/go-premailer/premailer"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
debug bool
|
|
||||||
templates *template.Template
|
|
||||||
)
|
|
||||||
|
|
||||||
// Mailer is a SMTP mailer.
|
|
||||||
type Mailer struct {
|
|
||||||
client *mail.Dialer
|
|
||||||
from Email
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMailer returns a configured SMTP Mailer.
|
|
||||||
func NewMailer() (*Mailer, error) {
|
|
||||||
if err := parseTemplates(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
smtp := struct {
|
|
||||||
Host string
|
|
||||||
Port int
|
|
||||||
User string
|
|
||||||
Password string
|
|
||||||
}{
|
|
||||||
viper.GetString("email_smtp_host"),
|
|
||||||
viper.GetInt("email_smtp_port"),
|
|
||||||
viper.GetString("email_smtp_user"),
|
|
||||||
viper.GetString("email_smtp_password"),
|
|
||||||
}
|
|
||||||
|
|
||||||
s := &Mailer{
|
|
||||||
client: mail.NewDialer(smtp.Host, smtp.Port, smtp.User, smtp.Password),
|
|
||||||
from: NewEmail(viper.GetString("email_from_name"), viper.GetString("email_from_address")),
|
|
||||||
}
|
|
||||||
|
|
||||||
if smtp.Host == "" {
|
|
||||||
log.Println("SMTP host not set => printing emails to stdout")
|
|
||||||
debug = true
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
d, err := s.client.Dial()
|
|
||||||
if err == nil {
|
|
||||||
d.Close()
|
|
||||||
return s, nil
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send sends the mail via smtp.
|
|
||||||
func (m *Mailer) Send(email *message) error {
|
|
||||||
if debug {
|
|
||||||
log.Println("To:", email.to.Address)
|
|
||||||
log.Println("Subject:", email.subject)
|
|
||||||
log.Println(email.text)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
msg := mail.NewMessage()
|
|
||||||
msg.SetAddressHeader("From", email.from.Address, email.from.Name)
|
|
||||||
msg.SetAddressHeader("To", email.to.Address, email.to.Name)
|
|
||||||
msg.SetHeader("Subject", email.subject)
|
|
||||||
msg.SetBody("text/plain", email.text)
|
|
||||||
msg.AddAlternative("text/html", email.html)
|
|
||||||
|
|
||||||
return m.client.DialAndSend(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// message struct holds all parts of a specific email message.
|
|
||||||
type message struct {
|
|
||||||
from Email
|
|
||||||
to Email
|
|
||||||
subject string
|
|
||||||
template string
|
|
||||||
content interface{}
|
|
||||||
html string
|
|
||||||
text string
|
|
||||||
}
|
|
||||||
|
|
||||||
// parse parses the corrsponding template and content
|
|
||||||
func (m *message) parse() error {
|
|
||||||
buf := new(bytes.Buffer)
|
|
||||||
if err := templates.ExecuteTemplate(buf, m.template, m.content); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
prem, err := premailer.NewPremailerFromString(buf.String(), premailer.NewOptions())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
html, err := prem.Transform()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
m.html = html
|
|
||||||
|
|
||||||
text, err := html2text.FromString(html, html2text.Options{PrettyTables: true})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
m.text = text
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Email struct holds email address and recipient name.
|
|
||||||
type Email struct {
|
|
||||||
Name string
|
|
||||||
Address string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewEmail returns an email address.
|
|
||||||
func NewEmail(name string, address string) Email {
|
|
||||||
return Email{
|
|
||||||
Name: name,
|
|
||||||
Address: address,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseTemplates() error {
|
|
||||||
templates = template.New("").Funcs(fMap)
|
|
||||||
return filepath.Walk("./templates", func(path string, info os.FileInfo, err error) error {
|
|
||||||
if strings.Contains(path, ".html") {
|
|
||||||
_, err = templates.ParseFiles(path)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var fMap = template.FuncMap{
|
|
||||||
"formatAsDate": formatAsDate,
|
|
||||||
"formatAsDuration": formatAsDuration,
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatAsDate(t time.Time) string {
|
|
||||||
year, month, day := t.Date()
|
|
||||||
return fmt.Sprintf("%d.%d.%d", day, month, year)
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatAsDuration(t time.Time) string {
|
|
||||||
dur := time.Until(t)
|
|
||||||
hours := int(dur.Hours())
|
|
||||||
mins := int(dur.Minutes())
|
|
||||||
|
|
||||||
v := ""
|
|
||||||
if hours != 0 {
|
|
||||||
v += strconv.Itoa(hours) + " hours and "
|
|
||||||
}
|
|
||||||
v += strconv.Itoa(mins) + " minutes"
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
106
email/mailer.go
Normal file
106
email/mailer.go
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
// Package email provides email sending functionality.
|
||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jaytaylor/html2text"
|
||||||
|
"github.com/vanng822/go-premailer/premailer"
|
||||||
|
)
|
||||||
|
|
||||||
|
var templates *template.Template
|
||||||
|
|
||||||
|
type Mailer interface {
|
||||||
|
Send(Message) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message struct holds all parts of a specific email Message.
|
||||||
|
type Message struct {
|
||||||
|
From Email
|
||||||
|
To Email
|
||||||
|
Subject string
|
||||||
|
Template string
|
||||||
|
Content any
|
||||||
|
html string
|
||||||
|
text string
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse parses the corrsponding template and content
|
||||||
|
func (m *Message) parse() error {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
if err := templates.ExecuteTemplate(buf, m.Template, m.Content); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
prem, err := premailer.NewPremailerFromString(buf.String(), premailer.NewOptions())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
html, err := prem.Transform()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.html = html
|
||||||
|
|
||||||
|
text, err := html2text.FromString(html, html2text.Options{PrettyTables: true})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.text = text
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email struct holds email address and recipient name.
|
||||||
|
type Email struct {
|
||||||
|
Name string
|
||||||
|
Address string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEmail returns an email address.
|
||||||
|
func NewEmail(name string, address string) Email {
|
||||||
|
return Email{
|
||||||
|
Name: name,
|
||||||
|
Address: address,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTemplates() error {
|
||||||
|
templates = template.New("").Funcs(fMap)
|
||||||
|
return filepath.Walk("./templates", func(path string, info os.FileInfo, err error) error {
|
||||||
|
if strings.Contains(path, ".html") {
|
||||||
|
_, err = templates.ParseFiles(path)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var fMap = template.FuncMap{
|
||||||
|
"formatAsDate": formatAsDate,
|
||||||
|
"formatAsDuration": formatAsDuration,
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatAsDate(t time.Time) string {
|
||||||
|
year, month, day := t.Date()
|
||||||
|
return fmt.Sprintf("%d.%d.%d", day, month, year)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatAsDuration(t time.Time) string {
|
||||||
|
dur := time.Until(t)
|
||||||
|
hours := int(dur.Hours())
|
||||||
|
mins := int(dur.Minutes())
|
||||||
|
|
||||||
|
v := ""
|
||||||
|
if hours != 0 {
|
||||||
|
v += strconv.Itoa(hours) + " hours and "
|
||||||
|
}
|
||||||
|
v += strconv.Itoa(mins) + " minutes"
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
@ -1,13 +1,28 @@
|
||||||
package email
|
package email
|
||||||
|
|
||||||
|
import "log"
|
||||||
|
|
||||||
// MockMailer is a mock Mailer
|
// MockMailer is a mock Mailer
|
||||||
type MockMailer struct {
|
type MockMailer struct {
|
||||||
LoginTokenFn func(name, email string, c ContentLoginToken) error
|
SendFn func(m Message) error
|
||||||
LoginTokenInvoked bool
|
SendInvoked bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoginToken is a mock for LoginToken
|
func logMessage(m Message) {
|
||||||
func (s *MockMailer) LoginToken(n, e string, c ContentLoginToken) error {
|
log.Printf("MockMailer email sent:\nSubject: %s\nTo: %s <%s>\nContext: %#v\n", m.Subject, m.To.Name, m.To.Address, m.Content)
|
||||||
s.LoginTokenInvoked = true
|
}
|
||||||
return s.LoginTokenFn(n, e, c)
|
|
||||||
|
func NewMockMailer() *MockMailer {
|
||||||
|
log.Println("ATTENTION: SMTP Mailer not configured => printing emails to stdout")
|
||||||
|
return &MockMailer{
|
||||||
|
SendFn: func(m Message) error {
|
||||||
|
logMessage(m)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MockMailer) Send(m Message) error {
|
||||||
|
s.SendInvoked = true
|
||||||
|
return s.SendFn(m)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
63
email/smtp.go
Normal file
63
email/smtp.go
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-mail/mail"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SMTPMailer is a SMTP mailer.
|
||||||
|
type SMTPMailer struct {
|
||||||
|
client *mail.Dialer
|
||||||
|
from Email
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMailer returns a configured SMTP Mailer.
|
||||||
|
func NewMailer() (Mailer, error) {
|
||||||
|
if err := parseTemplates(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
smtp := struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
}{
|
||||||
|
viper.GetString("email_smtp_host"),
|
||||||
|
viper.GetInt("email_smtp_port"),
|
||||||
|
viper.GetString("email_smtp_user"),
|
||||||
|
viper.GetString("email_smtp_password"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if smtp.Host == "" {
|
||||||
|
return NewMockMailer(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &SMTPMailer{
|
||||||
|
client: mail.NewDialer(smtp.Host, smtp.Port, smtp.User, smtp.Password),
|
||||||
|
from: NewEmail(viper.GetString("email_from_name"), viper.GetString("email_from_address")),
|
||||||
|
}
|
||||||
|
|
||||||
|
d, err := s.client.Dial()
|
||||||
|
if err == nil {
|
||||||
|
d.Close()
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sends the mail via smtp.
|
||||||
|
func (m *SMTPMailer) Send(email Message) error {
|
||||||
|
if err := email.parse(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := mail.NewMessage()
|
||||||
|
msg.SetAddressHeader("From", email.From.Address, email.From.Name)
|
||||||
|
msg.SetAddressHeader("To", email.To.Address, email.To.Name)
|
||||||
|
msg.SetHeader("Subject", email.Subject)
|
||||||
|
msg.SetBody("text/plain", email.text)
|
||||||
|
msg.AddAlternative("text/html", email.html)
|
||||||
|
|
||||||
|
return m.client.DialAndSend(msg)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue