...

Source file src/github.com/prometheus/alertmanager/notify/email/email_test.go

Documentation: github.com/prometheus/alertmanager/notify/email

     1  // Copyright 2019 Prometheus Team
     2  // Licensed under the Apache License, Version 2.0 (the "License");
     3  // you may not use this file except in compliance with the License.
     4  // You may obtain a copy of the License at
     5  //
     6  // http://www.apache.org/licenses/LICENSE-2.0
     7  //
     8  // Unless required by applicable law or agreed to in writing, software
     9  // distributed under the License is distributed on an "AS IS" BASIS,
    10  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    11  // See the License for the specific language governing permissions and
    12  // limitations under the License.
    13  
    14  // Some tests require a running mail catcher. We use MailDev for this purpose,
    15  // it can work without or with authentication (LOGIN only). It exposes a REST
    16  // API which we use to retrieve and check the sent emails.
    17  //
    18  // Those tests are only executed when specific environment variables are set,
    19  // otherwise they are skipped. The tests must be run by the CI.
    20  //
    21  // To run the tests locally, you should start 2 MailDev containers:
    22  //
    23  // $ docker run --rm -p 1080:1080 -p 1025:1025 --entrypoint bin/maildev djfarrelly/maildev@sha256:624e0ec781e11c3531da83d9448f5861f258ee008c1b2da63b3248bfd680acfa -v
    24  // $ docker run --rm -p 1081:1080 -p 1026:1025 --entrypoint bin/maildev djfarrelly/maildev@sha256:624e0ec781e11c3531da83d9448f5861f258ee008c1b2da63b3248bfd680acfa --incoming-user user --incoming-pass pass -v
    25  //
    26  // $ EMAIL_NO_AUTH_CONFIG=testdata/noauth.yml EMAIL_AUTH_CONFIG=testdata/auth.yml make
    27  //
    28  // See also https://github.com/djfarrelly/MailDev for more details.
    29  package email
    30  
    31  import (
    32  	"context"
    33  	"fmt"
    34  	"io"
    35  	"net/http"
    36  	"net/url"
    37  	"os"
    38  	"strings"
    39  	"testing"
    40  	"time"
    41  
    42  	"github.com/go-kit/log"
    43  	commoncfg "github.com/prometheus/common/config"
    44  	"github.com/prometheus/common/model"
    45  	"github.com/stretchr/testify/require"
    46  	"gopkg.in/yaml.v2"
    47  
    48  	"github.com/prometheus/alertmanager/config"
    49  	"github.com/prometheus/alertmanager/template"
    50  	"github.com/prometheus/alertmanager/types"
    51  )
    52  
    53  const (
    54  	emailNoAuthConfigVar = "EMAIL_NO_AUTH_CONFIG"
    55  	emailAuthConfigVar   = "EMAIL_AUTH_CONFIG"
    56  
    57  	emailTo   = "alerts@example.com"
    58  	emailFrom = "alertmanager@example.com"
    59  )
    60  
    61  // email represents an email returned by the MailDev REST API.
    62  // See https://github.com/djfarrelly/MailDev/blob/master/docs/rest.md.
    63  type email struct {
    64  	To      []map[string]string
    65  	From    []map[string]string
    66  	Subject string
    67  	HTML    *string
    68  	Text    *string
    69  	Headers map[string]string
    70  }
    71  
    72  // mailDev is a client for the MailDev server.
    73  type mailDev struct {
    74  	*url.URL
    75  }
    76  
    77  func (m *mailDev) UnmarshalYAML(unmarshal func(interface{}) error) error {
    78  	var s string
    79  	if err := unmarshal(&s); err != nil {
    80  		return err
    81  	}
    82  	urlp, err := url.Parse(s)
    83  	if err != nil {
    84  		return err
    85  	}
    86  	m.URL = urlp
    87  	return nil
    88  }
    89  
    90  // getLastEmail returns the last received email.
    91  func (m *mailDev) getLastEmail() (*email, error) {
    92  	code, b, err := m.doEmailRequest(http.MethodGet, "/email")
    93  	if err != nil {
    94  		return nil, err
    95  	}
    96  	if code != http.StatusOK {
    97  		return nil, fmt.Errorf("expected status OK, got %d", code)
    98  	}
    99  
   100  	var emails []email
   101  	err = yaml.Unmarshal(b, &emails)
   102  	if err != nil {
   103  		return nil, err
   104  	}
   105  	if len(emails) == 0 {
   106  		return nil, nil
   107  	}
   108  	return &emails[len(emails)-1], nil
   109  }
   110  
   111  // deleteAllEmails deletes all emails.
   112  func (m *mailDev) deleteAllEmails() error {
   113  	_, _, err := m.doEmailRequest(http.MethodDelete, "/email/all")
   114  	return err
   115  }
   116  
   117  // doEmailRequest makes a request to the MailDev API.
   118  func (m *mailDev) doEmailRequest(method, path string) (int, []byte, error) {
   119  	req, err := http.NewRequest(method, fmt.Sprintf("%s://%s%s", m.Scheme, m.Host, path), nil)
   120  	if err != nil {
   121  		return 0, nil, err
   122  	}
   123  	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
   124  	req = req.WithContext(ctx)
   125  	defer cancel()
   126  	res, err := http.DefaultClient.Do(req)
   127  	if err != nil {
   128  		return 0, nil, err
   129  	}
   130  	defer res.Body.Close()
   131  	b, err := io.ReadAll(res.Body)
   132  	if err != nil {
   133  		return 0, nil, err
   134  	}
   135  	return res.StatusCode, b, nil
   136  }
   137  
   138  // emailTestConfig is the configuration for the tests.
   139  type emailTestConfig struct {
   140  	Smarthost config.HostPort `yaml:"smarthost"`
   141  	Username  string          `yaml:"username"`
   142  	Password  string          `yaml:"password"`
   143  	Server    *mailDev        `yaml:"server"`
   144  }
   145  
   146  func loadEmailTestConfiguration(f string) (emailTestConfig, error) {
   147  	c := emailTestConfig{}
   148  	b, err := os.ReadFile(f)
   149  	if err != nil {
   150  		return c, err
   151  	}
   152  
   153  	err = yaml.UnmarshalStrict(b, &c)
   154  	if err != nil {
   155  		return c, err
   156  	}
   157  
   158  	return c, nil
   159  }
   160  
   161  func notifyEmail(cfg *config.EmailConfig, server *mailDev) (*email, bool, error) {
   162  	return notifyEmailWithContext(context.Background(), cfg, server)
   163  }
   164  
   165  // notifyEmailWithContext sends a notification with one firing alert and retrieves the
   166  // email from the SMTP server if the notification has been successfully delivered.
   167  func notifyEmailWithContext(ctx context.Context, cfg *config.EmailConfig, server *mailDev) (*email, bool, error) {
   168  	if cfg.RequireTLS == nil {
   169  		cfg.RequireTLS = new(bool)
   170  	}
   171  	if cfg.Headers == nil {
   172  		cfg.Headers = make(map[string]string)
   173  	}
   174  	firingAlert := &types.Alert{
   175  		Alert: model.Alert{
   176  			Labels:   model.LabelSet{},
   177  			StartsAt: time.Now(),
   178  			EndsAt:   time.Now().Add(time.Hour),
   179  		},
   180  	}
   181  	err := server.deleteAllEmails()
   182  	if err != nil {
   183  		return nil, false, err
   184  	}
   185  
   186  	tmpl, err := template.FromGlobs()
   187  	if err != nil {
   188  		return nil, false, err
   189  	}
   190  	tmpl.ExternalURL, _ = url.Parse("http://am")
   191  	email := New(cfg, tmpl, log.NewNopLogger())
   192  
   193  	retry, err := email.Notify(ctx, firingAlert)
   194  	if err != nil {
   195  		return nil, retry, err
   196  	}
   197  
   198  	e, err := server.getLastEmail()
   199  	if err != nil {
   200  		return nil, retry, err
   201  	} else if e == nil {
   202  		return nil, retry, fmt.Errorf("email not found")
   203  	}
   204  	return e, retry, nil
   205  }
   206  
   207  // TestEmailNotifyWithErrors tries to send emails with buggy inputs.
   208  func TestEmailNotifyWithErrors(t *testing.T) {
   209  	cfgFile := os.Getenv(emailNoAuthConfigVar)
   210  	if len(cfgFile) == 0 {
   211  		t.Skipf("%s not set", emailNoAuthConfigVar)
   212  	}
   213  
   214  	c, err := loadEmailTestConfiguration(cfgFile)
   215  	if err != nil {
   216  		t.Fatal(err)
   217  	}
   218  
   219  	for _, tc := range []struct {
   220  		title     string
   221  		updateCfg func(*config.EmailConfig)
   222  
   223  		errMsg   string
   224  		hasEmail bool
   225  	}{
   226  		{
   227  			title: "invalid 'from' template",
   228  			updateCfg: func(cfg *config.EmailConfig) {
   229  				cfg.From = `{{ template "invalid" }}`
   230  			},
   231  			errMsg: "execute 'from' template:",
   232  		},
   233  		{
   234  			title: "invalid 'from' address",
   235  			updateCfg: func(cfg *config.EmailConfig) {
   236  				cfg.From = `xxx`
   237  			},
   238  			errMsg: "parse 'from' addresses:",
   239  		},
   240  		{
   241  			title: "invalid 'to' template",
   242  			updateCfg: func(cfg *config.EmailConfig) {
   243  				cfg.To = `{{ template "invalid" }}`
   244  			},
   245  			errMsg: "execute 'to' template:",
   246  		},
   247  		{
   248  			title: "invalid 'to' address",
   249  			updateCfg: func(cfg *config.EmailConfig) {
   250  				cfg.To = `xxx`
   251  			},
   252  			errMsg: "parse 'to' addresses:",
   253  		},
   254  		{
   255  			title: "invalid 'subject' template",
   256  			updateCfg: func(cfg *config.EmailConfig) {
   257  				cfg.Headers["subject"] = `{{ template "invalid" }}`
   258  			},
   259  			errMsg:   `execute "subject" header template:`,
   260  			hasEmail: true,
   261  		},
   262  		{
   263  			title: "invalid 'text' template",
   264  			updateCfg: func(cfg *config.EmailConfig) {
   265  				cfg.Text = `{{ template "invalid" }}`
   266  			},
   267  			errMsg:   `execute text template:`,
   268  			hasEmail: true,
   269  		},
   270  		{
   271  			title: "invalid 'html' template",
   272  			updateCfg: func(cfg *config.EmailConfig) {
   273  				cfg.HTML = `{{ template "invalid" }}`
   274  			},
   275  			errMsg:   `execute html template:`,
   276  			hasEmail: true,
   277  		},
   278  	} {
   279  		tc := tc
   280  		t.Run(tc.title, func(t *testing.T) {
   281  			if len(tc.errMsg) == 0 {
   282  				t.Fatal("please define the expected error message")
   283  				return
   284  			}
   285  
   286  			emailCfg := &config.EmailConfig{
   287  				Smarthost: c.Smarthost,
   288  				To:        emailTo,
   289  				From:      emailFrom,
   290  				HTML:      "HTML body",
   291  				Text:      "Text body",
   292  				Headers: map[string]string{
   293  					"Subject": "{{ len .Alerts }} {{ .Status }} alert(s)",
   294  				},
   295  			}
   296  			if tc.updateCfg != nil {
   297  				tc.updateCfg(emailCfg)
   298  			}
   299  
   300  			_, retry, err := notifyEmail(emailCfg, c.Server)
   301  			require.Error(t, err)
   302  			require.Contains(t, err.Error(), tc.errMsg)
   303  			require.Equal(t, false, retry)
   304  
   305  			e, err := c.Server.getLastEmail()
   306  			require.NoError(t, err)
   307  			if tc.hasEmail {
   308  				require.NotNil(t, e)
   309  			} else {
   310  				require.Nil(t, e)
   311  			}
   312  		})
   313  	}
   314  }
   315  
   316  // TestEmailNotifyWithDoneContext tries to send an email with a context that is done.
   317  func TestEmailNotifyWithDoneContext(t *testing.T) {
   318  	cfgFile := os.Getenv(emailNoAuthConfigVar)
   319  	if len(cfgFile) == 0 {
   320  		t.Skipf("%s not set", emailNoAuthConfigVar)
   321  	}
   322  
   323  	c, err := loadEmailTestConfiguration(cfgFile)
   324  	if err != nil {
   325  		t.Fatal(err)
   326  	}
   327  
   328  	ctx, cancel := context.WithCancel(context.Background())
   329  	cancel()
   330  	_, _, err = notifyEmailWithContext(
   331  		ctx,
   332  		&config.EmailConfig{
   333  			Smarthost: c.Smarthost,
   334  			To:        emailTo,
   335  			From:      emailFrom,
   336  			HTML:      "HTML body",
   337  			Text:      "Text body",
   338  		},
   339  		c.Server,
   340  	)
   341  	require.Error(t, err)
   342  	require.Contains(t, err.Error(), "establish connection to server")
   343  }
   344  
   345  // TestEmailNotifyWithoutAuthentication sends an email to an instance of
   346  // MailDev configured with no authentication then it checks that the server has
   347  // successfully processed the email.
   348  func TestEmailNotifyWithoutAuthentication(t *testing.T) {
   349  	cfgFile := os.Getenv(emailNoAuthConfigVar)
   350  	if len(cfgFile) == 0 {
   351  		t.Skipf("%s not set", emailNoAuthConfigVar)
   352  	}
   353  
   354  	c, err := loadEmailTestConfiguration(cfgFile)
   355  	if err != nil {
   356  		t.Fatal(err)
   357  	}
   358  
   359  	mail, _, err := notifyEmail(
   360  		&config.EmailConfig{
   361  			Smarthost: c.Smarthost,
   362  			To:        emailTo,
   363  			From:      emailFrom,
   364  			HTML:      "HTML body",
   365  			Text:      "Text body",
   366  		},
   367  		c.Server,
   368  	)
   369  	require.NoError(t, err)
   370  	var (
   371  		foundMsgID bool
   372  		headers    []string
   373  	)
   374  	for k := range mail.Headers {
   375  		if strings.ToLower(k) == "message-id" {
   376  			foundMsgID = true
   377  			break
   378  		}
   379  		headers = append(headers, k)
   380  	}
   381  	require.True(t, foundMsgID, "Couldn't find 'message-id' in %v", headers)
   382  }
   383  
   384  // TestEmailNotifyWithSTARTTLS connects to the server, upgrades the connection
   385  // to TLS, sends an email then it checks that the server has successfully
   386  // processed the email.
   387  // MailDev doesn't support STARTTLS and authentication at the same time so it
   388  // is the only way to test successful STARTTLS.
   389  func TestEmailNotifyWithSTARTTLS(t *testing.T) {
   390  	cfgFile := os.Getenv(emailNoAuthConfigVar)
   391  	if len(cfgFile) == 0 {
   392  		t.Skipf("%s not set", emailNoAuthConfigVar)
   393  	}
   394  
   395  	c, err := loadEmailTestConfiguration(cfgFile)
   396  	if err != nil {
   397  		t.Fatal(err)
   398  	}
   399  
   400  	trueVar := true
   401  	_, _, err = notifyEmail(
   402  		&config.EmailConfig{
   403  			Smarthost:  c.Smarthost,
   404  			To:         emailTo,
   405  			From:       emailFrom,
   406  			HTML:       "HTML body",
   407  			Text:       "Text body",
   408  			RequireTLS: &trueVar,
   409  			// MailDev embeds a self-signed certificate which can't be retrieved.
   410  			TLSConfig: commoncfg.TLSConfig{InsecureSkipVerify: true},
   411  		},
   412  		c.Server,
   413  	)
   414  	require.NoError(t, err)
   415  }
   416  
   417  // TestEmailNotifyWithAuthentication sends emails to an instance of MailDev
   418  // configured with authentication.
   419  func TestEmailNotifyWithAuthentication(t *testing.T) {
   420  	cfgFile := os.Getenv(emailAuthConfigVar)
   421  	if len(cfgFile) == 0 {
   422  		t.Skipf("%s not set", emailAuthConfigVar)
   423  	}
   424  
   425  	c, err := loadEmailTestConfiguration(cfgFile)
   426  	if err != nil {
   427  		t.Fatal(err)
   428  	}
   429  
   430  	fileWithCorrectPassword, err := os.CreateTemp("", "smtp-password-correct")
   431  	require.NoError(t, err, "creating temp file failed")
   432  	_, err = fileWithCorrectPassword.WriteString(c.Password)
   433  	require.NoError(t, err, "writing to temp file failed")
   434  
   435  	fileWithIncorrectPassword, err := os.CreateTemp("", "smtp-password-incorrect")
   436  	require.NoError(t, err, "creating temp file failed")
   437  	_, err = fileWithIncorrectPassword.WriteString(c.Password + "wrong")
   438  	require.NoError(t, err, "writing to temp file failed")
   439  
   440  	for _, tc := range []struct {
   441  		title     string
   442  		updateCfg func(*config.EmailConfig)
   443  
   444  		errMsg string
   445  		retry  bool
   446  	}{
   447  		{
   448  			title: "email with authentication",
   449  			updateCfg: func(cfg *config.EmailConfig) {
   450  				cfg.AuthUsername = c.Username
   451  				cfg.AuthPassword = config.Secret(c.Password)
   452  			},
   453  		},
   454  		{
   455  			title: "email with authentication (password from file)",
   456  			updateCfg: func(cfg *config.EmailConfig) {
   457  				cfg.AuthUsername = c.Username
   458  				cfg.AuthPasswordFile = fileWithCorrectPassword.Name()
   459  			},
   460  		},
   461  		{
   462  			title: "HTML-only email",
   463  			updateCfg: func(cfg *config.EmailConfig) {
   464  				cfg.AuthUsername = c.Username
   465  				cfg.AuthPassword = config.Secret(c.Password)
   466  				cfg.Text = ""
   467  			},
   468  		},
   469  		{
   470  			title: "text-only email",
   471  			updateCfg: func(cfg *config.EmailConfig) {
   472  				cfg.AuthUsername = c.Username
   473  				cfg.AuthPassword = config.Secret(c.Password)
   474  				cfg.HTML = ""
   475  			},
   476  		},
   477  		{
   478  			title: "multiple To addresses",
   479  			updateCfg: func(cfg *config.EmailConfig) {
   480  				cfg.AuthUsername = c.Username
   481  				cfg.AuthPassword = config.Secret(c.Password)
   482  				cfg.To = strings.Join([]string{emailTo, emailFrom}, ",")
   483  			},
   484  		},
   485  		{
   486  			title: "no more than one From address",
   487  			updateCfg: func(cfg *config.EmailConfig) {
   488  				cfg.AuthUsername = c.Username
   489  				cfg.AuthPassword = config.Secret(c.Password)
   490  				cfg.From = strings.Join([]string{emailFrom, emailTo}, ",")
   491  			},
   492  
   493  			errMsg: "must be exactly one 'from' address",
   494  			retry:  false,
   495  		},
   496  		{
   497  			title: "wrong credentials",
   498  			updateCfg: func(cfg *config.EmailConfig) {
   499  				cfg.AuthUsername = c.Username
   500  				cfg.AuthPassword = config.Secret(c.Password + "wrong")
   501  			},
   502  
   503  			errMsg: "Invalid username or password",
   504  			retry:  true,
   505  		},
   506  		{
   507  			title: "wrong credentials (password from file)",
   508  			updateCfg: func(cfg *config.EmailConfig) {
   509  				cfg.AuthUsername = c.Username
   510  				cfg.AuthPasswordFile = fileWithIncorrectPassword.Name()
   511  			},
   512  
   513  			errMsg: "Invalid username or password",
   514  			retry:  true,
   515  		},
   516  		{
   517  			title: "wrong credentials (missing password file)",
   518  			updateCfg: func(cfg *config.EmailConfig) {
   519  				cfg.AuthUsername = c.Username
   520  				cfg.AuthPasswordFile = "/does/not/exist"
   521  			},
   522  
   523  			errMsg: "could not read",
   524  			retry:  true,
   525  		},
   526  		{
   527  			title:  "no credentials",
   528  			errMsg: "authentication Required",
   529  			retry:  true,
   530  		},
   531  		{
   532  			title: "try to enable STARTTLS",
   533  			updateCfg: func(cfg *config.EmailConfig) {
   534  				cfg.RequireTLS = new(bool)
   535  				*cfg.RequireTLS = true
   536  			},
   537  
   538  			errMsg: "does not advertise the STARTTLS extension",
   539  			retry:  true,
   540  		},
   541  		{
   542  			title: "invalid Hello string",
   543  			updateCfg: func(cfg *config.EmailConfig) {
   544  				cfg.AuthUsername = c.Username
   545  				cfg.AuthPassword = config.Secret(c.Password)
   546  				cfg.Hello = "invalid hello string"
   547  			},
   548  
   549  			errMsg: "501 Error",
   550  			retry:  true,
   551  		},
   552  	} {
   553  		tc := tc
   554  		t.Run(tc.title, func(t *testing.T) {
   555  			emailCfg := &config.EmailConfig{
   556  				Smarthost: c.Smarthost,
   557  				To:        emailTo,
   558  				From:      emailFrom,
   559  				HTML:      "HTML body",
   560  				Text:      "Text body",
   561  				Headers: map[string]string{
   562  					"Subject": "{{ len .Alerts }} {{ .Status }} alert(s)",
   563  				},
   564  			}
   565  			if tc.updateCfg != nil {
   566  				tc.updateCfg(emailCfg)
   567  			}
   568  
   569  			e, retry, err := notifyEmail(emailCfg, c.Server)
   570  			if len(tc.errMsg) > 0 {
   571  				require.Error(t, err)
   572  				require.Contains(t, err.Error(), tc.errMsg)
   573  				require.Equal(t, tc.retry, retry)
   574  				return
   575  			}
   576  			require.NoError(t, err)
   577  
   578  			require.Equal(t, "1 firing alert(s)", e.Subject)
   579  
   580  			getAddresses := func(addresses []map[string]string) []string {
   581  				res := make([]string, 0, len(addresses))
   582  				for _, addr := range addresses {
   583  					res = append(res, addr["address"])
   584  				}
   585  				return res
   586  			}
   587  			to := getAddresses(e.To)
   588  			from := getAddresses(e.From)
   589  			require.Equal(t, strings.Split(emailCfg.To, ","), to)
   590  			require.Equal(t, strings.Split(emailCfg.From, ","), from)
   591  
   592  			if len(emailCfg.HTML) > 0 {
   593  				require.Equal(t, emailCfg.HTML, *e.HTML)
   594  			} else {
   595  				require.Nil(t, e.HTML)
   596  			}
   597  
   598  			if len(emailCfg.Text) > 0 {
   599  				require.Equal(t, emailCfg.Text, *e.Text)
   600  			} else {
   601  				require.Nil(t, e.Text)
   602  			}
   603  		})
   604  	}
   605  }
   606  
   607  func TestEmailConfigNoAuthMechs(t *testing.T) {
   608  	email := &Email{
   609  		conf: &config.EmailConfig{AuthUsername: "test"}, tmpl: &template.Template{}, logger: log.NewNopLogger(),
   610  	}
   611  	_, err := email.auth("")
   612  	require.Error(t, err)
   613  	require.Equal(t, err.Error(), "unknown auth mechanism: ")
   614  }
   615  
   616  func TestEmailConfigMissingAuthParam(t *testing.T) {
   617  	conf := &config.EmailConfig{AuthUsername: "test"}
   618  	email := &Email{
   619  		conf: conf, tmpl: &template.Template{}, logger: log.NewNopLogger(),
   620  	}
   621  	_, err := email.auth("CRAM-MD5")
   622  	require.Error(t, err)
   623  	require.Equal(t, err.Error(), "missing secret for CRAM-MD5 auth mechanism")
   624  
   625  	_, err = email.auth("PLAIN")
   626  	require.Error(t, err)
   627  	require.Equal(t, err.Error(), "missing password for PLAIN auth mechanism")
   628  
   629  	_, err = email.auth("LOGIN")
   630  	require.Error(t, err)
   631  	require.Equal(t, err.Error(), "missing password for LOGIN auth mechanism")
   632  
   633  	_, err = email.auth("PLAIN LOGIN")
   634  	require.Error(t, err)
   635  	require.Equal(t, err.Error(), "missing password for PLAIN auth mechanism; missing password for LOGIN auth mechanism")
   636  }
   637  
   638  func TestEmailNoUsernameStillOk(t *testing.T) {
   639  	email := &Email{
   640  		conf: &config.EmailConfig{}, tmpl: &template.Template{}, logger: log.NewNopLogger(),
   641  	}
   642  	a, err := email.auth("CRAM-MD5")
   643  	require.NoError(t, err)
   644  	require.Nil(t, a)
   645  }
   646  

View as plain text