Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,19 @@ type PortalConfig struct {
MaxMediaImagesPerPlugin int `mapstructure:"max_media_images_per_plugin" json:"max_media_images_per_plugin,omitempty"`
MaxImageSizeBytes int64 `mapstructure:"max_image_size_bytes" json:"max_image_size_bytes,omitempty"`
PresignedURLExpiry time.Duration `mapstructure:"presigned_url_expiry" json:"presigned_url_expiry,omitempty"`
Email PortalEmailConfig `mapstructure:"email" json:"email,omitempty"`
DeveloperServiceURL string `mapstructure:"developer_service_url" json:"developer_service_url,omitempty"`
}

type PortalEmailConfig struct {
MandrillAPIKey string `mapstructure:"mandrill_api_key" json:"mandrill_api_key,omitempty"`
FromEmail string `mapstructure:"from_email" json:"from_email,omitempty"`
FromName string `mapstructure:"from_name" json:"from_name,omitempty"`
NotificationEmails []string `mapstructure:"notification_emails" json:"notification_emails,omitempty"`
}

func (c PortalEmailConfig) IsConfigured() bool {
return c.MandrillAPIKey != "" && c.FromEmail != "" && len(c.NotificationEmails) > 0
}

func GetConfigure() (*WorkerConfig, error) {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/ipfs/go-log v1.0.5 // indirect
github.com/ipfs/go-log/v2 v2.5.1 // indirect
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,8 @@ github.com/ipfs/go-log v1.0.5/go.mod h1:j0b8ZoR+7+R99LD9jZ6+AJsrzkPbSXbZfGakb5JP
github.com/ipfs/go-log/v2 v2.1.3/go.mod h1:/8d0SH3Su5Ooc31QlL1WysJhvyOTDCjcCZ9Axpmri6g=
github.com/ipfs/go-log/v2 v2.5.1 h1:1XdUzF7048prq4aBjDQQ4SL5RxftpRGdXhNRwKSAlcY=
github.com/ipfs/go-log/v2 v2.5.1/go.mod h1:prSpmC1Gpllc9UYWxDiZDreBYw7zp4Iqp1kOLU9U5UI=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
Expand Down
4 changes: 4 additions & 0 deletions internal/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ const (
msgInvalidSignatureFormat = "invalid signature format"
msgSignatureVerificationFailed = "signature verification failed"

// Proposed plugins
msgProposedPluginValidateFailed = "failed to validate proposed plugin"
msgProposedPluginNotApproved = "proposed plugin not found or not approved"

// General
msgInvalidRequestFormat = "invalid request format"
msgRequestValidationFailed = "request validation failed"
Expand Down
24 changes: 24 additions & 0 deletions internal/api/proposed_plugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package api

import (
"net/http"

"github.com/labstack/echo/v4"
)

func (s *Server) ValidateProposedPlugin(c echo.Context) error {
pluginID := c.Param("pluginId")
if pluginID == "" {
return s.badRequest(c, msgRequiredPluginID, nil)
}

approved, err := s.db.IsProposedPluginApproved(c.Request().Context(), pluginID)
if err != nil {
return s.internal(c, msgProposedPluginValidateFailed, err)
}
if !approved {
return c.JSON(http.StatusNotFound, NewErrorResponseWithMessage(msgProposedPluginNotApproved))
}

return c.JSON(http.StatusOK, NewSuccessResponse(http.StatusOK, map[string]bool{"valid": true}))
}
1 change: 1 addition & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ func (s *Server) StartServer() error {
pluginsGroup.GET("/:pluginId/skills", s.GetPluginSkills)
pluginsGroup.GET("/:pluginId/average-rating", s.GetPluginAvgRating)
pluginsGroup.POST("/:pluginId/report", s.ReportPlugin, s.VaultAuthMiddleware)
pluginsGroup.GET("/proposed/validate/:pluginId", s.ValidateProposedPlugin, s.VaultAuthMiddleware)

categoriesGroup := e.Group("/categories")
categoriesGroup.GET("", s.GetCategories)
Expand Down
305 changes: 305 additions & 0 deletions internal/portal/email.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,305 @@
package portal

import (
"bytes"
"context"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"net/url"
"strings"
"time"

"github.com/sirupsen/logrus"

"github.com/vultisig/verifier/config"
)

const mandrillSendURL = "https://mandrillapp.com/api/1.0/messages/send.json"

func maskEmail(email string) string {
at := strings.Index(email, "@")
if at <= 1 {
return "***"
}
return email[:1] + strings.Repeat("*", at-1) + email[at:]
}

type EmailSender interface {
IsConfigured() bool
SendProposalNotificationAsync(pluginID, title, contactEmail string)
SendApprovalNotificationAsync(pluginID, title, contactEmail string)
SendPublishNotificationAsync(pluginID, title, contactEmail string)
}

type EmailService struct {
cfg config.PortalEmailConfig
portalURL string
mandrillURL string
client *http.Client
logger *logrus.Logger
}

func NewEmailService(cfg config.PortalEmailConfig, portalURL string, logger *logrus.Logger) *EmailService {
return &EmailService{
cfg: cfg,
portalURL: strings.TrimRight(portalURL, "/"),
mandrillURL: mandrillSendURL,
logger: logger,
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}

func (s *EmailService) IsConfigured() bool {
return s.cfg.IsConfigured()
}

type mandrillMessage struct {
Key string `json:"key"`
Message mandrillMessageBody `json:"message"`
}

type mandrillMessageBody struct {
FromEmail string `json:"from_email"`
FromName string `json:"from_name"`
To []mandrillRecipient `json:"to"`
Subject string `json:"subject"`
HTML string `json:"html"`
Text string `json:"text"`
}

type mandrillRecipient struct {
Email string `json:"email"`
Type string `json:"type"`
}

type mandrillSendResult struct {
Email string `json:"email"`
Status string `json:"status"`
RejectReason string `json:"reject_reason,omitempty"`
}

func (s *EmailService) SendProposalNotificationAsync(pluginID, title, contactEmail string) {
if !s.IsConfigured() {
return
}

go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

err := s.sendProposalNotification(ctx, pluginID, title, contactEmail)
if err != nil {
s.logger.WithError(err).WithFields(logrus.Fields{
"plugin_id": pluginID,
}).Error("failed to send proposal notification email")
}
}()
}

func (s *EmailService) sendProposalNotification(ctx context.Context, pluginID, title, contactEmail string) error {
pid := html.EscapeString(pluginID)
t := html.EscapeString(title)
ce := html.EscapeString(contactEmail)

proposalURL := fmt.Sprintf("%s/admin/plugin-proposals/%s", s.portalURL, url.PathEscape(pluginID))

subject := fmt.Sprintf("New Plugin Proposal: %s", t)
htmlBody := fmt.Sprintf(`
<h2>New Plugin Proposal Submitted</h2>
<p>A new plugin proposal has been submitted for review.</p>
<table style="border-collapse: collapse; margin: 20px 0;">
<tr>
<td style="padding: 8px; font-weight: bold;">Plugin ID:</td>
<td style="padding: 8px;">%s</td>
</tr>
<tr>
<td style="padding: 8px; font-weight: bold;">Title:</td>
<td style="padding: 8px;">%s</td>
</tr>
<tr>
<td style="padding: 8px; font-weight: bold;">Contact Email:</td>
<td style="padding: 8px;">%s</td>
</tr>
</table>
<p><a href="%s">View proposal in admin portal</a></p>
`, pid, t, ce, html.EscapeString(proposalURL))

text := fmt.Sprintf(`New Plugin Proposal Submitted

Plugin ID: %s
Title: %s
Contact Email: %s

View proposal: %s
`, pluginID, title, contactEmail, proposalURL)

return s.sendToAdmins(ctx, subject, htmlBody, text)
}

// TODO: migrate async methods to use Redis/Asynq queue for reliability and retries
func (s *EmailService) SendApprovalNotificationAsync(pluginID, title, contactEmail string) {
if !s.IsConfigured() || contactEmail == "" {
return
}

go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

err := s.sendApprovalNotification(ctx, pluginID, title, contactEmail)
if err != nil {
s.logger.WithError(err).WithFields(logrus.Fields{
"plugin_id": pluginID,
"email": maskEmail(contactEmail),
}).Error("failed to send approval notification email")
}
}()
}

func (s *EmailService) sendApprovalNotification(ctx context.Context, pluginID, title, contactEmail string) error {
t := html.EscapeString(title)

subject := fmt.Sprintf("Your Plugin Proposal Has Been Approved: %s", t)
htmlBody := fmt.Sprintf(`
<h2>Plugin Proposal Approved</h2>
<p>Your plugin proposal <strong>%s</strong> has been approved.</p>
<p>To complete the listing process:</p>
<ol>
<li>Pay the listing fee through the developer portal</li>
<li>Once payment is confirmed, your plugin will be published automatically</li>
</ol>
<p>Thank you for contributing to Vultisig!</p>
`, t)

text := fmt.Sprintf(`Plugin Proposal Approved

Your plugin proposal "%s" has been approved.

To complete the listing process:
1. Pay the listing fee through the developer portal
2. Once payment is confirmed, your plugin will be published automatically

Thank you for contributing to Vultisig!
`, title)

return s.sendToRecipient(ctx, contactEmail, subject, htmlBody, text)
}

func (s *EmailService) SendPublishNotificationAsync(pluginID, title, contactEmail string) {
if !s.IsConfigured() || contactEmail == "" {
return
}

go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

err := s.sendPublishNotification(ctx, pluginID, title, contactEmail)
if err != nil {
s.logger.WithError(err).WithFields(logrus.Fields{
"plugin_id": pluginID,
"email": maskEmail(contactEmail),
}).Error("failed to send publish notification email")
}
}()
}

func (s *EmailService) sendPublishNotification(ctx context.Context, pluginID, title, contactEmail string) error {
t := html.EscapeString(title)
pid := html.EscapeString(pluginID)
pluginURL := fmt.Sprintf("%s/plugins/%s", s.portalURL, url.PathEscape(pluginID))

subject := fmt.Sprintf("Your Plugin Is Now Live: %s", t)
htmlBody := fmt.Sprintf(`
<h2>Plugin Published!</h2>
<p>Your plugin <strong>%s</strong> is now live on the Vultisig marketplace.</p>
<p><a href="%s">View your plugin</a></p>
<p>Plugin ID: %s</p>
<p>Thank you for contributing to Vultisig!</p>
`, t, html.EscapeString(pluginURL), pid)

text := fmt.Sprintf(`Plugin Published!

Your plugin "%s" is now live on the Vultisig marketplace.

View your plugin: %s

Plugin ID: %s

Thank you for contributing to Vultisig!
`, title, pluginURL, pluginID)

return s.sendToRecipient(ctx, contactEmail, subject, htmlBody, text)
}

func (s *EmailService) sendToAdmins(ctx context.Context, subject, htmlBody, text string) error {
recipients := make([]mandrillRecipient, len(s.cfg.NotificationEmails))
for i, email := range s.cfg.NotificationEmails {
recipients[i] = mandrillRecipient{Email: email, Type: "to"}
}
return s.sendEmail(ctx, recipients, subject, htmlBody, text)
}

func (s *EmailService) sendToRecipient(ctx context.Context, email, subject, htmlBody, text string) error {
recipients := []mandrillRecipient{{Email: email, Type: "to"}}
return s.sendEmail(ctx, recipients, subject, htmlBody, text)
}

func (s *EmailService) sendEmail(ctx context.Context, recipients []mandrillRecipient, subject, htmlBody, text string) error {
msg := mandrillMessage{
Key: s.cfg.MandrillAPIKey,
Message: mandrillMessageBody{
FromEmail: s.cfg.FromEmail,
FromName: s.cfg.FromName,
To: recipients,
Subject: subject,
HTML: htmlBody,
Text: text,
},
}

body, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("marshal email request: %w", err)
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.mandrillURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create email request: %w", err)
}
req.Header.Set("Content-Type", "application/json")

resp, err := s.client.Do(req)
if err != nil {
return fmt.Errorf("send email request: %w", err)
}
defer resp.Body.Close()

respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response body: %w", err)
}

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("mandrill returned status %d: %s", resp.StatusCode, string(respBody))
}

var results []mandrillSendResult
err = json.Unmarshal(respBody, &results)
if err != nil {
return fmt.Errorf("unmarshal response: %w", err)
}

for _, r := range results {
if r.Status != "sent" && r.Status != "queued" {
return fmt.Errorf("email to %s failed: %s (%s)", maskEmail(r.Email), r.Status, r.RejectReason)
}
}

return nil
}
Loading
Loading