From 4a7b0112efe9509b15551da68ccb708549c9d645 Mon Sep 17 00:00:00 2001 From: jordi fita mas Date: Tue, 13 Feb 2024 05:20:35 +0100 Subject: [PATCH] Send an email on notification of success payment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit To send the actual mail with sendmail, i have stolen the code from go-mail[0] and removed everything i did not need. This is because there is no Go package to send email in Debian 12, and this was easier than to build the DEB for go-mail. Once i have the time…. [0]: https://go-mail.dev/ --- Makefile | 2 +- pkg/mail/b64linebreaker.go | 70 +++++ pkg/mail/encoding.go | 61 +++++ pkg/mail/header.go | 54 ++++ pkg/mail/msg.go | 362 +++++++++++++++++++++++++ pkg/mail/msgwrite.go | 282 +++++++++++++++++++ pkg/mail/part.go | 18 ++ pkg/mail/random.go | 75 +++++ pkg/payment/public.go | 115 +++++++- pkg/template/render.go | 4 +- po/ca.po | 69 ++++- po/es.po | 69 ++++- po/fr.po | 69 ++++- web/templates/mail/payment/body.gohtml | 75 +++++ web/templates/mail/payment/body.gotxt | 18 ++ 15 files changed, 1330 insertions(+), 13 deletions(-) create mode 100644 pkg/mail/b64linebreaker.go create mode 100644 pkg/mail/encoding.go create mode 100644 pkg/mail/header.go create mode 100644 pkg/mail/msg.go create mode 100644 pkg/mail/msgwrite.go create mode 100644 pkg/mail/part.go create mode 100644 pkg/mail/random.go create mode 100644 web/templates/mail/payment/body.gohtml create mode 100644 web/templates/mail/payment/body.gotxt diff --git a/Makefile b/Makefile index 81c0ca2..5f18346 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -HTML_FILES := $(shell find web -name *.gohtml -o -name *.svg) +HTML_FILES := $(shell find web -name *.gohtml -o -name *.svg -o -name *.gotxt) GO_FILES := $(shell find . -name *.go) DEFAULT_DOMAIN = camper POT_FILE = po/$(DEFAULT_DOMAIN).pot diff --git a/pkg/mail/b64linebreaker.go b/pkg/mail/b64linebreaker.go new file mode 100644 index 0000000..d58208c --- /dev/null +++ b/pkg/mail/b64linebreaker.go @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2022-2023 The go-mail Authors +// +// SPDX-License-Identifier: MIT + +package mail + +import ( + "fmt" + "io" +) + +// ErrNoOutWriter is an error message that should be used if a Base64LineBreaker has no out io.Writer set +const ErrNoOutWriter = "no io.Writer set for Base64LineBreaker" + +// Base64LineBreaker is an io.WriteCloser that writes Base64 encoded data streams +// with line breaks at a given line length +type Base64LineBreaker struct { + line [MaxBodyLength]byte + used int + out io.Writer +} + +var nl = []byte(SingleNewLine) + +// Write writes the data stream and inserts a SingleNewLine when the maximum +// line length is reached +func (l *Base64LineBreaker) Write(b []byte) (n int, err error) { + if l.out == nil { + err = fmt.Errorf(ErrNoOutWriter) + return + } + if l.used+len(b) < MaxBodyLength { + copy(l.line[l.used:], b) + l.used += len(b) + return len(b), nil + } + + n, err = l.out.Write(l.line[0:l.used]) + if err != nil { + return + } + excess := MaxBodyLength - l.used + l.used = 0 + + n, err = l.out.Write(b[0:excess]) + if err != nil { + return + } + + n, err = l.out.Write(nl) + if err != nil { + return + } + + return l.Write(b[excess:]) +} + +// Close closes the Base64LineBreaker and writes any access data that is still +// unwritten in memory +func (l *Base64LineBreaker) Close() (err error) { + if l.used > 0 { + _, err = l.out.Write(l.line[0:l.used]) + if err != nil { + return + } + _, err = l.out.Write(nl) + } + + return +} diff --git a/pkg/mail/encoding.go b/pkg/mail/encoding.go new file mode 100644 index 0000000..36e43b7 --- /dev/null +++ b/pkg/mail/encoding.go @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2022-2023 The go-mail Authors +// +// SPDX-License-Identifier: MIT + +package mail + +// Charset represents a character set for the encoding +type Charset string + +// ContentType represents a content type for the Msg +type ContentType string + +// Encoding represents a MIME encoding scheme like quoted-printable or Base64. +type Encoding string + +// MIMEVersion represents the MIME version for the mail +type MIMEVersion string + +// MIMEType represents the MIME type for the mail +type MIMEType string + +// List of supported encodings +const ( + // EncodingB64 represents the Base64 encoding as specified in RFC 2045. + EncodingB64 Encoding = "base64" + + // EncodingQP represents the "quoted-printable" encoding as specified in RFC 2045. + EncodingQP Encoding = "quoted-printable" + + // NoEncoding avoids any character encoding (except of the mail headers) + NoEncoding Encoding = "8bit" +) + +const ( + // CharsetUTF8 represents the "UTF-8" charset + CharsetUTF8 Charset = "UTF-8" +) + +const ( + // Mime10 is the MIME Version 1.0 + Mime10 MIMEVersion = "1.0" +) + +const ( + TypeTextPlain ContentType = "text/plain" + TypeTextHTML ContentType = "text/html" +) + +const ( + MIMEAlternative MIMEType = "alternative" +) + +// String is a standard method to convert an Encoding into a printable format +func (e Encoding) String() string { + return string(e) +} + +// String is a standard method to convert a Charset into a printable format +func (c Charset) String() string { + return string(c) +} diff --git a/pkg/mail/header.go b/pkg/mail/header.go new file mode 100644 index 0000000..f7748e6 --- /dev/null +++ b/pkg/mail/header.go @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2022-2023 The go-mail Authors +// +// SPDX-License-Identifier: MIT + +package mail + +// Header represents a generic mail header field name +type Header string + +// AddrHeader represents an address related mail Header field name +type AddrHeader string + +// List of common generic header field names +const ( + // HeaderContentTransferEnc is the "Content-Transfer-Encoding" header + HeaderContentTransferEnc Header = "Content-Transfer-Encoding" + + // HeaderContentType is the "Content-Type" header + HeaderContentType Header = "Content-Type" + + // HeaderDate represents the "Date" field + // See: https://www.rfc-editor.org/rfc/rfc822#section-5.1 + HeaderDate Header = "Date" + + // HeaderMessageID represents the "Message-ID" field for message identification + // See: https://www.rfc-editor.org/rfc/rfc1036#section-2.1.5 + HeaderMessageID Header = "Message-ID" + + // HeaderMIMEVersion represents the "MIME-Version" field as per RFC 2045 + // See: https://datatracker.ietf.org/doc/html/rfc2045#section-4 + HeaderMIMEVersion Header = "MIME-Version" + + // HeaderReplyTo is the "Reply-To" header field + HeaderReplyTo Header = "Reply-To" + + // HeaderSubject is the "Subject" header field + HeaderSubject Header = "Subject" +) + +// List of common address header field names +const ( + // HeaderCc is the "Carbon Copy" header field + HeaderCc AddrHeader = "Cc" + + // HeaderEnvelopeFrom is the envelope FROM header field + // It's not included in the mail body but only used by the Client for the envelope + HeaderEnvelopeFrom AddrHeader = "EnvelopeFrom" + + // HeaderFrom is the "From" header field + HeaderFrom AddrHeader = "From" + + // HeaderTo is the "Recipient" header field + HeaderTo AddrHeader = "To" +) diff --git a/pkg/mail/msg.go b/pkg/mail/msg.go new file mode 100644 index 0000000..c30324b --- /dev/null +++ b/pkg/mail/msg.go @@ -0,0 +1,362 @@ +// SPDX-FileCopyrightText: 2022-2023 The go-mail Authors +// +// SPDX-License-Identifier: MIT + +package mail + +import ( + "bytes" + "context" + "errors" + "fmt" + ht "html/template" + "io" + "mime" + "net/mail" + "os" + "os/exec" + "syscall" + tt "text/template" + "time" +) + +const ( + // errTplExecuteFailed is issued when the template execution was not successful + errTplExecuteFailed = "failed to execute template: %w" + + // errTplPointerNil is issued when a template pointer is expected but it is nil + errTplPointerNil = "template pointer is nil" + + // errParseMailAddr is used when a mail address could not be validated + errParseMailAddr = "failed to parse mail address %q: %w" +) + +// Msg is the mail message struct +type Msg struct { + // addrHeader is a slice of strings that the different mail AddrHeader fields + addrHeader map[AddrHeader][]*mail.Address + + // boundary is the MIME content boundary + boundary string + + // charset represents the charset of the mail (defaults to UTF-8) + charset Charset + + // encoder represents a mime.WordEncoder from the std lib + encoder mime.WordEncoder + + // encoding represents the message encoding (the encoder will be a corresponding WordEncoder) + encoding Encoding + + // genHeader is a slice of strings that the different generic mail Header fields + genHeader map[Header][]string + + // mimever represents the MIME version + mimever MIMEVersion + + // parts represent the different parts of the Msg + parts []*Part + + // preformHeader is a slice of strings that the different generic mail Header fields + // of which content is already preformatted and will not be affected by the automatic line + // breaks + preformHeader map[Header]string + + // sendError holds the SendError in case a Msg could not be delivered during the Client.Send operation + sendError error +} + +// SendmailPath is the default system path to the sendmail binary +const SendmailPath = "/usr/sbin/sendmail" + +// MsgOption returns a function that can be used for grouping Msg options +type MsgOption func(*Msg) + +// NewMsg returns a new Msg pointer +func NewMsg() *Msg { + m := &Msg{ + addrHeader: make(map[AddrHeader][]*mail.Address), + charset: CharsetUTF8, + encoding: EncodingQP, + genHeader: make(map[Header][]string), + preformHeader: make(map[Header]string), + mimever: Mime10, + } + + // Set the matching mime.WordEncoder for the Msg + m.setEncoder() + + return m +} + +// SetGenHeader sets a generic header field of the Msg +// For adding address headers like "To:" or "From", see SetAddrHeader +func (m *Msg) SetGenHeader(h Header, v ...string) { + if m.genHeader == nil { + m.genHeader = make(map[Header][]string) + } + for i, hv := range v { + v[i] = m.encodeString(hv) + } + m.genHeader[h] = v +} + +// SetAddrHeader sets an address related header field of the Msg +func (m *Msg) SetAddrHeader(h AddrHeader, v ...string) error { + if m.addrHeader == nil { + m.addrHeader = make(map[AddrHeader][]*mail.Address) + } + var al []*mail.Address + for _, av := range v { + a, err := mail.ParseAddress(av) + if err != nil { + return fmt.Errorf(errParseMailAddr, av, err) + } + al = append(al, a) + } + switch h { + case HeaderFrom: + if len(al) > 0 { + m.addrHeader[h] = []*mail.Address{al[0]} + } + default: + m.addrHeader[h] = al + } + return nil +} + +// From takes and validates a given mail address and sets it as "From" genHeader of the Msg +func (m *Msg) From(f string) error { + return m.SetAddrHeader(HeaderFrom, f) +} + +// To takes and validates a given mail address list sets the To: addresses of the Msg +func (m *Msg) To(t ...string) error { + return m.SetAddrHeader(HeaderTo, t...) +} + +// ReplyTo takes and validates a given mail address and sets it as "Reply-To" addrHeader of the Msg +func (m *Msg) ReplyTo(r string) error { + rt, err := mail.ParseAddress(r) + if err != nil { + return fmt.Errorf("failed to parse reply-to address: %w", err) + } + m.SetGenHeader(HeaderReplyTo, rt.String()) + return nil +} + +// Subject sets the "Subject" header field of the Msg +func (m *Msg) Subject(s string) { + m.SetGenHeader(HeaderSubject, s) +} + +// SetMessageID generates a random message id for the mail +func (m *Msg) SetMessageID() { + hn, err := os.Hostname() + if err != nil { + hn = "localhost.localdomain" + } + rn, _ := randNum(100000000) + rm, _ := randNum(10000) + rs, _ := randomStringSecure(17) + pid := os.Getpid() * rm + mid := fmt.Sprintf("%d.%d%d.%s@%s", pid, rn, rm, rs, hn) + m.SetMessageIDWithValue(mid) +} + +// SetMessageIDWithValue sets the message id for the mail +func (m *Msg) SetMessageIDWithValue(v string) { + m.SetGenHeader(HeaderMessageID, fmt.Sprintf("<%s>", v)) +} + +// SetDate sets the Date genHeader field to the current time in a valid format +func (m *Msg) SetDate() { + ts := time.Now().Format(time.RFC1123Z) + m.SetGenHeader(HeaderDate, ts) +} + +// SetBodyWriter sets the body of the message. +func (m *Msg) SetBodyWriter(ct ContentType, w func(io.Writer) (int64, error)) { + p := m.newPart(ct) + p.w = w + m.parts = []*Part{p} +} + +// SetBodyTextTemplate sets the body of the message from a given text/template.Template pointer +// The content type will be set to text/plain automatically +func (m *Msg) SetBodyTextTemplate(t *tt.Template, d interface{}) error { + if t == nil { + return fmt.Errorf(errTplPointerNil) + } + buf := bytes.Buffer{} + if err := t.Execute(&buf, d); err != nil { + return fmt.Errorf(errTplExecuteFailed, err) + } + w := writeFuncFromBuffer(&buf) + m.SetBodyWriter(TypeTextPlain, w) + return nil +} + +// AddAlternativeWriter sets the body of the message. +func (m *Msg) AddAlternativeWriter(ct ContentType, w func(io.Writer) (int64, error)) { + p := m.newPart(ct) + p.w = w + m.parts = append(m.parts, p) +} + +// AddAlternativeHTMLTemplate sets the alternative body of the message to a html/template.Template output +// The content type will be set to text/html automatically +func (m *Msg) AddAlternativeHTMLTemplate(t *ht.Template, d interface{}) error { + if t == nil { + return fmt.Errorf(errTplPointerNil) + } + buf := bytes.Buffer{} + if err := t.Execute(&buf, d); err != nil { + return fmt.Errorf(errTplExecuteFailed, err) + } + w := writeFuncFromBuffer(&buf) + m.AddAlternativeWriter(TypeTextHTML, w) + return nil +} + +// WriteTo writes the formatted Msg into a give io.Writer and satisfies the io.WriteTo interface +func (m *Msg) WriteTo(w io.Writer) (int64, error) { + mw := &msgWriter{w: w, c: m.charset, en: m.encoder} + mw.writeMsg(m) + return mw.n, mw.err +} + +// WriteToSendmail returns WriteToSendmailWithCommand with a default sendmail path +func (m *Msg) WriteToSendmail() error { + return m.WriteToSendmailWithCommand(SendmailPath) +} + +// WriteToSendmailWithCommand returns WriteToSendmailWithContext with a default timeout +// of 5 seconds and a given sendmail path +func (m *Msg) WriteToSendmailWithCommand(sp string) error { + tctx, tcfn := context.WithTimeout(context.Background(), time.Second*5) + defer tcfn() + return m.WriteToSendmailWithContext(tctx, sp) +} + +// WriteToSendmailWithContext opens a pipe to the local sendmail binary and tries to send the +// mail though that. It takes a context.Context, the path to the sendmail binary and additional +// arguments for the sendmail binary as parameters +func (m *Msg) WriteToSendmailWithContext(ctx context.Context, sp string, a ...string) error { + ec := exec.CommandContext(ctx, sp) + ec.Args = append(ec.Args, "-oi", "-t") + ec.Args = append(ec.Args, a...) + + se, err := ec.StderrPipe() + if err != nil { + return fmt.Errorf("failed to set STDERR pipe: %w", err) + } + + si, err := ec.StdinPipe() + if err != nil { + return fmt.Errorf("failed to set STDIN pipe: %w", err) + } + if se == nil || si == nil { + return fmt.Errorf("received nil for STDERR or STDIN pipe") + } + + // Start the execution and write to STDIN + if err = ec.Start(); err != nil { + return fmt.Errorf("could not start sendmail execution: %w", err) + } + _, err = m.WriteTo(si) + if err != nil { + if !errors.Is(err, syscall.EPIPE) { + return fmt.Errorf("failed to write mail to buffer: %w", err) + } + } + + // Close STDIN and wait for completion or cancellation of the sendmail executable + if err = si.Close(); err != nil { + return fmt.Errorf("failed to close STDIN pipe: %w", err) + } + + // Read the stderr pipe for possible errors + serr, err := io.ReadAll(se) + if err != nil { + return fmt.Errorf("failed to read STDERR pipe: %w", err) + } + if len(serr) > 0 { + return fmt.Errorf("sendmail command failed: %s", string(serr)) + } + + if err = ec.Wait(); err != nil { + return fmt.Errorf("sendmail command execution failed: %w", err) + } + + return nil +} + +// SendError returns the sendError field of the Msg +func (m *Msg) SendError() error { + return m.sendError +} + +// encodeString encodes a string based on the configured message encoder and the corresponding +// charset for the Msg +func (m *Msg) encodeString(s string) string { + return m.encoder.Encode(string(m.charset), s) +} + +// hasAlt returns true if the Msg has more than one part +func (m *Msg) hasAlt() bool { + c := 0 + for _, p := range m.parts { + if !p.del { + c++ + } + } + return c > 1 +} + +// newPart returns a new Part for the Msg +func (m *Msg) newPart(ct ContentType) *Part { + return &Part{ + ctype: ct, + cset: m.charset, + enc: m.encoding, + } +} + +// setEncoder creates a new mime.WordEncoder based on the encoding setting of the message +func (m *Msg) setEncoder() { + m.encoder = getEncoder(m.encoding) +} + +// addDefaultHeader sets some default headers, if they haven't been set before +func (m *Msg) addDefaultHeader() { + if _, ok := m.genHeader[HeaderDate]; !ok { + m.SetDate() + } + if _, ok := m.genHeader[HeaderMessageID]; !ok { + m.SetMessageID() + } + m.SetGenHeader(HeaderMIMEVersion, string(m.mimever)) +} + +// getEncoder creates a new mime.WordEncoder based on the encoding setting of the message +func getEncoder(e Encoding) mime.WordEncoder { + switch e { + case EncodingQP: + return mime.QEncoding + case EncodingB64: + return mime.BEncoding + default: + return mime.QEncoding + } +} + +// writeFuncFromBuffer is a common method to convert a byte buffer into a writeFunc as +// often required by this library +func writeFuncFromBuffer(buf *bytes.Buffer) func(io.Writer) (int64, error) { + w := func(w io.Writer) (int64, error) { + nb, err := w.Write(buf.Bytes()) + return int64(nb), err + } + return w +} diff --git a/pkg/mail/msgwrite.go b/pkg/mail/msgwrite.go new file mode 100644 index 0000000..d71ed22 --- /dev/null +++ b/pkg/mail/msgwrite.go @@ -0,0 +1,282 @@ +// SPDX-FileCopyrightText: 2022-2023 The go-mail Authors +// +// SPDX-License-Identifier: MIT + +package mail + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net/textproto" + "sort" + "strings" +) + +// MaxHeaderLength defines the maximum line length for a mail header +// RFC 2047 suggests 76 characters +const MaxHeaderLength = 76 + +// MaxBodyLength defines the maximum line length for the mail body +// RFC 2047 suggests 76 characters +const MaxBodyLength = 76 + +// SingleNewLine represents a new line that can be used by the msgWriter to issue a carriage return +const SingleNewLine = "\r\n" + +// DoubleNewLine represents a double new line that can be used by the msgWriter to +// indicate a new segment of the mail +const DoubleNewLine = "\r\n\r\n" + +// msgWriter handles the I/O to the io.WriteCloser of the SMTP client +type msgWriter struct { + c Charset + d int8 + en mime.WordEncoder + err error + mpw [3]*multipart.Writer + n int64 + pw io.Writer + w io.Writer +} + +// Write implements the io.Writer interface for msgWriter +func (mw *msgWriter) Write(p []byte) (int, error) { + if mw.err != nil { + return 0, fmt.Errorf("failed to write due to previous error: %w", mw.err) + } + + var n int + n, mw.err = mw.w.Write(p) + mw.n += int64(n) + return n, mw.err +} + +// writeMsg formats the message and sends it to its io.Writer +func (mw *msgWriter) writeMsg(m *Msg) { + m.addDefaultHeader() + mw.writeGenHeader(m) + mw.writePreformattedGenHeader(m) + + // Set the FROM header (or envelope FROM if FROM is empty) + hf := true + f, ok := m.addrHeader[HeaderFrom] + if !ok || (len(f) == 0 || f == nil) { + f, ok = m.addrHeader[HeaderEnvelopeFrom] + if !ok || (len(f) == 0 || f == nil) { + hf = false + } + } + if hf && (len(f) > 0 && f[0] != nil) { + mw.writeHeader(Header(HeaderFrom), f[0].String()) + } + + // Set the rest of the address headers + for _, t := range []AddrHeader{HeaderTo, HeaderCc} { + if al, ok := m.addrHeader[t]; ok { + var v []string + for _, a := range al { + v = append(v, a.String()) + } + mw.writeHeader(Header(t), v...) + } + } + + if m.hasAlt() { + mw.startMP(MIMEAlternative, m.boundary) + mw.writeString(DoubleNewLine) + } + + for _, p := range m.parts { + if !p.del { + mw.writePart(p, m.charset) + } + } + + if m.hasAlt() { + mw.stopMP() + } +} + +// writeGenHeader writes out all generic headers to the msgWriter +func (mw *msgWriter) writeGenHeader(m *Msg) { + gk := make([]string, 0, len(m.genHeader)) + for k := range m.genHeader { + gk = append(gk, string(k)) + } + sort.Strings(gk) + for _, k := range gk { + mw.writeHeader(Header(k), m.genHeader[Header(k)]...) + } +} + +// writePreformattedHeader writes out all preformatted generic headers to the msgWriter +func (mw *msgWriter) writePreformattedGenHeader(m *Msg) { + for k, v := range m.preformHeader { + mw.writeString(fmt.Sprintf("%s: %s%s", k, v, SingleNewLine)) + } +} + +// startMP writes a multipart beginning +func (mw *msgWriter) startMP(mt MIMEType, b string) { + mp := multipart.NewWriter(mw) + if b != "" { + mw.err = mp.SetBoundary(b) + } + + ct := fmt.Sprintf("multipart/%s;\r\n boundary=%s", mt, mp.Boundary()) + mw.mpw[mw.d] = mp + + if mw.d == 0 { + mw.writeString(fmt.Sprintf("%s: %s", HeaderContentType, ct)) + } + if mw.d > 0 { + mw.newPart(map[string][]string{"Content-Type": {ct}}) + } + mw.d++ +} + +// stopMP closes the multipart +func (mw *msgWriter) stopMP() { + if mw.d > 0 { + mw.err = mw.mpw[mw.d-1].Close() + mw.d-- + } +} + +// newPart creates a new MIME multipart io.Writer and sets the part writer to it +func (mw *msgWriter) newPart(h map[string][]string) { + mw.pw, mw.err = mw.mpw[mw.d-1].CreatePart(h) +} + +// writePart writes the corresponding part to the Msg body +func (mw *msgWriter) writePart(p *Part, cs Charset) { + pcs := p.cset + if pcs.String() == "" { + pcs = cs + } + ct := fmt.Sprintf("%s; charset=%s", p.ctype, pcs) + cte := p.enc.String() + if mw.d == 0 { + mw.writeHeader(HeaderContentType, ct) + mw.writeHeader(HeaderContentTransferEnc, cte) + mw.writeString(SingleNewLine) + } + if mw.d > 0 { + mh := textproto.MIMEHeader{} + mh.Add(string(HeaderContentType), ct) + mh.Add(string(HeaderContentTransferEnc), cte) + mw.newPart(mh) + } + mw.writeBody(p.w, p.enc) +} + +// writeString writes a string into the msgWriter's io.Writer interface +func (mw *msgWriter) writeString(s string) { + if mw.err != nil { + return + } + var n int + n, mw.err = io.WriteString(mw.w, s) + mw.n += int64(n) +} + +// writeHeader writes a header into the msgWriter's io.Writer +func (mw *msgWriter) writeHeader(k Header, vl ...string) { + wbuf := bytes.Buffer{} + cl := MaxHeaderLength - 2 + wbuf.WriteString(string(k)) + cl -= len(k) + if len(vl) == 0 { + wbuf.WriteString(":\r\n") + return + } + wbuf.WriteString(": ") + cl -= 2 + + fs := strings.Join(vl, ", ") + sfs := strings.Split(fs, " ") + for i, v := range sfs { + if cl-len(v) <= 1 { + wbuf.WriteString(fmt.Sprintf("%s ", SingleNewLine)) + cl = MaxHeaderLength - 3 + } + wbuf.WriteString(v) + if i < len(sfs)-1 { + wbuf.WriteString(" ") + cl -= 1 + } + cl -= len(v) + } + + bufs := wbuf.String() + bufs = strings.ReplaceAll(bufs, fmt.Sprintf(" %s", SingleNewLine), SingleNewLine) + mw.writeString(bufs) + mw.writeString("\r\n") +} + +// writeBody writes an io.Reader into an io.Writer using provided Encoding +func (mw *msgWriter) writeBody(f func(io.Writer) (int64, error), e Encoding) { + var w io.Writer + var ew io.WriteCloser + var n int64 + var err error + if mw.d == 0 { + w = mw.w + } + if mw.d > 0 { + w = mw.pw + } + wbuf := bytes.Buffer{} + lb := Base64LineBreaker{} + lb.out = &wbuf + + switch e { + case EncodingQP: + ew = quotedprintable.NewWriter(&wbuf) + case EncodingB64: + ew = base64.NewEncoder(base64.StdEncoding, &lb) + case NoEncoding: + _, err = f(&wbuf) + if err != nil { + mw.err = fmt.Errorf("bodyWriter function: %w", err) + } + n, err = io.Copy(w, &wbuf) + if err != nil && mw.err == nil { + mw.err = fmt.Errorf("bodyWriter io.Copy: %w", err) + } + if mw.d == 0 { + mw.n += n + } + return + default: + ew = quotedprintable.NewWriter(w) + } + + _, err = f(ew) + if err != nil { + mw.err = fmt.Errorf("bodyWriter function: %w", err) + } + err = ew.Close() + if err != nil && mw.err == nil { + mw.err = fmt.Errorf("bodyWriter close encoded writer: %w", err) + } + err = lb.Close() + if err != nil && mw.err == nil { + mw.err = fmt.Errorf("bodyWriter close linebreaker: %w", err) + } + n, err = io.Copy(w, &wbuf) + if err != nil && mw.err == nil { + mw.err = fmt.Errorf("bodyWriter io.Copy: %w", err) + } + + // Since the part writer uses the WriteTo() method, we don't need to add the + // bytes twice + if mw.d == 0 { + mw.n += n + } +} diff --git a/pkg/mail/part.go b/pkg/mail/part.go new file mode 100644 index 0000000..e4315bc --- /dev/null +++ b/pkg/mail/part.go @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2022-2023 The go-mail Authors +// +// SPDX-License-Identifier: MIT + +package mail + +import ( + "io" +) + +// Part is a part of the Msg +type Part struct { + ctype ContentType + cset Charset + enc Encoding + del bool + w func(io.Writer) (int64, error) +} diff --git a/pkg/mail/random.go b/pkg/mail/random.go new file mode 100644 index 0000000..a4c691e --- /dev/null +++ b/pkg/mail/random.go @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: 2022-2023 The go-mail Authors +// +// SPDX-License-Identifier: MIT + +package mail + +import ( + "crypto/rand" + "encoding/binary" + "fmt" + "math/big" + "strings" +) + +// Range of characters for the secure string generation +const cr = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" + +// Bitmask sizes for the string generators (based on 93 chars total) +const ( + letterIdxBits = 7 // 7 bits to represent a letter index + letterIdxMask = 1<= 0; { + if r == 0 { + _, err := rand.Read(rp) + if err != nil { + return rs.String(), err + } + c, r = binary.BigEndian.Uint64(rp), letterIdxMax + } + if idx := int(c & letterIdxMask); idx < crl { + rs.WriteByte(cr[idx]) + i-- + } + c >>= letterIdxBits + r-- + } + + return rs.String(), nil +} + +// randNum returns a random number with a maximum value of n +func randNum(n int) (int, error) { + if n <= 0 { + return 0, fmt.Errorf("provided number is <= 0: %d", n) + } + mbi := big.NewInt(int64(n)) + if !mbi.IsUint64() { + return 0, fmt.Errorf("big.NewInt() generation returned negative value: %d", mbi) + } + rn64, err := rand.Int(rand.Reader, mbi) + if err != nil { + return 0, err + } + rn := int(rn64.Int64()) + if rn < 0 { + return 0, fmt.Errorf("generated random number does not fit as int64: %d", rn64) + } + return rn, nil +} diff --git a/pkg/payment/public.go b/pkg/payment/public.go index 71b2114..a3478f8 100644 --- a/pkg/payment/public.go +++ b/pkg/payment/public.go @@ -3,23 +3,23 @@ package payment import ( "context" "fmt" + ht "html/template" "net/http" + tt "text/template" "time" "dev.tandem.ws/tandem/camper/pkg/auth" "dev.tandem.ws/tandem/camper/pkg/database" httplib "dev.tandem.ws/tandem/camper/pkg/http" + "dev.tandem.ws/tandem/camper/pkg/locale" + "dev.tandem.ws/tandem/camper/pkg/mail" "dev.tandem.ws/tandem/camper/pkg/redsys" "dev.tandem.ws/tandem/camper/pkg/template" "dev.tandem.ws/tandem/camper/pkg/uuid" ) const ( - StatusDraft = "draft" - StatusPending = "pending" - StatusFailed = "failed" StatusCompleted = "completed" - StatusRefunded = "refunded" ) type PublicHandler struct { @@ -249,6 +249,7 @@ func handleNotification(w http.ResponseWriter, r *http.Request, user *auth.User, } switch status { case StatusCompleted: + _ = sendEmail(r.Context(), conn, payment, company, user.Locale) /* shrug */ default: } w.WriteHeader(http.StatusNoContent) @@ -259,3 +260,109 @@ func handleNotification(w http.ResponseWriter, r *http.Request, user *auth.User, http.NotFound(w, r) } } + +type CompletedEmail struct { + CurrentLocale string + CustomerFullName string + PaymentReference string + AccommodationName string + ArrivalDate string + DepartureDate string + Total string + CompanyAddress *address +} + +type address struct { + TradeName string + Address string + PostalCode string + Province string + City string + Country string +} + +func sendEmail(ctx context.Context, conn *database.Conn, payment *Payment, company *auth.Company, locale *locale.Locale) error { + email := &CompletedEmail{ + CurrentLocale: locale.Language.String(), + PaymentReference: payment.OrderNumber(), + Total: template.FormatPrice(payment.Total, locale.Language, locale.CurrencyPattern, company.DecimalDigits, company.CurrencySymbol), + CompanyAddress: &address{}, + } + + var fromAddress string + var toAddress string + if err := conn.QueryRow(ctx, ` + select company.email::text + , customer.email::text + , customer.full_name + , coalesce(i18n.name, campsite_type.name) + , to_char(arrival_date, 'DD/MM/YYYY') + , to_char(departure_date, 'DD/MM/YYYY') + , company.trade_name + , company.address + , company.postal_code + , company.province + , company.city + , coalesce(country_i18n.name, country.name) as country_name + from payment + join payment_customer as customer using (payment_id) + join campsite_type using (campsite_type_id) + left join campsite_type_i18n as i18n on i18n.campsite_type_id = campsite_type.campsite_type_id and i18n.lang_tag = $2 + join company on company.company_id = payment.company_id + join country on country.country_code = company.country_code + left join country_i18n on country.country_code = country_i18n.country_code and country_i18n.lang_tag = $2 + where payment_id = $1 + `, payment.ID, locale.Language).Scan(&fromAddress, + &toAddress, + &email.CustomerFullName, + &email.AccommodationName, + &email.ArrivalDate, + &email.DepartureDate, + &email.CompanyAddress.TradeName, + &email.CompanyAddress.Address, + &email.CompanyAddress.PostalCode, + &email.CompanyAddress.Province, + &email.CompanyAddress.City, + &email.CompanyAddress.Country, + ); err != nil { + return err + } + + m := mail.NewMsg() + if err := m.From(fromAddress); err != nil { + return err + } + if err := m.ReplyTo(fromAddress); err != nil { + return err + } + if err := m.To(toAddress); err != nil { + return err + } + m.Subject(locale.Pgettext("Booking payment successfully received", "subject")) + + baseTemplate := "body.go" + baseFilename := "web/templates/mail/payment/" + baseTemplate + body, err := tt.New(baseTemplate + "txt").Funcs(tt.FuncMap{ + "gettext": locale.Get, + "pgettext": locale.GetC, + }).ParseFiles(baseFilename + "txt") + if err != nil { + return err + } + if err := m.SetBodyTextTemplate(body, email); err != nil { + return err + } + + alternative, err := ht.New(baseTemplate + "html").Funcs(tt.FuncMap{ + "gettext": locale.Get, + "pgettext": locale.GetC, + "raw": func(s string) ht.HTML { return ht.HTML(s) }, + }).ParseFiles(baseFilename + "html") + if err != nil { + return err + } + if err := m.AddAlternativeHTMLTemplate(alternative, email); err != nil { + return err + } + return m.WriteToSendmail() +} diff --git a/pkg/template/render.go b/pkg/template/render.go index 5682dc4..34de752 100644 --- a/pkg/template/render.go +++ b/pkg/template/render.go @@ -102,7 +102,7 @@ func mustRenderLayout(w io.Writer, user *auth.User, company *auth.Company, templ return humanizeBytes(bytes) }, "formatPrice": func(price string) string { - return formatPrice(price, user.Locale.Language, user.Locale.CurrencyPattern, company.DecimalDigits, company.CurrencySymbol) + return FormatPrice(price, user.Locale.Language, user.Locale.CurrencyPattern, company.DecimalDigits, company.CurrencySymbol) }, "formatDate": func(time time.Time) template.HTML { return template.HTML(`") @@ -156,7 +156,7 @@ func mustRenderLayout(w io.Writer, user *auth.User, company *auth.Company, templ } } -func formatPrice(price string, language language.Tag, currencyPattern string, decimalDigits int, currencySymbol string) string { +func FormatPrice(price string, language language.Tag, currencyPattern string, decimalDigits int, currencySymbol string) string { p := message.NewPrinter(language) f, err := strconv.ParseFloat(price, 64) if err != nil { diff --git a/po/ca.po b/po/ca.po index fa3c638..0c53830 100644 --- a/po/ca.po +++ b/po/ca.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: camper\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n" -"POT-Creation-Date: 2024-02-12 17:49+0100\n" +"POT-Creation-Date: 2024-02-13 05:09+0100\n" "PO-Revision-Date: 2024-02-06 10:04+0100\n" "Last-Translator: jordi fita mas \n" "Language-Team: Catalan \n" @@ -59,6 +59,66 @@ msgctxt "tooltip" msgid "Zone 1" msgstr "Zona 1" +#: web/templates/mail/payment/body.gohtml:6 +msgctxt "title" +msgid "Booking Payment Notification" +msgstr "Notificació de pagament de reserva" + +#: web/templates/mail/payment/body.gohtml:33 +#: web/templates/mail/payment/body.gotxt:1 +msgid "Hi %s," +msgstr "Hola %s," + +#: web/templates/mail/payment/body.gohtml:35 +#: web/templates/mail/payment/body.gotxt:3 +msgid "We have successfully received the payment for the booking with the following details:" +msgstr "Hem rebut amb èxit el pagament de la reserva amb els següents detalls:" + +#: web/templates/mail/payment/body.gohtml:37 +msgid "Payment reference: %s" +msgstr "Referència de pagament: %s" + +#: web/templates/mail/payment/body.gohtml:39 +msgid "Accommodation: %s" +msgstr "Allotjament: %s" + +#: web/templates/mail/payment/body.gohtml:41 +msgid "Arrival Date: %s" +msgstr "Data d’arribada: %s" + +#: web/templates/mail/payment/body.gohtml:43 +msgid "Departure Date: %s" +msgstr "Data de sortida: %s" + +#: web/templates/mail/payment/body.gohtml:45 +msgid "Total: %s" +msgstr "Total: %s" + +#: web/templates/mail/payment/body.gohtml:48 +#: web/templates/mail/payment/body.gotxt:11 +msgid "Thank you for your booking, and see you soon!" +msgstr "Moltes gràcies per la reserva i fins aviat!" + +#: web/templates/mail/payment/body.gotxt:5 +msgid "Payment reference: **%s**" +msgstr "Referència de pagament: **%s**" + +#: web/templates/mail/payment/body.gotxt:6 +msgid "Accommodation: **%s**" +msgstr "Allotjament: **%s**" + +#: web/templates/mail/payment/body.gotxt:7 +msgid "Arrival Date: **%s**" +msgstr "Data d’arribada: **%s**" + +#: web/templates/mail/payment/body.gotxt:8 +msgid "Departure Date: **%s**" +msgstr "Data de sortida: **%s**" + +#: web/templates/mail/payment/body.gotxt:9 +msgid "Total: **%s**" +msgstr "Total: **%s**" + #: web/templates/public/payment/success.gohtml:6 msgctxt "title" msgid "Payment Successful" @@ -1979,11 +2039,16 @@ msgstr "Estat" msgid "No booking found." msgstr "No s’ha trobat cap reserva." -#: pkg/payment/public.go:97 +#: pkg/payment/public.go:107 msgctxt "order product name" msgid "Campsite Booking" msgstr "Reserva de càmping" +#: pkg/payment/public.go:344 +msgctxt "subject" +msgid "Booking payment successfully received" +msgstr "Rebut amb èxit el pagament de la reserva" + #: pkg/legal/admin.go:258 pkg/app/user.go:249 pkg/campsite/types/option.go:365 #: pkg/campsite/types/feature.go:272 pkg/campsite/types/admin.go:577 #: pkg/campsite/feature.go:269 pkg/season/admin.go:412 diff --git a/po/es.po b/po/es.po index 1aab691..f9e5c02 100644 --- a/po/es.po +++ b/po/es.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: camper\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n" -"POT-Creation-Date: 2024-02-12 17:49+0100\n" +"POT-Creation-Date: 2024-02-13 05:09+0100\n" "PO-Revision-Date: 2024-02-06 10:04+0100\n" "Last-Translator: jordi fita mas \n" "Language-Team: Spanish \n" @@ -59,6 +59,66 @@ msgctxt "tooltip" msgid "Zone 1" msgstr "Zona 1" +#: web/templates/mail/payment/body.gohtml:6 +msgctxt "title" +msgid "Booking Payment Notification" +msgstr "Notificación de pago de reserva" + +#: web/templates/mail/payment/body.gohtml:33 +#: web/templates/mail/payment/body.gotxt:1 +msgid "Hi %s," +msgstr "Hola %s," + +#: web/templates/mail/payment/body.gohtml:35 +#: web/templates/mail/payment/body.gotxt:3 +msgid "We have successfully received the payment for the booking with the following details:" +msgstr "Hemos recibido correctamente el pago de la reserva con los siguientes detalles:" + +#: web/templates/mail/payment/body.gohtml:37 +msgid "Payment reference: %s" +msgstr "Referencia del pago: %s" + +#: web/templates/mail/payment/body.gohtml:39 +msgid "Accommodation: %s" +msgstr "Alojamiento: %s" + +#: web/templates/mail/payment/body.gohtml:41 +msgid "Arrival Date: %s" +msgstr "Fecha de llegada: %s" + +#: web/templates/mail/payment/body.gohtml:43 +msgid "Departure Date: %s" +msgstr "Fecha de salida: %s" + +#: web/templates/mail/payment/body.gohtml:45 +msgid "Total: %s" +msgstr "Total: %s" + +#: web/templates/mail/payment/body.gohtml:48 +#: web/templates/mail/payment/body.gotxt:11 +msgid "Thank you for your booking, and see you soon!" +msgstr "Gracias por su reserva y ¡hasta pronto!" + +#: web/templates/mail/payment/body.gotxt:5 +msgid "Payment reference: **%s**" +msgstr "Referencia del pago: **%s**" + +#: web/templates/mail/payment/body.gotxt:6 +msgid "Accommodation: **%s**" +msgstr "Alojamiento: **%s**" + +#: web/templates/mail/payment/body.gotxt:7 +msgid "Arrival Date: **%s**" +msgstr "Fecha de llegada: **%s**" + +#: web/templates/mail/payment/body.gotxt:8 +msgid "Departure Date: **%s**" +msgstr "Fecha de salida: **%s**" + +#: web/templates/mail/payment/body.gotxt:9 +msgid "Total: **%s**" +msgstr "Total: **%s**" + #: web/templates/public/payment/success.gohtml:6 msgctxt "title" msgid "Payment Successful" @@ -1979,11 +2039,16 @@ msgstr "Estado" msgid "No booking found." msgstr "No se ha encontrado ninguna reserva." -#: pkg/payment/public.go:97 +#: pkg/payment/public.go:107 msgctxt "order product name" msgid "Campsite Booking" msgstr "Reserva de camping" +#: pkg/payment/public.go:344 +msgctxt "subject" +msgid "Booking payment successfully received" +msgstr "Se ha recibido correctamente el pago de la reserva" + #: pkg/legal/admin.go:258 pkg/app/user.go:249 pkg/campsite/types/option.go:365 #: pkg/campsite/types/feature.go:272 pkg/campsite/types/admin.go:577 #: pkg/campsite/feature.go:269 pkg/season/admin.go:412 diff --git a/po/fr.po b/po/fr.po index dad9918..455f3c3 100644 --- a/po/fr.po +++ b/po/fr.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: camper\n" "Report-Msgid-Bugs-To: jordi@tandem.blog\n" -"POT-Creation-Date: 2024-02-12 17:49+0100\n" +"POT-Creation-Date: 2024-02-13 05:02+0100\n" "PO-Revision-Date: 2024-02-06 10:05+0100\n" "Last-Translator: Oriol Carbonell \n" "Language-Team: French \n" @@ -59,6 +59,66 @@ msgctxt "tooltip" msgid "Zone 1" msgstr "Zone 1" +#: web/templates/mail/payment/body.gohtml:6 +msgctxt "title" +msgid "Booking Payment Notification" +msgstr "Notification de paiement de réservation" + +#: web/templates/mail/payment/body.gohtml:33 +#: web/templates/mail/payment/body.gotxt:1 +msgid "Hi %s," +msgstr "Salut %s," + +#: web/templates/mail/payment/body.gohtml:35 +#: web/templates/mail/payment/body.gotxt:3 +msgid "We have successfully received the payment for the booking with the following details:" +msgstr "Nous avons reçu avec succès le paiement de la réservation avec les détails suivants :" + +#: web/templates/mail/payment/body.gohtml:37 +msgid "Payment reference: %s" +msgstr "Référence de paiement : %s." + +#: web/templates/mail/payment/body.gohtml:39 +msgid "Accommodation: %s" +msgstr "Hébergement : < strong>%s" + +#: web/templates/mail/payment/body.gohtml:41 +msgid "Arrival Date: %s" +msgstr "Date d’arrivée : %s" + +#: web/templates/mail/payment/body.gohtml:43 +msgid "Departure Date: %s" +msgstr "Date de depart : %s" + +#: web/templates/mail/payment/body.gohtml:45 +msgid "Total: %s" +msgstr "Totale : %s" + +#: web/templates/mail/payment/body.gohtml:48 +#: web/templates/mail/payment/body.gotxt:11 +msgid "Thank you for your booking, and see you soon!" +msgstr "Merci pour votre réservation et à bientôt !" + +#: web/templates/mail/payment/body.gotxt:5 +msgid "Payment reference: **%s**" +msgstr "Référence de paiement : **%s**" + +#: web/templates/mail/payment/body.gotxt:6 +msgid "Accommodation: **%s**" +msgstr "Hébergement : **%s**" + +#: web/templates/mail/payment/body.gotxt:7 +msgid "Arrival Date: **%s**" +msgstr "Date d’arrivée : **%s**" + +#: web/templates/mail/payment/body.gotxt:8 +msgid "Departure Date: **%s**" +msgstr "Date de depart : **%s**" + +#: web/templates/mail/payment/body.gotxt:9 +msgid "Total: **%s**" +msgstr "Totale : **%s**" + #: web/templates/public/payment/success.gohtml:6 msgctxt "title" msgid "Payment Successful" @@ -1979,11 +2039,16 @@ msgstr "Statut" msgid "No booking found." msgstr "Aucune réservation trouvée." -#: pkg/payment/public.go:97 +#: pkg/payment/public.go:107 msgctxt "order product name" msgid "Campsite Booking" msgstr "Réservation camping" +#: pkg/payment/public.go:344 +msgctxt "subject" +msgid "Booking payment successfully received" +msgstr "Paiement de réservation reçu avec succès" + #: pkg/legal/admin.go:258 pkg/app/user.go:249 pkg/campsite/types/option.go:365 #: pkg/campsite/types/feature.go:272 pkg/campsite/types/admin.go:577 #: pkg/campsite/feature.go:269 pkg/season/admin.go:412 diff --git a/web/templates/mail/payment/body.gohtml b/web/templates/mail/payment/body.gohtml new file mode 100644 index 0000000..e605045 --- /dev/null +++ b/web/templates/mail/payment/body.gohtml @@ -0,0 +1,75 @@ + + + + + + {{( pgettext "Booking Payment Notification" "title" )}} + + + + + + + + + + + + diff --git a/web/templates/mail/payment/body.gotxt b/web/templates/mail/payment/body.gotxt new file mode 100644 index 0000000..ef8a653 --- /dev/null +++ b/web/templates/mail/payment/body.gotxt @@ -0,0 +1,18 @@ +{{ printf (gettext "Hi %s,") .CustomerFullName }} + +{{( gettext "We have successfully received the payment for the booking with the following details:" )}}

+ + {{ printf ( gettext "Payment reference: **%s**") .PaymentReference }} + {{ printf ( gettext "Accommodation: **%s**") .AccommodationName }} + {{ printf ( gettext "Arrival Date: **%s**") .ArrivalDate }} + {{ printf ( gettext "Departure Date: **%s**") .DepartureDate }} + {{ printf ( gettext "Total: **%s**") .Total }} + +{{( gettext "Thank you for your booking, and see you soon!" )}} + +{{ with .CompanyAddress -}} +{{ .TradeName }}, +{{ .Address }}, +{{ .PostalCode}} · {{ .City }} · {{ .Province }} +{{ .Country }} +{{- end }}