github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/worker/mails/mail_test.go (about)

     1  package mails
     2  
     3  import (
     4  	"bufio"
     5  	"bytes"
     6  	"errors"
     7  	"net"
     8  	"net/textproto"
     9  	"strconv"
    10  	"strings"
    11  	"testing"
    12  	"time"
    13  
    14  	"github.com/cozy/cozy-stack/model/instance/lifecycle"
    15  	"github.com/cozy/cozy-stack/model/job"
    16  	"github.com/cozy/cozy-stack/pkg/config/config"
    17  	"github.com/cozy/cozy-stack/pkg/mail"
    18  	"github.com/cozy/cozy-stack/tests/testutils"
    19  	"github.com/cozy/gomail"
    20  	"github.com/stretchr/testify/assert"
    21  )
    22  
    23  const serverString = `220 hello world
    24  502 EH?
    25  250 smtp.me at your service
    26  250 Sender ok
    27  250 Receiver ok
    28  354 Go ahead
    29  250 Data ok
    30  221 Goodbye
    31  `
    32  
    33  func TestMails(t *testing.T) {
    34  	if testing.Short() {
    35  		t.Skip("an instance is required for this test: test skipped due to the use of --short flag")
    36  	}
    37  
    38  	config.UseTestFile(t)
    39  
    40  	setup := testutils.NewSetup(t, t.Name())
    41  
    42  	inst := setup.GetTestInstance(&lifecycle.Options{Email: "me@me"})
    43  
    44  	t.Run("mail send server", func(t *testing.T) {
    45  		clientStrings := []string{`EHLO localhost
    46  HELO localhost
    47  MAIL FROM:<me@me>
    48  RCPT TO:<you1@you>
    49  DATA
    50  Hey !!!
    51  .
    52  QUIT
    53  `}
    54  
    55  		expectedHeaders := map[string]string{
    56  			"From":                      "me@me",
    57  			"To":                        "you1@you",
    58  			"Subject":                   "Up?",
    59  			"Date":                      "Mon, 01 Jan 0001 00:00:00 +0000",
    60  			"Content-Transfer-Encoding": "quoted-printable",
    61  			"Content-Type":              "text/plain; charset=UTF-8",
    62  			"Mime-Version":              "1.0",
    63  			"X-Cozy":                    "cozy.example.com",
    64  		}
    65  
    66  		mailServer(t, serverString, clientStrings, expectedHeaders, func(host string, port int) error {
    67  			msg := &mail.Options{
    68  				From: &mail.Address{Email: "me@me"},
    69  				To: []*mail.Address{
    70  					{Email: "you1@you"},
    71  				},
    72  				Date:    &time.Time{},
    73  				Subject: "Up?",
    74  				Dialer: &gomail.DialerOptions{
    75  					Host:       host,
    76  					Port:       port,
    77  					DisableTLS: true,
    78  				},
    79  				Parts: []*mail.Part{
    80  					{
    81  						Body: "Hey !!!",
    82  						Type: "text/plain",
    83  					},
    84  				},
    85  				Locale: "en",
    86  			}
    87  			j := &job.Job{JobID: "1", Domain: "cozy.example.com"}
    88  			ctx, cancel := job.NewTaskContext("0", j, nil)
    89  			defer cancel()
    90  			return sendMail(ctx, msg, "cozy.example.com")
    91  		})
    92  	})
    93  
    94  	t.Run("send template mail", func(t *testing.T) {
    95  		clientStrings := []string{`EHLO localhost
    96  HELO localhost
    97  MAIL FROM:<me@me>
    98  RCPT TO:<you1@you>
    99  DATA
   100  <!DOCTYPE html>
   101  <html>
   102    <head>
   103      <meta charset=3D"UTF-8">
   104      <title>My page</title>
   105    </head>
   106    <body>
   107      <div>My photos</div><div>My blog</div>
   108    </body>
   109  </html>
   110  .
   111  QUIT
   112  `}
   113  
   114  		expectedHeaders := map[string]string{
   115  			"From":                      "me@me",
   116  			"To":                        "you1@you",
   117  			"Subject":                   "Up?",
   118  			"Date":                      "Mon, 01 Jan 0001 00:00:00 +0000",
   119  			"Content-Transfer-Encoding": "quoted-printable",
   120  			"Content-Type":              "text/html; charset=UTF-8",
   121  			"Mime-Version":              "1.0",
   122  			"X-Cozy":                    "cozy.example.com",
   123  		}
   124  
   125  		mailBody := `<!DOCTYPE html>
   126  <html>
   127    <head>
   128      <meta charset="UTF-8">
   129      <title>My page</title>
   130    </head>
   131    <body>
   132      <div>My photos</div><div>My blog</div>
   133    </body>
   134  </html>
   135  `
   136  
   137  		mailServer(t, serverString, clientStrings, expectedHeaders, func(host string, port int) error {
   138  			msg := &mail.Options{
   139  				From: &mail.Address{Email: "me@me"},
   140  				To: []*mail.Address{
   141  					{Email: "you1@you"},
   142  				},
   143  				Date:    &time.Time{},
   144  				Subject: "Up?",
   145  				Dialer: &gomail.DialerOptions{
   146  					Host:       host,
   147  					Port:       port,
   148  					DisableTLS: true,
   149  				},
   150  				Parts: []*mail.Part{
   151  					{Body: mailBody, Type: "text/html"},
   152  				},
   153  				Locale: "en",
   154  			}
   155  			j := &job.Job{JobID: "1", Domain: "cozy.example.com"}
   156  			ctx, cancel := job.NewTaskContext("0", j, nil)
   157  			defer cancel()
   158  			return sendMail(ctx, msg, "cozy.example.com")
   159  		})
   160  	})
   161  
   162  	t.Run("with missing subject", func(t *testing.T) {
   163  		msg := &mail.Options{
   164  			From:   &mail.Address{Email: "me@me"},
   165  			To:     []*mail.Address{{Email: "you@you"}},
   166  			Locale: "en",
   167  		}
   168  		j := &job.Job{JobID: "1", Domain: "cozy.example.com"}
   169  		ctx, cancel := job.NewTaskContext("0", j, nil)
   170  		defer cancel()
   171  		err := sendMail(ctx, msg, "cozy.example.com")
   172  		if assert.Error(t, err) {
   173  			assert.Equal(t, "Missing mail subject", err.Error())
   174  		}
   175  	})
   176  
   177  	t.Run("with bad body type", func(t *testing.T) {
   178  		msg := &mail.Options{
   179  			From:    &mail.Address{Email: "me@me"},
   180  			To:      []*mail.Address{{Email: "you@you"}},
   181  			Subject: "Up?",
   182  			Parts: []*mail.Part{
   183  				{
   184  					Type: "text/qsdqsd",
   185  					Body: "foo",
   186  				},
   187  			},
   188  			Locale: "en",
   189  		}
   190  		j := &job.Job{JobID: "1", Domain: "cozy.example.com"}
   191  		ctx, cancel := job.NewTaskContext("0", j, nil)
   192  		defer cancel()
   193  		err := sendMail(ctx, msg, "cozy.example.com")
   194  		if assert.Error(t, err) {
   195  			assert.Equal(t, "Unknown body content-type text/qsdqsd", err.Error())
   196  		}
   197  	})
   198  
   199  	t.Run("send with NoReply", func(t *testing.T) {
   200  		sendMail = func(_ *job.TaskContext, opts *mail.Options, domain string) error {
   201  			assert.NotNil(t, opts.From)
   202  			assert.NotNil(t, opts.To)
   203  			assert.Len(t, opts.To, 1)
   204  			assert.Equal(t, "me@me", opts.To[0].Email)
   205  			assert.Equal(t, "noreply@"+inst.Domain, opts.From.Email)
   206  			assert.Equal(t, inst.Domain, domain)
   207  			return errors.New("yes")
   208  		}
   209  		defer func() {
   210  			sendMail = doSendMail
   211  		}()
   212  		msg, _ := job.NewMessage(mail.Options{
   213  			Mode:    "noreply",
   214  			Subject: "Up?",
   215  			Parts: []*mail.Part{
   216  				{
   217  					Type: "text/plain",
   218  					Body: "foo",
   219  				},
   220  			},
   221  			Locale: "en",
   222  		})
   223  		j := job.NewJob(inst, &job.JobRequest{
   224  			Message:    msg,
   225  			WorkerType: "sendmail",
   226  		})
   227  		ctx, cancel := job.NewTaskContext("123", j, inst)
   228  		defer cancel()
   229  		err := SendMail(ctx)
   230  		if assert.Error(t, err) {
   231  			assert.Equal(t, "yes", err.Error())
   232  		}
   233  	})
   234  
   235  	t.Run("send with From", func(t *testing.T) {
   236  		sendMail = func(_ *job.TaskContext, opts *mail.Options, domain string) error {
   237  			assert.NotNil(t, opts.From)
   238  			assert.NotNil(t, opts.To)
   239  			assert.Len(t, opts.To, 1)
   240  			assert.Equal(t, "you@you", opts.To[0].Email)
   241  			assert.Equal(t, "noreply@"+inst.Domain, opts.From.Email)
   242  			assert.Equal(t, "me@me", opts.ReplyTo.Email)
   243  			assert.Equal(t, inst.Domain, domain)
   244  			return errors.New("yes")
   245  		}
   246  		defer func() {
   247  			sendMail = doSendMail
   248  		}()
   249  		msg, _ := job.NewMessage(mail.Options{
   250  			Mode:    "from",
   251  			Subject: "Up?",
   252  			To:      []*mail.Address{{Email: "you@you"}},
   253  			Parts: []*mail.Part{
   254  				{
   255  					Type: "text/plain",
   256  					Body: "foo",
   257  				},
   258  			},
   259  			Locale: "en",
   260  		})
   261  		j := job.NewJob(inst, &job.JobRequest{
   262  			Message:    msg,
   263  			WorkerType: "sendmail",
   264  		})
   265  		ctx, cancel := job.NewTaskContext("123", j, inst)
   266  		defer cancel()
   267  		err := SendMail(ctx)
   268  		if assert.Error(t, err) {
   269  			assert.Equal(t, "yes", err.Error())
   270  		}
   271  	})
   272  
   273  	t.Run("send campaign email", func(t *testing.T) {
   274  		sendMail = func(_ *job.TaskContext, opts *mail.Options, domain string) error {
   275  			assert.NotNil(t, opts.From)
   276  			assert.NotNil(t, opts.To)
   277  			assert.Len(t, opts.To, 1)
   278  			assert.Equal(t, "me@me", opts.To[0].Email)
   279  			assert.Equal(t, "noreply@"+inst.Domain, opts.From.Email)
   280  			assert.Equal(t, inst.Domain, domain)
   281  			return errors.New("yes")
   282  		}
   283  		defer func() {
   284  			sendMail = doSendMail
   285  		}()
   286  		msg, _ := job.NewMessage(mail.Options{
   287  			Mode:    mail.ModeCampaign,
   288  			Subject: "Awesome content",
   289  			Parts: []*mail.Part{
   290  				{
   291  					Type: "text/plain",
   292  					Body: "foo",
   293  				},
   294  			},
   295  			Locale: "en",
   296  		})
   297  		j := job.NewJob(inst, &job.JobRequest{
   298  			Message:    msg,
   299  			WorkerType: "sendmail",
   300  		})
   301  		ctx, cancel := job.NewTaskContext("123", j, inst)
   302  		defer cancel()
   303  		err := SendMail(ctx)
   304  		if assert.Error(t, err) {
   305  			assert.Equal(t, "yes", err.Error())
   306  		}
   307  	})
   308  }
   309  
   310  func mailServer(t *testing.T, serverString string, clientStrings []string, expectedHeader map[string]string, send func(string, int) error) {
   311  	serverString = strings.Join(strings.Split(serverString, "\n"), "\r\n")
   312  	for i, s := range clientStrings {
   313  		clientStrings[i] = strings.Join(strings.Split(s, "\n"), "\r\n")
   314  	}
   315  
   316  	var cmdbuf bytes.Buffer
   317  	bcmdbuf := bufio.NewWriter(&cmdbuf)
   318  	headers := make(map[string]string)
   319  	l, err := net.Listen("tcp", "127.0.0.1:0")
   320  	if err != nil {
   321  		t.Fatalf("Unable to to create listener: %v", err)
   322  	}
   323  	defer l.Close()
   324  
   325  	// prevent data race on bcmdbuf
   326  	done := make(chan struct{})
   327  	go func(data []string) {
   328  		defer close(done)
   329  
   330  		conn, err := l.Accept()
   331  		if err != nil {
   332  			t.Errorf("Accept error: %v", err)
   333  			return
   334  		}
   335  		defer conn.Close()
   336  
   337  		tc := textproto.NewConn(conn)
   338  		readdata := false
   339  		readhead := false
   340  		for i := 0; i < len(data) && data[i] != ""; i++ {
   341  			_ = tc.PrintfLine(data[i])
   342  			for len(data[i]) >= 4 && data[i][3] == '-' {
   343  				i++
   344  				_ = tc.PrintfLine(data[i])
   345  			}
   346  			if data[i] == "221 Goodbye" {
   347  				return
   348  			}
   349  			read := false
   350  			for !read || data[i] == "354 Go ahead" {
   351  				msg, err := tc.ReadLine()
   352  				if readdata && msg != "." {
   353  					if msg == "" {
   354  						readhead = true
   355  						read = true
   356  						continue
   357  					}
   358  					// skip multipart --boundaries
   359  					if readhead &&
   360  						(len(msg) <= 1 || msg[0] != '-' || msg[1] != '-') {
   361  						_, _ = bcmdbuf.Write([]byte(msg + "\r\n"))
   362  					} else {
   363  						parts := strings.SplitN(msg, ": ", 2)
   364  						if len(parts) == 2 {
   365  							headers[parts[0]] = parts[1]
   366  						}
   367  					}
   368  				} else {
   369  					if msg == "." {
   370  						readdata = false
   371  					}
   372  					if msg == "DATA" {
   373  						readdata = true
   374  					}
   375  					_, _ = bcmdbuf.Write([]byte(msg + "\r\n"))
   376  					read = true
   377  				}
   378  				if err != nil {
   379  					t.Errorf("Read error: %v", err)
   380  					return
   381  				}
   382  				if data[i] == "354 Go ahead" && msg == "." {
   383  					break
   384  				}
   385  			}
   386  		}
   387  	}(strings.Split(serverString, "\r\n"))
   388  
   389  	host, port, _ := net.SplitHostPort(l.Addr().String())
   390  	portI, _ := strconv.Atoi(port)
   391  	if err := send(host, portI); err != nil {
   392  		t.Errorf("%v", err)
   393  	}
   394  
   395  	<-done
   396  	bcmdbuf.Flush()
   397  	actualcmds := cmdbuf.String()
   398  	for _, s := range clientStrings {
   399  		assert.Contains(t, actualcmds, s)
   400  	}
   401  	assert.EqualValues(t, expectedHeader, headers)
   402  }