mail.go (view raw)
1package mail
2
3import (
4 "bytes"
5 "crypto/tls"
6 "encoding/base64"
7 "errors"
8 "fmt"
9 "github.com/joegrasse/mime/header"
10 "io"
11 "io/ioutil"
12 "mime"
13 "mime/multipart"
14 "mime/quotedprintable"
15 "net"
16 "net/mail"
17 "net/smtp"
18 "net/textproto"
19 "path/filepath"
20 "regexp"
21 "strconv"
22 "strings"
23 "time"
24)
25
26// email represents an email message.
27type email struct {
28 from string
29 sender string
30 replyTo string
31 returnPath string
32 recipients []string
33 headers textproto.MIMEHeader
34 parts []part
35 attachments []*file
36 inlines []*file
37 Charset string
38 Encoding encoding
39 Encryption encryption
40 Username string
41 Password string
42 TLSConfig *tls.Config
43 ConnectTimeout int
44}
45
46// part represents the different content parts of an email body.
47type part struct {
48 contentType string
49 body *bytes.Buffer
50}
51
52// file represents the files that can be added to the email message.
53type file struct {
54 filename string
55 mimeType string
56 data []byte
57}
58
59type encryption int
60
61const (
62 EncryptionTLS encryption = iota
63 EncryptionSSL
64 EncryptionNone
65)
66
67var encryptionTypes = [...]string{"TLS", "SSL", "None"}
68
69func (encryption encryption) String() string {
70 return encryptionTypes[encryption]
71}
72
73type encoding int
74
75const (
76 EncodingQuotedPrintable encoding = iota
77 EncodingBase64
78 EncodingNone
79)
80
81var encodingTypes = [...]string{"quoted-printable", "base64", "binary"}
82
83func (encoding encoding) String() string {
84 return encodingTypes[encoding]
85}
86
87// New creates a new email. It uses UTF-8 by default.
88func New() *email {
89 email := &email{
90 headers: make(textproto.MIMEHeader),
91 Charset: "UTF-8",
92 Encoding: EncodingQuotedPrintable,
93 Encryption: EncryptionNone,
94 TLSConfig: new(tls.Config),
95 }
96
97 email.AddHeader("MIME-Version", "1.0")
98
99 return email
100}
101
102// SetFrom sets the From address.
103func (email *email) SetFrom(address string) error {
104 return email.AddAddresses("From", address)
105}
106
107// SetSender sets the Sender address.
108func (email *email) SetSender(address string) error {
109 return email.AddAddresses("Sender", address)
110}
111
112// SetReplyTo sets the Reply-To address.
113func (email *email) SetReplyTo(address string) error {
114 return email.AddAddresses("Reply-To", address)
115}
116
117// SetReturnPath sets the Return-Path address. This is most often used
118// to send bounced emails to a different email address.
119func (email *email) SetReturnPath(address string) error {
120 return email.AddAddresses("Return-Path", address)
121}
122
123// AddTo adds a To address. You can provide multiple
124// addresses at the same time.
125func (email *email) AddTo(addresses ...string) error {
126 return email.AddAddresses("To", addresses...)
127}
128
129// AddCc adds a Cc address. You can provide multiple
130// addresses at the same time.
131func (email *email) AddCc(addresses ...string) error {
132 return email.AddAddresses("Cc", addresses...)
133}
134
135// AddBcc adds a Bcc address. You can provide multiple
136// addresses at the same time.
137func (email *email) AddBcc(addresses ...string) error {
138 return email.AddAddresses("Bcc", addresses...)
139}
140
141// AddAddresses allows you to add addresses to the specified address header.
142func (email *email) AddAddresses(header string, addresses ...string) error {
143 found := false
144
145 // check for a valid address header
146 for _, h := range []string{"To", "Cc", "Bcc", "From", "Sender", "Reply-To", "Return-Path"} {
147 if header == h {
148 found = true
149 }
150 }
151
152 if !found {
153 return errors.New("Mail Error: Invalid address header; Header: [" + header + "]")
154 }
155
156 // check to see if the addresses are valid
157 for i := range addresses {
158 address, err := mail.ParseAddress(addresses[i])
159 if err != nil {
160 return errors.New("Mail Error: " + err.Error() + "; Header: [" + header + "] Address: [" + addresses[i] + "]")
161 }
162
163 // check for more than one address
164 switch {
165 case header == "From" && len(email.from) > 0:
166 fallthrough
167 case header == "Sender" && len(email.sender) > 0:
168 fallthrough
169 case header == "Reply-To" && len(email.replyTo) > 0:
170 fallthrough
171 case header == "Return-Path" && len(email.returnPath) > 0:
172 return errors.New("Mail Error: There can only be one \"" + header + "\" address; Header: [" + header + "] Address: [" + addresses[i] + "]")
173 default:
174 // other address types can have more than one address
175 }
176
177 // save the address
178 switch header {
179 case "From":
180 email.from = address.Address
181 case "Sender":
182 email.sender = address.Address
183 case "Reply-To":
184 email.replyTo = address.Address
185 case "Return-Path":
186 email.returnPath = address.Address
187 default:
188 // check that the address was added to the recipients list
189 email.recipients, err = addAddress(email.recipients, address.Address)
190 if err != nil {
191 return errors.New("Mail Error: " + err.Error() + "; Header: [" + header + "] Address: [" + addresses[i] + "]")
192 }
193 }
194
195 // make sure the from and sender addresses are different
196 if email.from != "" && email.sender != "" && email.from == email.sender {
197 email.sender = ""
198 email.headers.Del("Sender")
199 return errors.New("Mail Error: From and Sender should not be set to the same address.")
200 }
201
202 // add all addresses to the headers except for Bcc and Return-Path
203 if header != "Bcc" && header != "Return-Path" {
204 // add the address to the headers
205 email.headers.Add(header, address.String())
206 }
207 }
208
209 return nil
210}
211
212// addAddress adds an address to the address list if it hasn't already been added
213func addAddress(addressList []string, address string) ([]string, error) {
214 // loop through the address list to check for dups
215 for _, a := range addressList {
216 if address == a {
217 return addressList, errors.New("Mail Error: Address: [" + address + "] has already been added")
218 }
219 }
220
221 return append(addressList, address), nil
222}
223
224type priority int
225
226const (
227 PriorityHigh priority = iota
228 PriorityLow
229)
230
231var priorities = [...]string{"High", "Low"}
232
233func (priority priority) String() string {
234 return priorities[priority]
235}
236
237// SetPriority sets the email message priority. Use with
238// either "High" or "Low".
239func (email *email) SetPriority(priority priority) error {
240 switch priority {
241 case PriorityHigh:
242 return email.AddHeaders(textproto.MIMEHeader{
243 "X-Priority": {"1 (Highest)"},
244 "X-MSMail-Priority": {"High"},
245 "Importance": {"High"},
246 })
247 case PriorityLow:
248 return email.AddHeaders(textproto.MIMEHeader{
249 "X-Priority": {"5 (Lowest)"},
250 "X-MSMail-Priority": {"Low"},
251 "Importance": {"Low"},
252 })
253 default:
254 }
255
256 return nil
257}
258
259// SetDate sets the date header to the provided date/time.
260// The format of the string should be YYYY-MM-DD HH:MM:SS Time Zone.
261//
262// Example: SetDate("2015-04-28 10:32:00 CDT")
263func (email *email) SetDate(dateTime string) error {
264 const dateFormat = "2006-01-02 15:04:05 MST"
265
266 // Try to parse the provided date/time
267 dt, err := time.Parse(dateFormat, dateTime)
268 if err != nil {
269 return errors.New("Mail Error: Setting date failed with: " + err.Error())
270 }
271
272 email.headers.Set("Date", dt.Format(time.RFC1123Z))
273
274 return nil
275}
276
277// SetSubject sets the subject of the email message.
278func (email *email) SetSubject(subject string) error {
279 return email.AddHeader("Subject", subject)
280}
281
282// SetBody sets the body of the email message.
283func (email *email) SetBody(contentType, body string) {
284 email.parts = []part{
285 part{
286 contentType: contentType,
287 body: bytes.NewBufferString(body),
288 },
289 }
290}
291
292// Header adds the given "header" with the passed "value".
293func (email *email) AddHeader(header string, values ...string) error {
294 // check that there is actually a value
295 if len(values) < 1 {
296 return errors.New("Mail Error: no value provided; Header: [" + header + "]")
297 }
298
299 switch header {
300 case "Sender":
301 fallthrough
302 case "From":
303 fallthrough
304 case "To":
305 fallthrough
306 case "Bcc":
307 fallthrough
308 case "Cc":
309 fallthrough
310 case "Reply-To":
311 fallthrough
312 case "Return-Path":
313 return email.AddAddresses(header, values...)
314 case "Date":
315 if len(values) > 1 {
316 return errors.New("Mail Error: To many dates provided")
317 }
318 return email.SetDate(values[0])
319 default:
320 email.headers[header] = values
321 }
322
323 return nil
324}
325
326// Headers is used to add mulitple headers at once
327func (email *email) AddHeaders(headers textproto.MIMEHeader) error {
328 for header, values := range headers {
329 if err := email.AddHeader(header, values...); err != nil {
330 return err
331 }
332 }
333
334 return nil
335}
336
337// Alternative allows you to add alternative parts to the body
338// of the email message. This is most commonly used to add an
339// html version in addition to a plain text version that was
340// already added with SetBody.
341func (email *email) AddAlternative(contentType, body string) {
342 email.parts = append(email.parts,
343 part{
344 contentType: contentType,
345 body: bytes.NewBufferString(body),
346 },
347 )
348}
349
350// Attach allows you to add an attachment to the email message.
351// You can optionally provide a different name for the file.
352func (email *email) AddAttachment(file string, name ...string) error {
353 if len(name) > 1 {
354 return errors.New("Mail Error: Attach can only have a file and an optional name")
355 }
356
357 return email.attach(file, false, name...)
358}
359
360// Inline allows you to add an inline attachment to the email message.
361// You can optionally provide a different name for the file.
362func (email *email) AddInline(file string, name ...string) error {
363 if len(name) > 1 {
364 return errors.New("Mail Error: Inline can only have a file and an optional name")
365 }
366
367 return email.attach(file, true, name...)
368}
369
370// attach does the low level attaching of the files
371func (email *email) attach(f string, inline bool, name ...string) error {
372 // Get the file data
373 data, err := ioutil.ReadFile(f)
374 if err != nil {
375 return errors.New("Mail Error: Failed to add file with following error: " + err.Error())
376 }
377
378 // get the file mime type
379 mimeType := mime.TypeByExtension(filepath.Ext(f))
380 if mimeType == "" {
381 mimeType = "application/octet-stream"
382 }
383
384 // get the filename
385 _, filename := filepath.Split(f)
386
387 // if an alternative filename was provided, use that instead
388 if len(name) == 1 {
389 filename = name[0]
390 }
391
392 if inline {
393 email.inlines = append(email.inlines, &file{
394 filename: filename,
395 mimeType: mimeType,
396 data: data,
397 })
398 } else {
399 email.attachments = append(email.attachments, &file{
400 filename: filename,
401 mimeType: mimeType,
402 data: data,
403 })
404 }
405
406 return nil
407}
408
409// getFrom returns the sender of the email, if any
410func (email *email) getFrom() string {
411 from := email.returnPath
412 if from == "" {
413 from = email.sender
414 if from == "" {
415 from = email.from
416 if from == "" {
417 from = email.replyTo
418 }
419 }
420 }
421
422 return from
423}
424
425func (email *email) hasMixedPart() bool {
426 return (len(email.parts) > 0 && len(email.attachments) > 0) || len(email.attachments) > 1
427}
428
429func (email *email) hasRelatedPart() bool {
430 return (len(email.parts) > 0 && len(email.inlines) > 0) || len(email.inlines) > 1
431}
432
433func (email *email) hasAlternativePart() bool {
434 return len(email.parts) > 1
435}
436
437// GetMessage builds and returns the email message
438func (email *email) GetMessage() string {
439 msg := newMessage(email)
440
441 if email.hasMixedPart() {
442 msg.openMultipart("mixed")
443 }
444
445 if email.hasRelatedPart() {
446 msg.openMultipart("related")
447 }
448
449 if email.hasAlternativePart() {
450 msg.openMultipart("alternative")
451 }
452
453 for _, part := range email.parts {
454 msg.addBody(part.contentType, part.body.Bytes())
455 }
456
457 if email.hasAlternativePart() {
458 msg.closeMultipart()
459 }
460
461 msg.addFiles(email.inlines, true)
462 if email.hasRelatedPart() {
463 msg.closeMultipart()
464 }
465
466 msg.addFiles(email.attachments, false)
467 if email.hasMixedPart() {
468 msg.closeMultipart()
469 }
470
471 return msg.getHeaders() + msg.body.String()
472}
473
474// Send sends the composed email
475func (email *email) Send(address string) error {
476 var auth smtp.Auth
477
478 from := email.getFrom()
479 if from == "" {
480 return errors.New(`Mail Error: No "From" address specified.`)
481 }
482
483 if len(email.recipients) < 1 {
484 return errors.New("Mail Error: No recipient specified.")
485 }
486
487 msg := email.GetMessage()
488
489 host, port, err := net.SplitHostPort(address)
490 if err != nil {
491 return errors.New("Mail Error: " + err.Error())
492 }
493
494 if email.Username != "" || email.Password != "" {
495 auth = smtp.PlainAuth("", email.Username, email.Password, host)
496 }
497
498 return send(host, port, from, email.recipients, msg, auth, email.Encryption, email.TLSConfig, email.ConnectTimeout)
499}
500
501// dial connects to the smtp server with the request encryption type
502func dial(host string, port string, encryption encryption, config *tls.Config) (*smtp.Client, error) {
503 var conn net.Conn
504 var err error
505
506 address := host + ":" + port
507
508 // do the actual dial
509 switch encryption {
510 case EncryptionSSL:
511 conn, err = tls.Dial("tcp", address, config)
512 default:
513 conn, err = net.Dial("tcp", address)
514 }
515
516 if err != nil {
517 return nil, errors.New("Mail Error on dailing with encryption type " + encryption.String() + ": " + err.Error())
518 }
519
520 c, err := smtp.NewClient(conn, host)
521
522 if err != nil {
523 return nil, errors.New("Mail Error on smtp dial: " + err.Error())
524 }
525
526 return c, err
527}
528
529// smtpConnect connects to the smtp server and starts TLS and passes auth
530// if necessary
531func smtpConnect(host string, port string, from string, to []string, msg string, auth smtp.Auth, encryption encryption, config *tls.Config) (*smtp.Client, error) {
532 // connect to the mail server
533 c, err := dial(host, port, encryption, config)
534
535 if err != nil {
536 return nil, err
537 }
538
539 // send Hello
540 if err = c.Hello("localhost"); err != nil {
541 c.Close()
542 return nil, errors.New("Mail Error on Hello: " + err.Error())
543 }
544
545 // start TLS if necessary
546 if encryption == EncryptionTLS {
547 if ok, _ := c.Extension("STARTTLS"); ok {
548 if config.ServerName == "" {
549 config = &tls.Config{ServerName: host}
550 }
551
552 if err = c.StartTLS(config); err != nil {
553 c.Close()
554 return nil, errors.New("Mail Error on Start TLS: " + err.Error())
555 }
556 }
557 }
558
559 // pass the authentication if necessary
560 if auth != nil {
561 if ok, _ := c.Extension("AUTH"); ok {
562 if err = c.Auth(auth); err != nil {
563 c.Close()
564 return nil, errors.New("Mail Error on Auth: " + err.Error())
565 }
566 }
567 }
568
569 return c, nil
570}
571
572type smtpConnectErrorChannel struct {
573 client *smtp.Client
574 err error
575}
576
577// send does the low level sending of the email
578func send(host string, port string, from string, to []string, msg string, auth smtp.Auth, encryption encryption, config *tls.Config, connectTimeout int) error {
579 var smtpConnectChannel chan smtpConnectErrorChannel
580 var c *smtp.Client = nil
581 var err error
582
583 // set the timeout value
584 timeout := time.Duration(connectTimeout) * time.Second
585
586 // if there is a timeout, setup the channel and do the connect under a goroutine
587 if timeout != 0 {
588 smtpConnectChannel = make(chan smtpConnectErrorChannel, 2)
589 go func() {
590 c, err = smtpConnect(host, port, from, to, msg, auth, encryption, config)
591 // send the result
592 smtpConnectChannel <- smtpConnectErrorChannel{
593 client: c,
594 err: err,
595 }
596 }()
597 }
598
599 if timeout == 0 {
600 // no timeout, just fire the connect
601 c, err = smtpConnect(host, port, from, to, msg, auth, encryption, config)
602 } else {
603 // get the connect result or timeout result, which ever happens first
604 select {
605 case result := <-smtpConnectChannel:
606 c = result.client
607 err = result.err
608 case <-time.After(timeout):
609 return errors.New("Mail Error: SMTP Connection timed out")
610 }
611 }
612
613 // check for connect error
614 if err != nil {
615 return err
616 }
617
618 defer c.Close()
619
620 // Set the sender
621 if err := c.Mail(from); err != nil {
622 return err
623 }
624
625 // Set the recipients
626 for _, address := range to {
627 if err := c.Rcpt(address); err != nil {
628 return err
629 }
630 }
631
632 // Send the data command
633 w, err := c.Data()
634 if err != nil {
635 return err
636 }
637
638 // write the message
639 _, err = fmt.Fprint(w, msg)
640 if err != nil {
641 return err
642 }
643
644 err = w.Close()
645 if err != nil {
646 return err
647 }
648
649 return c.Quit()
650}
651
652type message struct {
653 headers textproto.MIMEHeader
654 body *bytes.Buffer
655 writers []*multipart.Writer
656 parts uint8
657 cids map[string]string
658 charset string
659 encoding encoding
660}
661
662func newMessage(email *email) *message {
663 return &message{
664 headers: email.headers,
665 body: new(bytes.Buffer),
666 cids: make(map[string]string),
667 charset: email.Charset,
668 encoding: email.Encoding}
669}
670
671func encodeHeader(text string, charset string, usedChars int) string {
672 // create buffer
673 buf := new(bytes.Buffer)
674
675 // encode
676 encoder := header.NewEncoder(buf, charset, usedChars)
677 encoder.Encode([]byte(text))
678
679 return buf.String()
680
681 /*
682 switch encoding {
683 case EncodingBase64:
684 return mime.BEncoding.Encode(charset, text)
685 default:
686 return mime.QEncoding.Encode(charset, text)
687 }
688 */
689}
690
691// getHeaders returns the message headers
692func (msg *message) getHeaders() (headers string) {
693 // if the date header isn't set, set it
694 if date := msg.headers.Get("Date"); date == "" {
695 msg.headers.Set("Date", time.Now().Format(time.RFC1123Z))
696 }
697
698 // encode and combine the headers
699 for header, values := range msg.headers {
700 headers += header + ": " + encodeHeader(strings.Join(values, ", "), msg.charset, len(header) + 2) + "\r\n"
701 }
702
703 headers = headers + "\r\n"
704
705 return
706}
707
708// getCID gets the generated CID for the provided text
709func (msg *message) getCID(text string) (cid string) {
710 // set the date format to use
711 const dateFormat = "20060102.150405"
712
713 // get the cid if we have one
714 cid, exists := msg.cids[text]
715 if !exists {
716 // generate a new cid
717 cid = time.Now().Format(dateFormat) + "." + strconv.Itoa(len(msg.cids)+1) + "@mail.0"
718 // save it
719 msg.cids[text] = cid
720 }
721
722 return
723}
724
725// replaceCIDs replaces the CIDs found in a text string
726// with generated ones
727func (msg *message) replaceCIDs(text string) string {
728 // regular expression to find cids
729 re := regexp.MustCompile(`(src|href)="cid:(.*?)"`)
730 // replace all of the found cids with generated ones
731 for _, matches := range re.FindAllStringSubmatch(text, -1) {
732 cid := msg.getCID(matches[2])
733 text = strings.Replace(text, "cid:"+matches[2], "cid:"+cid, -1)
734 }
735
736 return text
737}
738
739// openMultipart creates a new part of a multipart message
740func (msg *message) openMultipart(multipartType string) {
741 // create a new multipart writer
742 msg.writers = append(msg.writers, multipart.NewWriter(msg.body))
743 // create the boundary
744 contentType := "multipart/" + multipartType + ";\n \tboundary=" + msg.writers[msg.parts].Boundary()
745
746 // if no existing parts, add header to main header group
747 if msg.parts == 0 {
748 msg.headers.Set("Content-Type", contentType)
749 } else { // add header to multipart section
750 header := make(textproto.MIMEHeader)
751 header.Set("Content-Type", contentType)
752 msg.writers[msg.parts-1].CreatePart(header)
753 }
754
755 msg.parts++
756}
757
758// closeMultipart closes a part of a multipart message
759func (msg *message) closeMultipart() {
760 if msg.parts > 0 {
761 msg.writers[msg.parts-1].Close()
762 msg.parts--
763 }
764}
765
766// base64Encode base64 encodes the provided text with line wrapping
767func base64Encode(text []byte) []byte {
768 // create buffer
769 buf := new(bytes.Buffer)
770
771 // create base64 encoder that linewraps
772 encoder := base64.NewEncoder(base64.StdEncoding, &base64LineWrap{writer: buf})
773
774 // write the encoded text to buf
775 encoder.Write(text)
776 encoder.Close()
777
778 return buf.Bytes()
779}
780
781// qpEncode uses the quoted-printable encoding to encode the provided text
782func qpEncode(text []byte) []byte {
783 // create buffer
784 buf := new(bytes.Buffer)
785
786 encoder := quotedprintable.NewWriter(buf)
787
788 encoder.Write(text)
789 encoder.Close()
790
791 return buf.Bytes()
792}
793
794const maxLineChars = 76
795
796type base64LineWrap struct {
797 writer io.Writer
798 numLineChars int
799}
800
801func (e *base64LineWrap) Write(p []byte) (n int, err error) {
802 n = 0
803 // while we have more chars than are allowed
804 for len(p)+e.numLineChars > maxLineChars {
805 numCharsToWrite := maxLineChars - e.numLineChars
806 // write the chars we can
807 e.writer.Write(p[:numCharsToWrite])
808 // write a line break
809 e.writer.Write([]byte("\r\n"))
810 // reset the line count
811 e.numLineChars = 0
812 // remove the chars that have been written
813 p = p[numCharsToWrite:]
814 // set the num of chars written
815 n += numCharsToWrite
816 }
817
818 // write what is left
819 e.writer.Write(p)
820 e.numLineChars += len(p)
821 n += len(p)
822
823 return
824}
825
826func (msg *message) write(header textproto.MIMEHeader, body []byte, encoding encoding) {
827 msg.writeHeader(header)
828 msg.writeBody(body, encoding)
829}
830
831func (msg *message) writeHeader(headers textproto.MIMEHeader) {
832 // if there are no parts add header to main headers
833 if msg.parts == 0 {
834 for header, value := range headers {
835 msg.headers[header] = value
836 }
837 } else { // add header to multipart section
838 msg.writers[msg.parts-1].CreatePart(headers)
839 }
840}
841
842func (msg *message) writeBody(body []byte, encoding encoding) {
843 // encode and write the body
844 switch encoding {
845 case EncodingQuotedPrintable:
846 msg.body.Write(qpEncode(body))
847 case EncodingBase64:
848 msg.body.Write(base64Encode(body))
849 default:
850 msg.body.Write(body)
851 }
852}
853
854func (msg *message) addBody(contentType string, body []byte) {
855 body = []byte(msg.replaceCIDs(string(body)))
856
857 header := make(textproto.MIMEHeader)
858 header.Set("Content-Type", contentType+"; charset="+msg.charset)
859 header.Set("Content-Transfer-Encoding", msg.encoding.String())
860 msg.write(header, body, msg.encoding)
861}
862
863var quoteEscaper = strings.NewReplacer("\\", "\\\\", `"`, "\\\"")
864
865func escapeQuotes(s string) string {
866 return quoteEscaper.Replace(s)
867}
868
869func (msg *message) addFiles(files []*file, inline bool) {
870 encoding := EncodingBase64
871 for _, file := range files {
872 header := make(textproto.MIMEHeader)
873 header.Set("Content-Type", file.mimeType+";\n \tname=\""+encodeHeader(escapeQuotes(file.filename), msg.charset, 6)+`"`)
874 header.Set("Content-Transfer-Encoding", encoding.String())
875 if inline {
876 header.Set("Content-Disposition", "inline;\n \tfilename=\""+encodeHeader(escapeQuotes(file.filename), msg.charset, 10)+`"`)
877 header.Set("Content-ID", "<"+msg.getCID(file.filename)+">")
878 } else {
879 header.Set("Content-Disposition", "attachment;\n \tfilename=\""+encodeHeader(escapeQuotes(file.filename), msg.charset, 10)+`"`)
880 }
881
882 msg.write(header, file.data, encoding)
883 }
884}