mirror of https://github.com/go-gitea/gitea
Add support for incoming emails (#22056)
closes #13585 fixes #9067 fixes #2386 ref #6226 ref #6219 fixes #745 This PR adds support to process incoming emails to perform actions. Currently I added handling of replies and unsubscribing from issues/pulls. In contrast to #13585 the IMAP IDLE command is used instead of polling which results (in my opinion 😉) in cleaner code. Procedure: - When sending an issue/pull reply email, a token is generated which is present in the Reply-To and References header. - IMAP IDLE waits until a new email arrives - The token tells which action should be performed A possible signature and/or reply gets stripped from the content. I added a new service to the drone pipeline to test the receiving of incoming mails. If we keep this in, we may test our outgoing emails too in future. Co-authored-by: silverwind <me@silverwind.io> Co-authored-by: Lunny Xiao <xiaolunwen@gmail.com>pull/22449/head^2
parent
20e3ffd208
commit
fc037b4b82
File diff suppressed because one or more lines are too long
@ -0,0 +1,47 @@ |
||||
--- |
||||
date: "2022-12-01T00:00:00+00:00" |
||||
title: "Incoming Email" |
||||
slug: "incoming-email" |
||||
draft: false |
||||
toc: false |
||||
menu: |
||||
sidebar: |
||||
parent: "usage" |
||||
name: "Incoming Email" |
||||
weight: 13 |
||||
identifier: "incoming-email" |
||||
--- |
||||
|
||||
# Incoming Email |
||||
|
||||
Gitea supports the execution of several actions through incoming mails. This page describes how to set this up. |
||||
|
||||
**Table of Contents** |
||||
|
||||
{{< toc >}} |
||||
|
||||
## Requirements |
||||
|
||||
Handling incoming email messages requires an IMAP-enabled email account. |
||||
The recommended strategy is to use [email sub-addressing](https://en.wikipedia.org/wiki/Email_address#Sub-addressing) but a catch-all mailbox does work too. |
||||
The receiving email address contains a user/action specific token which tells Gitea which action should be performed. |
||||
This token is expected in the `To` and `Delivered-To` header fields. |
||||
|
||||
Gitea tries to detect automatic responses to skip and the email server should be configured to reduce the incoming noise too (spam, newsletter). |
||||
|
||||
## Configuration |
||||
|
||||
To activate the handling of incoming email messages you have to configure the `email.incoming` section in the configuration file. |
||||
|
||||
The `REPLY_TO_ADDRESS` contains the address an email client will respond to. |
||||
This address needs to contain the `%{token}` placeholder which will be replaced with a token describing the user/action. |
||||
This placeholder must only appear once in the address and must be in the user part of the address (before the `@`). |
||||
|
||||
An example using email sub-addressing may look like this: `incoming+%{token}@example.com` |
||||
|
||||
If a catch-all mailbox is used, the placeholder may be used anywhere in the user part of the address: `incoming+%{token}@example.com`, `incoming_%{token}@example.com`, `%{token}@example.com` |
||||
|
||||
## Security |
||||
|
||||
Be careful when choosing the domain used for receiving incoming email. |
||||
It's recommended receiving incoming email on a subdomain, such as `incoming.example.com` to prevent potential security problems with other services running on `example.com`. |
@ -0,0 +1,73 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting |
||||
|
||||
import ( |
||||
"fmt" |
||||
"net/mail" |
||||
"strings" |
||||
|
||||
"code.gitea.io/gitea/modules/log" |
||||
) |
||||
|
||||
var IncomingEmail = struct { |
||||
Enabled bool |
||||
ReplyToAddress string |
||||
TokenPlaceholder string `ini:"-"` |
||||
Host string |
||||
Port int |
||||
UseTLS bool `ini:"USE_TLS"` |
||||
SkipTLSVerify bool `ini:"SKIP_TLS_VERIFY"` |
||||
Username string |
||||
Password string |
||||
Mailbox string |
||||
DeleteHandledMessage bool |
||||
MaximumMessageSize uint32 |
||||
}{ |
||||
Mailbox: "INBOX", |
||||
DeleteHandledMessage: true, |
||||
TokenPlaceholder: "%{token}", |
||||
MaximumMessageSize: 10485760, |
||||
} |
||||
|
||||
func newIncomingEmail() { |
||||
if err := Cfg.Section("email.incoming").MapTo(&IncomingEmail); err != nil { |
||||
log.Fatal("Unable to map [email.incoming] section on to IncomingEmail. Error: %v", err) |
||||
} |
||||
|
||||
if !IncomingEmail.Enabled { |
||||
return |
||||
} |
||||
|
||||
if err := checkReplyToAddress(IncomingEmail.ReplyToAddress); err != nil { |
||||
log.Fatal("Invalid incoming_mail.REPLY_TO_ADDRESS (%s): %v", IncomingEmail.ReplyToAddress, err) |
||||
} |
||||
} |
||||
|
||||
func checkReplyToAddress(address string) error { |
||||
parsed, err := mail.ParseAddress(IncomingEmail.ReplyToAddress) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if parsed.Name != "" { |
||||
return fmt.Errorf("name must not be set") |
||||
} |
||||
|
||||
c := strings.Count(IncomingEmail.ReplyToAddress, IncomingEmail.TokenPlaceholder) |
||||
switch c { |
||||
case 0: |
||||
return fmt.Errorf("%s must appear in the user part of the address (before the @)", IncomingEmail.TokenPlaceholder) |
||||
case 1: |
||||
default: |
||||
return fmt.Errorf("%s must appear only once", IncomingEmail.TokenPlaceholder) |
||||
} |
||||
|
||||
parts := strings.Split(IncomingEmail.ReplyToAddress, "@") |
||||
if !strings.Contains(parts[0], IncomingEmail.TokenPlaceholder) { |
||||
return fmt.Errorf("%s must appear in the user part of the address (before the @)", IncomingEmail.TokenPlaceholder) |
||||
} |
||||
|
||||
return nil |
||||
} |
@ -0,0 +1,33 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util |
||||
|
||||
import ( |
||||
"bytes" |
||||
"encoding/gob" |
||||
) |
||||
|
||||
// PackData uses gob to encode the given data in sequence
|
||||
func PackData(data ...interface{}) ([]byte, error) { |
||||
var buf bytes.Buffer |
||||
enc := gob.NewEncoder(&buf) |
||||
for _, datum := range data { |
||||
if err := enc.Encode(datum); err != nil { |
||||
return nil, err |
||||
} |
||||
} |
||||
return buf.Bytes(), nil |
||||
} |
||||
|
||||
// UnpackData uses gob to decode the given data in sequence
|
||||
func UnpackData(buf []byte, data ...interface{}) error { |
||||
r := bytes.NewReader(buf) |
||||
enc := gob.NewDecoder(r) |
||||
for _, datum := range data { |
||||
if err := enc.Decode(datum); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
return nil |
||||
} |
@ -0,0 +1,28 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package util |
||||
|
||||
import ( |
||||
"testing" |
||||
|
||||
"github.com/stretchr/testify/assert" |
||||
) |
||||
|
||||
func TestPackAndUnpackData(t *testing.T) { |
||||
s := "string" |
||||
i := int64(4) |
||||
f := float32(4.1) |
||||
|
||||
var s2 string |
||||
var i2 int64 |
||||
var f2 float32 |
||||
|
||||
data, err := PackData(s, i, f) |
||||
assert.NoError(t, err) |
||||
|
||||
assert.NoError(t, UnpackData(data, &s2, &i2, &f2)) |
||||
assert.NoError(t, UnpackData(data, &s2)) |
||||
assert.Error(t, UnpackData(data, &i2)) |
||||
assert.Error(t, UnpackData(data, &s2, &f2)) |
||||
} |
@ -0,0 +1,375 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package incoming |
||||
|
||||
import ( |
||||
"context" |
||||
"crypto/tls" |
||||
"fmt" |
||||
net_mail "net/mail" |
||||
"regexp" |
||||
"strings" |
||||
"time" |
||||
|
||||
"code.gitea.io/gitea/modules/log" |
||||
"code.gitea.io/gitea/modules/process" |
||||
"code.gitea.io/gitea/modules/setting" |
||||
"code.gitea.io/gitea/services/mailer/token" |
||||
|
||||
"github.com/dimiro1/reply" |
||||
"github.com/emersion/go-imap" |
||||
"github.com/emersion/go-imap/client" |
||||
"github.com/jhillyerd/enmime" |
||||
) |
||||
|
||||
var ( |
||||
addressTokenRegex *regexp.Regexp |
||||
referenceTokenRegex *regexp.Regexp |
||||
) |
||||
|
||||
func Init(ctx context.Context) error { |
||||
if !setting.IncomingEmail.Enabled { |
||||
return nil |
||||
} |
||||
|
||||
var err error |
||||
addressTokenRegex, err = regexp.Compile( |
||||
fmt.Sprintf( |
||||
`\A%s\z`, |
||||
strings.Replace(regexp.QuoteMeta(setting.IncomingEmail.ReplyToAddress), regexp.QuoteMeta(setting.IncomingEmail.TokenPlaceholder), "(.+)", 1), |
||||
), |
||||
) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
referenceTokenRegex, err = regexp.Compile(fmt.Sprintf(`\Areply-(.+)@%s\z`, regexp.QuoteMeta(setting.Domain))) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
go func() { |
||||
ctx, _, finished := process.GetManager().AddTypedContext(ctx, "Incoming Email", process.SystemProcessType, true) |
||||
defer finished() |
||||
|
||||
// This background job processes incoming emails. It uses the IMAP IDLE command to get notified about incoming emails.
|
||||
// The following loop restarts the processing logic after errors until ctx indicates to stop.
|
||||
|
||||
for { |
||||
select { |
||||
case <-ctx.Done(): |
||||
return |
||||
default: |
||||
if err := processIncomingEmails(ctx); err != nil { |
||||
log.Error("Error while processing incoming emails: %v", err) |
||||
} |
||||
select { |
||||
case <-ctx.Done(): |
||||
return |
||||
case <-time.NewTimer(10 * time.Second).C: |
||||
} |
||||
} |
||||
} |
||||
}() |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// processIncomingEmails is the "main" method with the wait/process loop
|
||||
func processIncomingEmails(ctx context.Context) error { |
||||
server := fmt.Sprintf("%s:%d", setting.IncomingEmail.Host, setting.IncomingEmail.Port) |
||||
|
||||
var c *client.Client |
||||
var err error |
||||
if setting.IncomingEmail.UseTLS { |
||||
c, err = client.DialTLS(server, &tls.Config{InsecureSkipVerify: setting.IncomingEmail.SkipTLSVerify}) |
||||
} else { |
||||
c, err = client.Dial(server) |
||||
} |
||||
if err != nil { |
||||
return fmt.Errorf("could not connect to server '%s': %w", server, err) |
||||
} |
||||
|
||||
if err := c.Login(setting.IncomingEmail.Username, setting.IncomingEmail.Password); err != nil { |
||||
return fmt.Errorf("could not login: %w", err) |
||||
} |
||||
defer func() { |
||||
if err := c.Logout(); err != nil { |
||||
log.Error("Logout from incoming email server failed: %v", err) |
||||
} |
||||
}() |
||||
|
||||
if _, err := c.Select(setting.IncomingEmail.Mailbox, false); err != nil { |
||||
return fmt.Errorf("selecting box '%s' failed: %w", setting.IncomingEmail.Mailbox, err) |
||||
} |
||||
|
||||
// The following loop processes messages. If there are no messages available, IMAP IDLE is used to wait for new messages.
|
||||
// This process is repeated until an IMAP error occurs or ctx indicates to stop.
|
||||
|
||||
for { |
||||
select { |
||||
case <-ctx.Done(): |
||||
return nil |
||||
default: |
||||
if err := processMessages(ctx, c); err != nil { |
||||
return fmt.Errorf("could not process messages: %w", err) |
||||
} |
||||
if err := waitForUpdates(ctx, c); err != nil { |
||||
return fmt.Errorf("wait for updates failed: %w", err) |
||||
} |
||||
select { |
||||
case <-ctx.Done(): |
||||
return nil |
||||
case <-time.NewTimer(time.Second).C: |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
// waitForUpdates uses IMAP IDLE to wait for new emails
|
||||
func waitForUpdates(ctx context.Context, c *client.Client) error { |
||||
updates := make(chan client.Update, 1) |
||||
|
||||
c.Updates = updates |
||||
defer func() { |
||||
c.Updates = nil |
||||
}() |
||||
|
||||
errs := make(chan error, 1) |
||||
stop := make(chan struct{}) |
||||
go func() { |
||||
errs <- c.Idle(stop, nil) |
||||
}() |
||||
|
||||
stopped := false |
||||
for { |
||||
select { |
||||
case update := <-updates: |
||||
switch update.(type) { |
||||
case *client.MailboxUpdate: |
||||
if !stopped { |
||||
close(stop) |
||||
stopped = true |
||||
} |
||||
default: |
||||
} |
||||
case err := <-errs: |
||||
if err != nil { |
||||
return fmt.Errorf("imap idle failed: %w", err) |
||||
} |
||||
return nil |
||||
case <-ctx.Done(): |
||||
return nil |
||||
} |
||||
} |
||||
} |
||||
|
||||
// processMessages searches unread mails and processes them.
|
||||
func processMessages(ctx context.Context, c *client.Client) error { |
||||
criteria := imap.NewSearchCriteria() |
||||
criteria.WithoutFlags = []string{imap.SeenFlag} |
||||
criteria.Smaller = setting.IncomingEmail.MaximumMessageSize |
||||
ids, err := c.Search(criteria) |
||||
if err != nil { |
||||
return fmt.Errorf("imap search failed: %w", err) |
||||
} |
||||
|
||||
if len(ids) == 0 { |
||||
return nil |
||||
} |
||||
|
||||
seqset := new(imap.SeqSet) |
||||
seqset.AddNum(ids...) |
||||
messages := make(chan *imap.Message, 10) |
||||
|
||||
section := &imap.BodySectionName{} |
||||
|
||||
errs := make(chan error, 1) |
||||
go func() { |
||||
errs <- c.Fetch( |
||||
seqset, |
||||
[]imap.FetchItem{section.FetchItem()}, |
||||
messages, |
||||
) |
||||
}() |
||||
|
||||
handledSet := new(imap.SeqSet) |
||||
loop: |
||||
for { |
||||
select { |
||||
case <-ctx.Done(): |
||||
break loop |
||||
case msg, ok := <-messages: |
||||
if !ok { |
||||
if setting.IncomingEmail.DeleteHandledMessage && !handledSet.Empty() { |
||||
if err := c.Store( |
||||
handledSet, |
||||
imap.FormatFlagsOp(imap.AddFlags, true), |
||||
[]interface{}{imap.DeletedFlag}, |
||||
nil, |
||||
); err != nil { |
||||
return fmt.Errorf("imap store failed: %w", err) |
||||
} |
||||
|
||||
if err := c.Expunge(nil); err != nil { |
||||
return fmt.Errorf("imap expunge failed: %w", err) |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
err := func() error { |
||||
r := msg.GetBody(section) |
||||
if r == nil { |
||||
return fmt.Errorf("could not get body from message: %w", err) |
||||
} |
||||
|
||||
env, err := enmime.ReadEnvelope(r) |
||||
if err != nil { |
||||
return fmt.Errorf("could not read envelope: %w", err) |
||||
} |
||||
|
||||
if isAutomaticReply(env) { |
||||
log.Debug("Skipping automatic email reply") |
||||
return nil |
||||
} |
||||
|
||||
t := searchTokenInHeaders(env) |
||||
if t == "" { |
||||
log.Debug("Incoming email token not found in headers") |
||||
return nil |
||||
} |
||||
|
||||
handlerType, user, payload, err := token.ExtractToken(ctx, t) |
||||
if err != nil { |
||||
if _, ok := err.(*token.ErrToken); ok { |
||||
log.Info("Invalid incoming email token: %v", err) |
||||
return nil |
||||
} |
||||
return err |
||||
} |
||||
|
||||
handler, ok := handlers[handlerType] |
||||
if !ok { |
||||
return fmt.Errorf("unexpected handler type: %v", handlerType) |
||||
} |
||||
|
||||
content := getContentFromMailReader(env) |
||||
|
||||
if err := handler.Handle(ctx, content, user, payload); err != nil { |
||||
return fmt.Errorf("could not handle message: %w", err) |
||||
} |
||||
|
||||
handledSet.AddNum(msg.SeqNum) |
||||
|
||||
return nil |
||||
}() |
||||
if err != nil { |
||||
log.Error("Error while processing incoming email[%v]: %v", msg.Uid, err) |
||||
} |
||||
} |
||||
} |
||||
|
||||
if err := <-errs; err != nil { |
||||
return fmt.Errorf("imap fetch failed: %w", err) |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
// isAutomaticReply tests if the headers indicate an automatic reply
|
||||
func isAutomaticReply(env *enmime.Envelope) bool { |
||||
autoSubmitted := env.GetHeader("Auto-Submitted") |
||||
if autoSubmitted != "" && autoSubmitted != "no" { |
||||
return true |
||||
} |
||||
autoReply := env.GetHeader("X-Autoreply") |
||||
if autoReply == "yes" { |
||||
return true |
||||
} |
||||
autoRespond := env.GetHeader("X-Autorespond") |
||||
return autoRespond != "" |
||||
} |
||||
|
||||
// searchTokenInHeaders looks for the token in To, Delivered-To and References
|
||||
func searchTokenInHeaders(env *enmime.Envelope) string { |
||||
if addressTokenRegex != nil { |
||||
to, _ := env.AddressList("To") |
||||
|
||||
token := searchTokenInAddresses(to) |
||||
if token != "" { |
||||
return token |
||||
} |
||||
|
||||
deliveredTo, _ := env.AddressList("Delivered-To") |
||||
|
||||
token = searchTokenInAddresses(deliveredTo) |
||||
if token != "" { |
||||
return token |
||||
} |
||||
} |
||||
|
||||
references := env.GetHeader("References") |
||||
for { |
||||
begin := strings.IndexByte(references, '<') |
||||
if begin == -1 { |
||||
break |
||||
} |
||||
begin++ |
||||
|
||||
end := strings.IndexByte(references, '>') |
||||
if end == -1 || begin > end { |
||||
break |
||||
} |
||||
|
||||
match := referenceTokenRegex.FindStringSubmatch(references[begin:end]) |
||||
if len(match) == 2 { |
||||
return match[1] |
||||
} |
||||
|
||||
references = references[end+1:] |
||||
} |
||||
|
||||
return "" |
||||
} |
||||
|
||||
// searchTokenInAddresses looks for the token in an address
|
||||
func searchTokenInAddresses(addresses []*net_mail.Address) string { |
||||
for _, address := range addresses { |
||||
match := addressTokenRegex.FindStringSubmatch(address.Address) |
||||
if len(match) != 2 { |
||||
continue |
||||
} |
||||
|
||||
return match[1] |
||||
} |
||||
|
||||
return "" |
||||
} |
||||
|
||||
type MailContent struct { |
||||
Content string |
||||
Attachments []*Attachment |
||||
} |
||||
|
||||
type Attachment struct { |
||||
Name string |
||||
Content []byte |
||||
} |
||||
|
||||
// getContentFromMailReader grabs the plain content and the attachments from the mail.
|
||||
// A potential reply/signature gets stripped from the content.
|
||||
func getContentFromMailReader(env *enmime.Envelope) *MailContent { |
||||
attachments := make([]*Attachment, 0, len(env.Attachments)) |
||||
for _, attachment := range env.Attachments { |
||||
attachments = append(attachments, &Attachment{ |
||||
Name: attachment.FileName, |
||||
Content: attachment.Content, |
||||
}) |
||||
} |
||||
|
||||
return &MailContent{ |
||||
Content: reply.FromText(env.Text), |
||||
Attachments: attachments, |
||||
} |
||||
} |
@ -0,0 +1,171 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package incoming |
||||
|
||||
import ( |
||||
"bytes" |
||||
"context" |
||||
"fmt" |
||||
|
||||
issues_model "code.gitea.io/gitea/models/issues" |
||||
access_model "code.gitea.io/gitea/models/perm/access" |
||||
repo_model "code.gitea.io/gitea/models/repo" |
||||
user_model "code.gitea.io/gitea/models/user" |
||||
"code.gitea.io/gitea/modules/log" |
||||
"code.gitea.io/gitea/modules/setting" |
||||
"code.gitea.io/gitea/modules/upload" |
||||
"code.gitea.io/gitea/modules/util" |
||||
attachment_service "code.gitea.io/gitea/services/attachment" |
||||
issue_service "code.gitea.io/gitea/services/issue" |
||||
incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload" |
||||
"code.gitea.io/gitea/services/mailer/token" |
||||
pull_service "code.gitea.io/gitea/services/pull" |
||||
) |
||||
|
||||
type MailHandler interface { |
||||
Handle(ctx context.Context, content *MailContent, doer *user_model.User, payload []byte) error |
||||
} |
||||
|
||||
var handlers = map[token.HandlerType]MailHandler{ |
||||
token.ReplyHandlerType: &ReplyHandler{}, |
||||
token.UnsubscribeHandlerType: &UnsubscribeHandler{}, |
||||
} |
||||
|
||||
// ReplyHandler handles incoming emails to create a reply from them
|
||||
type ReplyHandler struct{} |
||||
|
||||
func (h *ReplyHandler) Handle(ctx context.Context, content *MailContent, doer *user_model.User, payload []byte) error { |
||||
if doer == nil { |
||||
return util.NewInvalidArgumentErrorf("doer can't be nil") |
||||
} |
||||
|
||||
ref, err := incoming_payload.GetReferenceFromPayload(ctx, payload) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
var issue *issues_model.Issue |
||||
|
||||
switch r := ref.(type) { |
||||
case *issues_model.Issue: |
||||
issue = r |
||||
case *issues_model.Comment: |
||||
comment := r |
||||
|
||||
if err := comment.LoadIssue(ctx); err != nil { |
||||
return err |
||||
} |
||||
|
||||
issue = comment.Issue |
||||
default: |
||||
return util.NewInvalidArgumentErrorf("unsupported reply reference: %v", ref) |
||||
} |
||||
|
||||
if err := issue.LoadRepo(ctx); err != nil { |
||||
return err |
||||
} |
||||
|
||||
perm, err := access_model.GetUserRepoPermission(ctx, issue.Repo, doer) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if !perm.CanWriteIssuesOrPulls(issue.IsPull) || issue.IsLocked && !doer.IsAdmin { |
||||
log.Debug("can't write issue or pull") |
||||
return nil |
||||
} |
||||
|
||||
switch r := ref.(type) { |
||||
case *issues_model.Issue: |
||||
attachmentIDs := make([]string, 0, len(content.Attachments)) |
||||
if setting.Attachment.Enabled { |
||||
for _, attachment := range content.Attachments { |
||||
a, err := attachment_service.UploadAttachment(bytes.NewReader(attachment.Content), setting.Attachment.AllowedTypes, &repo_model.Attachment{ |
||||
Name: attachment.Name, |
||||
UploaderID: doer.ID, |
||||
RepoID: issue.Repo.ID, |
||||
}) |
||||
if err != nil { |
||||
if upload.IsErrFileTypeForbidden(err) { |
||||
log.Info("Skipping disallowed attachment type: %s", attachment.Name) |
||||
continue |
||||
} |
||||
return err |
||||
} |
||||
attachmentIDs = append(attachmentIDs, a.UUID) |
||||
} |
||||
} |
||||
|
||||
if content.Content == "" && len(attachmentIDs) == 0 { |
||||
return nil |
||||
} |
||||
|
||||
_, err = issue_service.CreateIssueComment(ctx, doer, issue.Repo, issue, content.Content, attachmentIDs) |
||||
if err != nil { |
||||
return fmt.Errorf("CreateIssueComment failed: %w", err) |
||||
} |
||||
case *issues_model.Comment: |
||||
comment := r |
||||
|
||||
if content.Content == "" { |
||||
return nil |
||||
} |
||||
|
||||
if comment.Type == issues_model.CommentTypeCode { |
||||
_, err := pull_service.CreateCodeComment( |
||||
ctx, |
||||
doer, |
||||
nil, |
||||
issue, |
||||
comment.Line, |
||||
content.Content, |
||||
comment.TreePath, |
||||
false, |
||||
comment.ReviewID, |
||||
"", |
||||
) |
||||
if err != nil { |
||||
return fmt.Errorf("CreateCodeComment failed: %w", err) |
||||
} |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
// UnsubscribeHandler handles unwatching issues/pulls
|
||||
type UnsubscribeHandler struct{} |
||||
|
||||
func (h *UnsubscribeHandler) Handle(ctx context.Context, _ *MailContent, doer *user_model.User, payload []byte) error { |
||||
if doer == nil { |
||||
return util.NewInvalidArgumentErrorf("doer can't be nil") |
||||
} |
||||
|
||||
ref, err := incoming_payload.GetReferenceFromPayload(ctx, payload) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
switch r := ref.(type) { |
||||
case *issues_model.Issue: |
||||
issue := r |
||||
|
||||
if err := issue.LoadRepo(ctx); err != nil { |
||||
return err |
||||
} |
||||
|
||||
perm, err := access_model.GetUserRepoPermission(ctx, issue.Repo, doer) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if !perm.CanReadIssuesOrPulls(issue.IsPull) { |
||||
log.Debug("can't read issue or pull") |
||||
return nil |
||||
} |
||||
|
||||
return issues_model.CreateOrUpdateIssueWatch(doer.ID, issue.ID, false) |
||||
} |
||||
|
||||
return fmt.Errorf("unsupported unsubscribe reference: %v", ref) |
||||
} |
@ -0,0 +1,138 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package incoming |
||||
|
||||
import ( |
||||
"strings" |
||||
"testing" |
||||
|
||||
"github.com/jhillyerd/enmime" |
||||
"github.com/stretchr/testify/assert" |
||||
) |
||||
|
||||
func TestIsAutomaticReply(t *testing.T) { |
||||
cases := []struct { |
||||
Headers map[string]string |
||||
Expected bool |
||||
}{ |
||||
{ |
||||
Headers: map[string]string{}, |
||||
Expected: false, |
||||
}, |
||||
{ |
||||
Headers: map[string]string{ |
||||
"Auto-Submitted": "no", |
||||
}, |
||||
Expected: false, |
||||
}, |
||||
{ |
||||
Headers: map[string]string{ |
||||
"Auto-Submitted": "yes", |
||||
}, |
||||
Expected: true, |
||||
}, |
||||
{ |
||||
Headers: map[string]string{ |
||||
"X-Autoreply": "no", |
||||
}, |
||||
Expected: false, |
||||
}, |
||||
{ |
||||
Headers: map[string]string{ |
||||
"X-Autoreply": "yes", |
||||
}, |
||||
Expected: true, |
||||
}, |
||||
{ |
||||
Headers: map[string]string{ |
||||
"X-Autorespond": "yes", |
||||
}, |
||||
Expected: true, |
||||
}, |
||||
} |
||||
|
||||
for _, c := range cases { |
||||
b := enmime.Builder(). |
||||
From("Dummy", "dummy@gitea.io"). |
||||
To("Dummy", "dummy@gitea.io") |
||||
for k, v := range c.Headers { |
||||
b = b.Header(k, v) |
||||
} |
||||
root, err := b.Build() |
||||
assert.NoError(t, err) |
||||
env, err := enmime.EnvelopeFromPart(root) |
||||
assert.NoError(t, err) |
||||
|
||||
assert.Equal(t, c.Expected, isAutomaticReply(env)) |
||||
} |
||||
} |
||||
|
||||
func TestGetContentFromMailReader(t *testing.T) { |
||||
mailString := "Content-Type: multipart/mixed; boundary=message-boundary\r\n" + |
||||
"\r\n" + |
||||
"--message-boundary\r\n" + |
||||
"Content-Type: multipart/alternative; boundary=text-boundary\r\n" + |
||||
"\r\n" + |
||||
"--text-boundary\r\n" + |
||||
"Content-Type: text/plain\r\n" + |
||||
"Content-Disposition: inline\r\n" + |
||||
"\r\n" + |
||||
"mail content\r\n" + |
||||
"--text-boundary--\r\n" + |
||||
"--message-boundary\r\n" + |
||||
"Content-Type: text/plain\r\n" + |
||||
"Content-Disposition: attachment; filename=attachment.txt\r\n" + |
||||
"\r\n" + |
||||
"attachment content\r\n" + |
||||
"--message-boundary--\r\n" |
||||
|
||||
env, err := enmime.ReadEnvelope(strings.NewReader(mailString)) |
||||
assert.NoError(t, err) |
||||
content := getContentFromMailReader(env) |
||||
assert.Equal(t, "mail content", content.Content) |
||||
assert.Len(t, content.Attachments, 1) |
||||
assert.Equal(t, "attachment.txt", content.Attachments[0].Name) |
||||
assert.Equal(t, []byte("attachment content"), content.Attachments[0].Content) |
||||
|
||||
mailString = "Content-Type: multipart/mixed; boundary=message-boundary\r\n" + |
||||
"\r\n" + |
||||
"--message-boundary\r\n" + |
||||
"Content-Type: multipart/alternative; boundary=text-boundary\r\n" + |
||||
"\r\n" + |
||||
"--text-boundary\r\n" + |
||||
"Content-Type: text/html\r\n" + |
||||
"Content-Disposition: inline\r\n" + |
||||
"\r\n" + |
||||
"<p>mail content</p>\r\n" + |
||||
"--text-boundary--\r\n" + |
||||
"--message-boundary--\r\n" |
||||
|
||||
env, err = enmime.ReadEnvelope(strings.NewReader(mailString)) |
||||
assert.NoError(t, err) |
||||
content = getContentFromMailReader(env) |
||||
assert.Equal(t, "mail content", content.Content) |
||||
assert.Empty(t, content.Attachments) |
||||
|
||||
mailString = "Content-Type: multipart/mixed; boundary=message-boundary\r\n" + |
||||
"\r\n" + |
||||
"--message-boundary\r\n" + |
||||
"Content-Type: multipart/alternative; boundary=text-boundary\r\n" + |
||||
"\r\n" + |
||||
"--text-boundary\r\n" + |
||||
"Content-Type: text/plain\r\n" + |
||||
"Content-Disposition: inline\r\n" + |
||||
"\r\n" + |
||||
"mail content without signature\r\n" + |
||||
"--\r\n" + |
||||
"signature\r\n" + |
||||
"--text-boundary--\r\n" + |
||||
"--message-boundary--\r\n" |
||||
|
||||
env, err = enmime.ReadEnvelope(strings.NewReader(mailString)) |
||||
assert.NoError(t, err) |
||||
content = getContentFromMailReader(env) |
||||
assert.NoError(t, err) |
||||
assert.Equal(t, "mail content without signature", content.Content) |
||||
assert.Empty(t, content.Attachments) |
||||
} |
@ -0,0 +1,70 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package payload |
||||
|
||||
import ( |
||||
"context" |
||||
|
||||
issues_model "code.gitea.io/gitea/models/issues" |
||||
"code.gitea.io/gitea/modules/util" |
||||
) |
||||
|
||||
const replyPayloadVersion1 byte = 1 |
||||
|
||||
type payloadReferenceType byte |
||||
|
||||
const ( |
||||
payloadReferenceIssue payloadReferenceType = iota |
||||
payloadReferenceComment |
||||
) |
||||
|
||||
// CreateReferencePayload creates data which GetReferenceFromPayload resolves to the reference again.
|
||||
func CreateReferencePayload(reference interface{}) ([]byte, error) { |
||||
var refType payloadReferenceType |
||||
var refID int64 |
||||
|
||||
switch r := reference.(type) { |
||||
case *issues_model.Issue: |
||||
refType = payloadReferenceIssue |
||||
refID = r.ID |
||||
case *issues_model.Comment: |
||||
refType = payloadReferenceComment |
||||
refID = r.ID |
||||
default: |
||||
return nil, util.NewInvalidArgumentErrorf("unsupported reference type: %T", r) |
||||
} |
||||
|
||||
payload, err := util.PackData(refType, refID) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
return append([]byte{replyPayloadVersion1}, payload...), nil |
||||
} |
||||
|
||||
// GetReferenceFromPayload resolves the reference from the payload
|
||||
func GetReferenceFromPayload(ctx context.Context, payload []byte) (interface{}, error) { |
||||
if len(payload) < 1 { |
||||
return nil, util.NewInvalidArgumentErrorf("payload to small") |
||||
} |
||||
|
||||
if payload[0] != replyPayloadVersion1 { |
||||
return nil, util.NewInvalidArgumentErrorf("unsupported payload version") |
||||
} |
||||
|
||||
var ref payloadReferenceType |
||||
var id int64 |
||||
if err := util.UnpackData(payload[1:], &ref, &id); err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
switch ref { |
||||
case payloadReferenceIssue: |
||||
return issues_model.GetIssueByID(ctx, id) |
||||
case payloadReferenceComment: |
||||
return issues_model.GetCommentByID(ctx, id) |
||||
default: |
||||
return nil, util.NewInvalidArgumentErrorf("unsupported reference type: %T", ref) |
||||
} |
||||
} |
@ -0,0 +1,128 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package token |
||||
|
||||
import ( |
||||
"context" |
||||
crypto_hmac "crypto/hmac" |
||||
"crypto/sha256" |
||||
"encoding/base32" |
||||
"fmt" |
||||
"time" |
||||
|
||||
user_model "code.gitea.io/gitea/models/user" |
||||
"code.gitea.io/gitea/modules/util" |
||||
) |
||||
|
||||
// A token is a verifiable container describing an action.
|
||||
//
|
||||
// A token has a dynamic length depending on the contained data and has the following structure:
|
||||
// | Token Version | User ID | HMAC | Payload |
|
||||
//
|
||||
// The payload is verifiable by the generated HMAC using the user secret. It contains:
|
||||
// | Timestamp | Action/Handler Type | Action/Handler Data |
|
||||
|
||||
const ( |
||||
tokenVersion1 byte = 1 |
||||
tokenLifetimeInYears int = 1 |
||||
) |
||||
|
||||
type HandlerType byte |
||||
|
||||
const ( |
||||
UnknownHandlerType HandlerType = iota |
||||
ReplyHandlerType |
||||
UnsubscribeHandlerType |
||||
) |
||||
|
||||
var encodingWithoutPadding = base32.StdEncoding.WithPadding(base32.NoPadding) |
||||
|
||||
type ErrToken struct { |
||||
context string |
||||
} |
||||
|
||||
func (err *ErrToken) Error() string { |
||||
return "invalid email token: " + err.context |
||||
} |
||||
|
||||
func (err *ErrToken) Unwrap() error { |
||||
return util.ErrInvalidArgument |
||||
} |
||||
|
||||
// CreateToken creates a token for the action/user tuple
|
||||
func CreateToken(ht HandlerType, user *user_model.User, data []byte) (string, error) { |
||||
payload, err := util.PackData( |
||||
time.Now().AddDate(tokenLifetimeInYears, 0, 0).Unix(), |
||||
ht, |
||||
data, |
||||
) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
packagedData, err := util.PackData( |
||||
user.ID, |
||||
generateHmac([]byte(user.Rands), payload), |
||||
payload, |
||||
) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
|
||||
return encodingWithoutPadding.EncodeToString(append([]byte{tokenVersion1}, packagedData...)), nil |
||||
} |
||||
|
||||
// ExtractToken extracts the action/user tuple from the token and verifies the content
|
||||
func ExtractToken(ctx context.Context, token string) (HandlerType, *user_model.User, []byte, error) { |
||||
data, err := encodingWithoutPadding.DecodeString(token) |
||||
if err != nil { |
||||
return UnknownHandlerType, nil, nil, err |
||||
} |
||||
|
||||
if len(data) < 1 { |
||||
return UnknownHandlerType, nil, nil, &ErrToken{"no data"} |
||||
} |
||||
|
||||
if data[0] != tokenVersion1 { |
||||
return UnknownHandlerType, nil, nil, &ErrToken{fmt.Sprintf("unsupported token version: %v", data[0])} |
||||
} |
||||
|
||||
var userID int64 |
||||
var hmac []byte |
||||
var payload []byte |
||||
if err := util.UnpackData(data[1:], &userID, &hmac, &payload); err != nil { |
||||
return UnknownHandlerType, nil, nil, err |
||||
} |
||||
|
||||
user, err := user_model.GetUserByID(ctx, userID) |
||||
if err != nil { |
||||
return UnknownHandlerType, nil, nil, err |
||||
} |
||||
|
||||
if !crypto_hmac.Equal(hmac, generateHmac([]byte(user.Rands), payload)) { |
||||
return UnknownHandlerType, nil, nil, &ErrToken{"verification failed"} |
||||
} |
||||
|
||||
var expiresUnix int64 |
||||
var handlerType HandlerType |
||||
var innerPayload []byte |
||||
if err := util.UnpackData(payload, &expiresUnix, &handlerType, &innerPayload); err != nil { |
||||
return UnknownHandlerType, nil, nil, err |
||||
} |
||||
|
||||
if time.Unix(expiresUnix, 0).Before(time.Now()) { |
||||
return UnknownHandlerType, nil, nil, &ErrToken{"token expired"} |
||||
} |
||||
|
||||
return handlerType, user, innerPayload, nil |
||||
} |
||||
|
||||
// generateHmac creates a trunkated HMAC for the given payload
|
||||
func generateHmac(secret, payload []byte) []byte { |
||||
mac := crypto_hmac.New(sha256.New, secret) |
||||
mac.Write(payload) |
||||
hmac := mac.Sum(nil) |
||||
|
||||
return hmac[:10] // RFC2104 recommends not using less then 80 bits
|
||||
} |
@ -0,0 +1,249 @@ |
||||
// Copyright 2023 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package integration |
||||
|
||||
import ( |
||||
"io" |
||||
"net" |
||||
"net/smtp" |
||||
"strings" |
||||
"testing" |
||||
"time" |
||||
|
||||
"code.gitea.io/gitea/models/db" |
||||
issues_model "code.gitea.io/gitea/models/issues" |
||||
"code.gitea.io/gitea/models/unittest" |
||||
user_model "code.gitea.io/gitea/models/user" |
||||
"code.gitea.io/gitea/modules/setting" |
||||
"code.gitea.io/gitea/services/mailer/incoming" |
||||
incoming_payload "code.gitea.io/gitea/services/mailer/incoming/payload" |
||||
token_service "code.gitea.io/gitea/services/mailer/token" |
||||
"code.gitea.io/gitea/tests" |
||||
|
||||
"github.com/stretchr/testify/assert" |
||||
"gopkg.in/gomail.v2" |
||||
) |
||||
|
||||
func TestIncomingEmail(t *testing.T) { |
||||
defer tests.PrepareTestEnv(t)() |
||||
|
||||
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) |
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1}) |
||||
|
||||
t.Run("Payload", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 1}) |
||||
|
||||
_, err := incoming_payload.CreateReferencePayload(user) |
||||
assert.Error(t, err) |
||||
|
||||
issuePayload, err := incoming_payload.CreateReferencePayload(issue) |
||||
assert.NoError(t, err) |
||||
commentPayload, err := incoming_payload.CreateReferencePayload(comment) |
||||
assert.NoError(t, err) |
||||
|
||||
_, err = incoming_payload.GetReferenceFromPayload(db.DefaultContext, []byte{1, 2, 3}) |
||||
assert.Error(t, err) |
||||
|
||||
ref, err := incoming_payload.GetReferenceFromPayload(db.DefaultContext, issuePayload) |
||||
assert.NoError(t, err) |
||||
assert.IsType(t, ref, new(issues_model.Issue)) |
||||
assert.EqualValues(t, issue.ID, ref.(*issues_model.Issue).ID) |
||||
|
||||
ref, err = incoming_payload.GetReferenceFromPayload(db.DefaultContext, commentPayload) |
||||
assert.NoError(t, err) |
||||
assert.IsType(t, ref, new(issues_model.Comment)) |
||||
assert.EqualValues(t, comment.ID, ref.(*issues_model.Comment).ID) |
||||
}) |
||||
|
||||
t.Run("Token", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
payload := []byte{1, 2, 3, 4, 5} |
||||
|
||||
token, err := token_service.CreateToken(token_service.ReplyHandlerType, user, payload) |
||||
assert.NoError(t, err) |
||||
assert.NotEmpty(t, token) |
||||
|
||||
ht, u, p, err := token_service.ExtractToken(db.DefaultContext, token) |
||||
assert.NoError(t, err) |
||||
assert.Equal(t, token_service.ReplyHandlerType, ht) |
||||
assert.Equal(t, user.ID, u.ID) |
||||
assert.Equal(t, payload, p) |
||||
}) |
||||
|
||||
t.Run("Handler", func(t *testing.T) { |
||||
t.Run("Reply", func(t *testing.T) { |
||||
t.Run("Comment", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
handler := &incoming.ReplyHandler{} |
||||
|
||||
payload, err := incoming_payload.CreateReferencePayload(issue) |
||||
assert.NoError(t, err) |
||||
|
||||
assert.Error(t, handler.Handle(db.DefaultContext, &incoming.MailContent{}, nil, payload)) |
||||
assert.NoError(t, handler.Handle(db.DefaultContext, &incoming.MailContent{}, user, payload)) |
||||
|
||||
content := &incoming.MailContent{ |
||||
Content: "reply by mail", |
||||
Attachments: []*incoming.Attachment{ |
||||
{ |
||||
Name: "attachment.txt", |
||||
Content: []byte("test"), |
||||
}, |
||||
}, |
||||
} |
||||
|
||||
assert.NoError(t, handler.Handle(db.DefaultContext, content, user, payload)) |
||||
|
||||
comments, err := issues_model.FindComments(db.DefaultContext, &issues_model.FindCommentsOptions{ |
||||
IssueID: issue.ID, |
||||
Type: issues_model.CommentTypeComment, |
||||
}) |
||||
assert.NoError(t, err) |
||||
assert.NotEmpty(t, comments) |
||||
comment := comments[len(comments)-1] |
||||
assert.Equal(t, user.ID, comment.PosterID) |
||||
assert.Equal(t, content.Content, comment.Content) |
||||
assert.NoError(t, comment.LoadAttachments(db.DefaultContext)) |
||||
assert.Len(t, comment.Attachments, 1) |
||||
attachment := comment.Attachments[0] |
||||
assert.Equal(t, content.Attachments[0].Name, attachment.Name) |
||||
assert.EqualValues(t, 4, attachment.Size) |
||||
}) |
||||
|
||||
t.Run("CodeComment", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 6}) |
||||
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: comment.IssueID}) |
||||
|
||||
handler := &incoming.ReplyHandler{} |
||||
content := &incoming.MailContent{ |
||||
Content: "code reply by mail", |
||||
Attachments: []*incoming.Attachment{ |
||||
{ |
||||
Name: "attachment.txt", |
||||
Content: []byte("test"), |
||||
}, |
||||
}, |
||||
} |
||||
|
||||
payload, err := incoming_payload.CreateReferencePayload(comment) |
||||
assert.NoError(t, err) |
||||
|
||||
assert.NoError(t, handler.Handle(db.DefaultContext, content, user, payload)) |
||||
|
||||
comments, err := issues_model.FindComments(db.DefaultContext, &issues_model.FindCommentsOptions{ |
||||
IssueID: issue.ID, |
||||
Type: issues_model.CommentTypeCode, |
||||
}) |
||||
assert.NoError(t, err) |
||||
assert.NotEmpty(t, comments) |
||||
comment = comments[len(comments)-1] |
||||
assert.Equal(t, user.ID, comment.PosterID) |
||||
assert.Equal(t, content.Content, comment.Content) |
||||
assert.NoError(t, comment.LoadAttachments(db.DefaultContext)) |
||||
assert.Empty(t, comment.Attachments) |
||||
}) |
||||
}) |
||||
|
||||
t.Run("Unsubscribe", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
watching, err := issues_model.CheckIssueWatch(user, issue) |
||||
assert.NoError(t, err) |
||||
assert.True(t, watching) |
||||
|
||||
handler := &incoming.UnsubscribeHandler{} |
||||
|
||||
content := &incoming.MailContent{ |
||||
Content: "unsub me", |
||||
} |
||||
|
||||
payload, err := incoming_payload.CreateReferencePayload(issue) |
||||
assert.NoError(t, err) |
||||
|
||||
assert.NoError(t, handler.Handle(db.DefaultContext, content, user, payload)) |
||||
|
||||
watching, err = issues_model.CheckIssueWatch(user, issue) |
||||
assert.NoError(t, err) |
||||
assert.False(t, watching) |
||||
}) |
||||
}) |
||||
|
||||
if setting.IncomingEmail.Enabled { |
||||
// This test connects to the configured email server and is currently only enabled for MySql integration tests.
|
||||
// It sends a reply to create a comment. If the comment is not detected after 10 seconds the test fails.
|
||||
t.Run("IMAP", func(t *testing.T) { |
||||
defer tests.PrintCurrentTest(t)() |
||||
|
||||
payload, err := incoming_payload.CreateReferencePayload(issue) |
||||
assert.NoError(t, err) |
||||
token, err := token_service.CreateToken(token_service.ReplyHandlerType, user, payload) |
||||
assert.NoError(t, err) |
||||
|
||||
msg := gomail.NewMessage() |
||||
msg.SetHeader("To", strings.Replace(setting.IncomingEmail.ReplyToAddress, setting.IncomingEmail.TokenPlaceholder, token, 1)) |
||||
msg.SetHeader("From", user.Email) |
||||
msg.SetBody("text/plain", token) |
||||
err = gomail.Send(&smtpTestSender{}, msg) |
||||
assert.NoError(t, err) |
||||
|
||||
assert.Eventually(t, func() bool { |
||||
comments, err := issues_model.FindComments(db.DefaultContext, &issues_model.FindCommentsOptions{ |
||||
IssueID: issue.ID, |
||||
Type: issues_model.CommentTypeComment, |
||||
}) |
||||
assert.NoError(t, err) |
||||
assert.NotEmpty(t, comments) |
||||
|
||||
comment := comments[len(comments)-1] |
||||
|
||||
return comment.PosterID == user.ID && comment.Content == token |
||||
}, 10*time.Second, 1*time.Second) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
// A simple SMTP mail sender used for integration tests.
|
||||
type smtpTestSender struct{} |
||||
|
||||
func (s *smtpTestSender) Send(from string, to []string, msg io.WriterTo) error { |
||||
conn, err := net.Dial("tcp", net.JoinHostPort(setting.IncomingEmail.Host, "25")) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
defer conn.Close() |
||||
|
||||
client, err := smtp.NewClient(conn, setting.IncomingEmail.Host) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
|
||||
if err = client.Mail(from); err != nil { |
||||
return err |
||||
} |
||||
|
||||
for _, rec := range to { |
||||
if err = client.Rcpt(rec); err != nil { |
||||
return err |
||||
} |
||||
} |
||||
|
||||
w, err := client.Data() |
||||
if err != nil { |
||||
return err |
||||
} |
||||
if _, err := msg.WriteTo(w); err != nil { |
||||
return err |
||||
} |
||||
if err := w.Close(); err != nil { |
||||
return err |
||||
} |
||||
|
||||
return client.Quit() |
||||
} |
Loading…
Reference in new issue