api: send event notifications via meta.sr.ht GQL

This commit implements the sending of event notification emails via the
meta.sr.ht GraphQL API, which will cause the emails to be properly
signed and potentially encrypted (according to the recipients privacy
settings).

Almost the entire SendEmail function had to be modified (though much of
it only moved). While doing so, a few minor issues were discovered and
also fixed:

* The check `if len(name) == 0 || len(address) == 0` was used to filter
  out "external" participants, but it also filtered out "email"
  participants that happened to not use a name in their From-header;
  fixed by checking the participant type as stored in the DB instead
* In the query for the event participants, the name of "email"
  participants was `Scan()`ned into a `string`, but it can be `NULL`;
  fixed by coalescing with empty string
* Removed the last usage of Squirrel in this file
This commit is contained in:
Conrad Hoffmann 2023-02-21 12:36:48 +01:00 committed by Drew DeVault
parent a4ef456246
commit 210f551564
2 changed files with 155 additions and 82 deletions

View File

@ -3,12 +3,12 @@ package graph
import (
"context"
"database/sql"
"io"
"strings"
"text/template"
"git.sr.ht/~sircmpwn/core-go/client"
"git.sr.ht/~sircmpwn/core-go/config"
"git.sr.ht/~sircmpwn/core-go/email"
sq "github.com/Masterminds/squirrel"
"github.com/emersion/go-message/mail"
"git.sr.ht/~sircmpwn/todo.sr.ht/api/graph/model"
@ -272,10 +272,42 @@ func (builder *EventBuilder) InsertNotifications(eventID int, commentID *int) {
}
}
func sendEmailNotification(ctx context.Context, username, message string) error {
var resp struct {
Ok bool
}
return client.Execute(ctx, "", "meta.sr.ht", client.GraphQLQuery{
Query: `
mutation sendEmail($username: String!, $message: String!) {
sendEmailNotification(username: $username, message: $message)
}`,
Variables: map[string]interface{}{
"username": username,
"message": message,
},
}, &resp)
}
func sendEmailExternal(ctx context.Context, address, message string) error {
var resp struct {
Ok bool
}
return client.Execute(ctx, "", "meta.sr.ht", client.GraphQLQuery{
Query: `
mutation sendEmail($address: String!, $message: String!) {
sendEmailExternal(address: $address, message: $message)
}`,
Variables: map[string]interface{}{
"address": address,
"message": message,
},
}, &resp)
}
func (builder *EventBuilder) SendEmails(subject string,
template *template.Template, context interface{}) {
var (
rcpts []mail.Address
submitterType string
submitterName string
submitterEmail string
notifySelf, copiedSelf bool
@ -283,8 +315,9 @@ func (builder *EventBuilder) SendEmails(subject string,
row := builder.tx.QueryRowContext(builder.ctx, `
SELECT
part.participant_type,
CASE part.participant_type
WHEN 'user' THEN '~' || "user".username
WHEN 'user' THEN "user".username
WHEN 'email' THEN part.email_name
ELSE '' END,
CASE part.participant_type
@ -298,71 +331,12 @@ func (builder *EventBuilder) SendEmails(subject string,
LEFT JOIN "user" ON "user".id = part.user_id
WHERE part.id = $1
`, builder.submitterID)
if err := row.Scan(&submitterName, &submitterEmail, &notifySelf); err != nil {
if err := row.Scan(&submitterType, &submitterName, &submitterEmail, &notifySelf); err != nil {
panic(err)
}
// XXX: It may be possible to implement this more efficiently by skipping
// the joins and pre-stashing the email details when inserting
// event_participants.
subs := sq.Select(`
CASE part.participant_type
WHEN 'user' THEN '~' || "user".username
WHEN 'email' THEN part.email_name
ELSE '' END
`, `
CASE part.participant_type
WHEN 'user' THEN "user".email
WHEN 'email' THEN part.email
ELSE '' END
`).
Distinct().
From(`event_participant evpart`).
Join(`participant part ON evpart.participant_id = part.id`).
LeftJoin(`"user" ON "user".id = part.user_id`)
rows, err := subs.
PlaceholderFormat(sq.Dollar).
RunWith(builder.tx).
QueryContext(builder.ctx)
if err != nil && err != sql.ErrNoRows {
panic(err)
}
set := make(map[string]interface{})
for rows.Next() {
var name, address string
if err := rows.Scan(&name, &address); err != nil {
panic(err)
}
if len(name) == 0 || len(address) == 0 {
continue
}
if address == submitterEmail {
if notifySelf {
copiedSelf = true
} else {
continue
}
}
if _, ok := set[address]; ok {
continue
}
set[address] = nil
rcpts = append(rcpts, mail.Address{
Name: name,
Address: address,
})
}
if notifySelf && !copiedSelf {
rcpts = append(rcpts, mail.Address{
Name: submitterName,
Address: submitterEmail,
})
}
var body strings.Builder
err = template.Execute(&body, context)
if err != nil {
if err := template.Execute(&body, context); err != nil {
panic(err)
}
@ -387,7 +361,7 @@ func (builder *EventBuilder) SendEmails(subject string,
}
from := mail.Address{
Name: submitterName,
Name: "~" + submitterName,
Address: notifyFrom,
}
sender := mail.Address{
@ -400,23 +374,101 @@ func (builder *EventBuilder) SendEmails(subject string,
Address: ticketRef,
}
for _, rcpt := range rcpts {
var header mail.Header
// TODO: List-Unsubscribe header
header.SetAddressList("To", []*mail.Address{&rcpt})
header.SetAddressList("From", []*mail.Address{&from})
header.SetAddressList("Reply-To", []*mail.Address{&ticketAddress})
header.SetAddressList("Sender", []*mail.Address{&sender})
if builder.eventType == model.EVENT_CREATED {
header.SetMessageID(ticketRef)
} else {
header.SetMsgIDList("In-Reply-To", []string{ticketRef})
}
header.SetSubject(subject)
// Generate the email, minus recipient
var header mail.Header
var message strings.Builder
// TODO: List-Unsubscribe header
header.SetAddressList("From", []*mail.Address{&from})
header.SetAddressList("Reply-To", []*mail.Address{&ticketAddress})
header.SetAddressList("Sender", []*mail.Address{&sender})
if builder.eventType == model.EVENT_CREATED {
header.SetMessageID(ticketRef)
} else {
header.SetMsgIDList("In-Reply-To", []string{ticketRef})
}
header.SetSubject(subject)
msgBodyWriter, err := mail.CreateSingleInlineWriter(&message, header)
if err != nil {
panic(err)
}
_, err = io.WriteString(msgBodyWriter, body.String())
if err != nil {
panic(err)
}
msgBodyWriter.Close()
// TODO: Fetch user PGP key (or send via meta.sr.ht API?)
err = email.EnqueueStd(builder.ctx, header,
strings.NewReader(body.String()), nil)
// XXX: It may be possible to implement this more efficiently by skipping
// the joins and pre-stashing the email details when inserting
// event_participants.
rows, err := builder.tx.QueryContext(builder.ctx, `
SELECT
part.participant_type,
CASE part.participant_type
WHEN 'user' THEN "user".username
WHEN 'email' THEN COALESCE(part.email_name, '')
ELSE '' END,
CASE part.participant_type
WHEN 'user' THEN "user".email
WHEN 'email' THEN part.email
ELSE '' END
DISTINCT
FROM event_participant evpart
JOIN participant part ON evpart.participant_id = part.id
LEFT JOIN "user" ON "user".id = part.user_id;
`)
if err != nil {
panic(err)
}
set := make(map[string]interface{})
for rows.Next() {
var participantTypeString, name, address string
if err := rows.Scan(&participantTypeString, &name, &address); err != nil {
panic(err)
}
participantType := model.ParticipantTypeFromString(participantTypeString)
if participantType == model.ParticipantTypeExternal {
continue
}
if address == submitterEmail {
if notifySelf {
copiedSelf = true
} else {
continue
}
}
if _, ok := set[address]; ok {
continue
}
set[address] = nil
switch participantType {
case model.ParticipantTypeUser:
err = sendEmailNotification(builder.ctx, name, message.String())
case model.ParticipantTypeEmail:
to := mail.Address{
Name: name,
Address: address,
}
err = sendEmailExternal(builder.ctx, to.String(), message.String())
}
if err != nil {
panic(err)
}
}
if notifySelf && !copiedSelf {
var err error
participantType := model.ParticipantTypeFromString(submitterType)
switch participantType {
case model.ParticipantTypeUser:
err = sendEmailNotification(builder.ctx, submitterName, message.String())
case model.ParticipantTypeEmail:
to := mail.Address{
Name: submitterName,
Address: submitterEmail,
}
err = sendEmailExternal(builder.ctx, to.String(), message.String())
}
if err != nil {
panic(err)
}

View File

@ -8,6 +8,27 @@ type Participant struct {
// Note: Right now we don't need any other fields
}
type ParticipantType string
const (
ParticipantTypeUser ParticipantType = "user"
ParticipantTypeEmail ParticipantType = "email"
ParticipantTypeExternal ParticipantType = "external"
)
func ParticipantTypeFromString(participantType string) ParticipantType {
switch participantType {
case "user":
return ParticipantTypeUser
case "email":
return ParticipantTypeEmail
case "external":
return ParticipantTypeExternal
default:
panic("database invariant broken")
}
}
type EmailAddress struct {
Mailbox string `json:"mailbox"`
Name *string `json:"name"`