diff --git a/backend/app/administration.go b/backend/app/administration.go index 81c039a..5a37b1a 100644 --- a/backend/app/administration.go +++ b/backend/app/administration.go @@ -132,6 +132,7 @@ const ( ROUTE_V1_CAMPAIGN_STATS = "/api/v1/campaign/statistics" ROUTE_V1_CAMPAIGN_STATS_ID = "/api/v1/campaign/:id/stats" ROUTE_V1_CAMPAIGN_STATS_ALL = "/api/v1/campaign/stats/all" + ROUTE_V1_CAMPAIGN_UPLOAD_REPORTED = "/api/v1/campaign/:id/upload/reported" // campaign-recipient ROUTE_V1_CAMPAIGN_RECIPIENT_EMAIL = "/api/v1/campaign/recipient/:id/email" ROUTE_V1_CAMPAIGN_RECIPIENT_URL = "/api/v1/campaign/recipient/:id/url" @@ -364,6 +365,7 @@ func setupRoutes( POST(ROUTE_V1_CAMPAIGN_CLOSE, middleware.SessionHandler, controllers.Campaign.CloseCampaignByID). GET(ROUTE_V1_CAMPAIGN_EXPORT_EVENTS, middleware.SessionHandler, controllers.Campaign.ExportEventsAsCSV). GET(ROUTE_V1_CAMPAIGN_EXPORT_SUBMISSIONS, middleware.SessionHandler, controllers.Campaign.ExportSubmissionsAsCSV). + POST(ROUTE_V1_CAMPAIGN_UPLOAD_REPORTED, middleware.SessionHandler, controllers.Campaign.UploadReportedCSV). POST(ROUTE_V1_CAMPAIGN_ANONYMIZE, middleware.SessionHandler, controllers.Campaign.AnonymizeByID). DELETE(ROUTE_V1_CAMPAIGN_ID, middleware.SessionHandler, controllers.Campaign.DeleteByID). // campaign-recipient diff --git a/backend/cache/local.go b/backend/cache/local.go index a123c5c..4b5bf09 100644 --- a/backend/cache/local.go +++ b/backend/cache/local.go @@ -37,6 +37,7 @@ func IsUpdateAvailable() bool { // readonly var CampaignEventPriority = map[string]int{ // campaign recipient events + data.EVENT_CAMPAIGN_RECIPIENT_REPORTED: 90, data.EVENT_CAMPAIGN_RECIPIENT_CANCELLED: 80, data.EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA: 70, data.EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED: 60, diff --git a/backend/controller/campaign.go b/backend/controller/campaign.go index 1e66399..ee2c6cc 100644 --- a/backend/controller/campaign.go +++ b/backend/controller/campaign.go @@ -3,6 +3,8 @@ package controller import ( "bytes" "encoding/csv" + "io" + "strings" "time" "github.com/go-errors/errors" @@ -935,3 +937,70 @@ func (c *Campaign) GetAllCampaignStats(g *gin.Context) { } c.Response.OK(g, stats) } + +// UploadReportedCSV uploads a CSV file with reported recipients +func (c *Campaign) UploadReportedCSV(g *gin.Context) { + // handle session + session, _, ok := c.handleSession(g) + if !ok { + return + } + // parse campaign id + id, ok := c.handleParseIDParam(g) + if !ok { + return + } + + // get the uploaded file + file, header, err := g.Request.FormFile("file") + if err != nil { + c.Response.ValidationFailed(g, "file", err) + return + } + defer file.Close() + + // validate file extension + if !strings.HasSuffix(strings.ToLower(header.Filename), ".csv") { + c.Response.ValidationFailed(g, "file", errors.New("file must be a CSV")) + return + } + + // read file content + content, err := io.ReadAll(file) + if err != nil { + c.Response.ValidationFailed(g, "file", err) + return + } + + // parse CSV + reader := csv.NewReader(strings.NewReader(string(content))) + records, err := reader.ReadAll() + if err != nil { + c.Logger.Errorw("failed to parse CSV file", "error", err) + c.Response.ValidationFailed(g, "file", errors.New("failed to parse CSV file: "+err.Error())) + return + } + + if len(records) < 2 { + c.Logger.Debugw("CSV file has insufficient rows", "rows", len(records)) + c.Response.ValidationFailed(g, "file", errors.New("CSV file must have header and at least one data row")) + return + } + + c.Logger.Debugw("processing CSV", "rows", len(records), "headers", records[0]) + + // process CSV + processed, skipped, err := c.CampaignService.ProcessReportedCSV(g.Request.Context(), session, id, records) + if err != nil { + c.Logger.Errorw("failed to process reported CSV", "error", err) + if ok := c.handleErrors(g, err); !ok { + return + } + } + + c.Response.OK(g, gin.H{ + "processed": processed, + "skipped": skipped, + "message": "CSV processed successfully", + }) +} diff --git a/backend/data/events.go b/backend/data/events.go index b01ca8c..e04950a 100644 --- a/backend/data/events.go +++ b/backend/data/events.go @@ -14,6 +14,7 @@ const ( EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED = "campaign_recipient_page_visited" EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED = "campaign_recipient_after_page_visited" EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA = "campaign_recipient_submitted_data" + EVENT_CAMPAIGN_RECIPIENT_REPORTED = "campaign_recipient_reported" EVENT_CAMPAIGN_RECIPIENT_CANCELLED = "campaign_recipient_cancelled" ) @@ -32,5 +33,6 @@ var Events = []string{ EVENT_CAMPAIGN_RECIPIENT_PAGE_VISITED, EVENT_CAMPAIGN_RECIPIENT_AFTER_PAGE_VISITED, EVENT_CAMPAIGN_RECIPIENT_SUBMITTED_DATA, + EVENT_CAMPAIGN_RECIPIENT_REPORTED, EVENT_CAMPAIGN_RECIPIENT_CANCELLED, } diff --git a/backend/database/campaignStats.go b/backend/database/campaignStats.go index 965dc1b..2bc4747 100644 --- a/backend/database/campaignStats.go +++ b/backend/database/campaignStats.go @@ -35,11 +35,13 @@ type CampaignStats struct { TrackingPixelLoaded int `gorm:"not null;default:0" json:"trackingPixelLoaded"` // Email opens WebsiteVisits int `gorm:"not null;default:0" json:"websiteVisits"` // Link clicks DataSubmissions int `gorm:"not null;default:0" json:"dataSubmissions"` // Form submissions + Reported int `gorm:"not null;default:0" json:"reported"` // Reported phishing // Success rates (as percentages for quick display) OpenRate float64 `gorm:"not null;default:0" json:"openRate"` ClickRate float64 `gorm:"not null;default:0" json:"clickRate"` SubmissionRate float64 `gorm:"not null;default:0" json:"submissionRate"` + ReportRate float64 `gorm:"not null;default:0" json:"reportRate"` // Campaign metadata TemplateName string `gorm:"" json:"templateName"` diff --git a/backend/model/campaignResultView.go b/backend/model/campaignResultView.go index 938e338..3a4d291 100644 --- a/backend/model/campaignResultView.go +++ b/backend/model/campaignResultView.go @@ -6,4 +6,5 @@ type CampaignResultView struct { TrackingPixelLoaded int64 `json:"trackingPixelLoaded"` WebsiteLoaded int64 `json:"clickedLink"` SubmittedData int64 `json:"submittedData"` + Reported int64 `json:"reported"` } diff --git a/backend/model/recipientCampaignStatsView.go b/backend/model/recipientCampaignStatsView.go index d40d975..048edee 100644 --- a/backend/model/recipientCampaignStatsView.go +++ b/backend/model/recipientCampaignStatsView.go @@ -5,6 +5,7 @@ type RecipientCampaignStatsView struct { CampaignsTrackingPixelLoaded int64 `json:"campaignsTrackingPixelLoaded"` CampaignsPhishingPageLoaded int64 `json:"campaignsPhishingPageLoaded"` CampaignsDataSubmitted int64 `json:"campaignsDataSubmitted"` + CampaignsReported int64 `json:"campaignsReported"` RepeatLinkClicks int64 `json:"repeatLinkClicks"` RepeatSubmissions int64 `json:"repeatSubmissions"` } diff --git a/backend/repository/campaign.go b/backend/repository/campaign.go index 773c0dc..5b1c567 100644 --- a/backend/repository/campaign.go +++ b/backend/repository/campaign.go @@ -794,6 +794,32 @@ func (r *Campaign) GetResultStats( return nil, res.Error } + // Get unique reported + res = r.DB.Raw(` + SELECT COUNT(*) FROM ( + SELECT DISTINCT recipient_id + FROM campaign_events + WHERE campaign_id = ? + AND event_id = ? + AND recipient_id IS NOT NULL + UNION + SELECT DISTINCT anonymized_id + FROM campaign_events + WHERE campaign_id = ? + AND event_id = ? + AND anonymized_id IS NOT NULL + ) as unique_ids +`, + campaignID, + cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_REPORTED], + campaignID, + cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_REPORTED], + ).Scan(&stats.Reported) + + if res.Error != nil { + return nil, res.Error + } + return stats, nil } diff --git a/backend/repository/recipient.go b/backend/repository/recipient.go index f89a875..542904a 100644 --- a/backend/repository/recipient.go +++ b/backend/repository/recipient.go @@ -419,6 +419,18 @@ func (r *Recipient) GetStatsByID( Distinct("campaign_events.campaign_id"). Count(&stats.CampaignsDataSubmitted) + // get unique reported campaigns + r.DB.Model(&database.CampaignEvent{}). + Joins("JOIN campaigns ON campaigns.id = campaign_events.campaign_id"). + Where( + "campaign_events.recipient_id = ? AND campaign_events.event_id = ? AND campaigns.is_test = ?", + id, + cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_REPORTED], + false, + ). + Distinct("campaign_events.campaign_id"). + Count(&stats.CampaignsReported) + // Get repeat link clicks in last selected threshold months var linkClickCount int64 r.DB.Model(&database.CampaignEvent{}). diff --git a/backend/service/campaign.go b/backend/service/campaign.go index 52da103..c2398c0 100644 --- a/backend/service/campaign.go +++ b/backend/service/campaign.go @@ -766,7 +766,6 @@ func (c *Campaign) GetStats( if err != nil { return nil, errs.Wrap(err) } - // no audit on read return &model.CampaignsStatView{ Active: active, Upcoming: upcoming, @@ -3051,11 +3050,13 @@ func (c *Campaign) GenerateCampaignStats(ctx context.Context, session *model.Ses openRate := float64(0) clickRate := float64(0) submissionRate := float64(0) + reportRate := float64(0) if resultStats.Recipients > 0 { openRate = (float64(resultStats.TrackingPixelLoaded) / float64(resultStats.Recipients)) * 100 clickRate = (float64(resultStats.WebsiteLoaded) / float64(resultStats.Recipients)) * 100 submissionRate = (float64(resultStats.SubmittedData) / float64(resultStats.Recipients)) * 100 + reportRate = (float64(resultStats.Reported) / float64(resultStats.Recipients)) * 100 } // Determine campaign type @@ -3116,9 +3117,11 @@ func (c *Campaign) GenerateCampaignStats(ctx context.Context, session *model.Ses TrackingPixelLoaded: int(resultStats.TrackingPixelLoaded), WebsiteVisits: int(resultStats.WebsiteLoaded), DataSubmissions: int(resultStats.SubmittedData), + Reported: int(resultStats.Reported), OpenRate: openRate, ClickRate: clickRate, SubmissionRate: submissionRate, + ReportRate: reportRate, TemplateName: templateName, CampaignType: campaignType, @@ -3184,3 +3187,265 @@ func (c *Campaign) GetAllCampaignStats(ctx context.Context, session *model.Sessi return result, nil } + +// ProcessReportedCSV processes a CSV file with reported recipients +func (c *Campaign) ProcessReportedCSV( + ctx context.Context, + session *model.Session, + campaignID *uuid.UUID, + records [][]string, +) (int, int, error) { + ae := NewAuditEvent("Campaign.ProcessReportedCSV", session) + ae.Details["campaignID"] = campaignID.String() + + // check permissions + isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) + if err != nil && !errors.Is(err, errs.ErrAuthorizationFailed) { + c.LogAuthError(err) + return 0, 0, errs.Wrap(err) + } + if !isAuthorized { + c.AuditLogNotAuthorized(ae) + return 0, 0, errs.ErrAuthorizationFailed + } + + // get campaign to check it exists and get details + campaign, err := c.CampaignRepository.GetByID(ctx, campaignID, &repository.CampaignOption{}) + if err != nil { + c.Logger.Errorw("failed to get campaign by id", "error", err) + return 0, 0, errs.Wrap(err) + } + + // validate CSV headers + headers := records[0] + reportedByIndex := -1 + dateReportedIndex := -1 + + c.Logger.Debugw("processing CSV headers", "headers", headers) + + for i, header := range headers { + switch strings.ToLower(strings.TrimSpace(header)) { + case "reported by": + reportedByIndex = i + c.Logger.Debugw("found reported by column", "index", i) + case "date reporter (utc+02:00)", "date reported(utc+02:00)", "date reported", "date reporter": + dateReportedIndex = i + c.Logger.Debugw("found date column", "index", i, "header", header) + } + } + + if reportedByIndex == -1 { + c.Logger.Errorw("CSV missing required column", "expected", "reported by", "headers", headers) + return 0, 0, errs.NewValidationError(errors.New("CSV must have 'reported by' column")) + } + if dateReportedIndex == -1 { + c.Logger.Errorw("CSV missing required date column", "expected", "date reported(utc+02:00)", "headers", headers) + return 0, 0, errs.NewValidationError(errors.New("CSV must have 'date reporter (utc+02:00)' or similar date column")) + } + + processed := 0 + skipped := 0 + reportedEventID := cache.EventIDByName[data.EVENT_CAMPAIGN_RECIPIENT_REPORTED] + + // process each row + for i, record := range records[1:] { // skip header + if len(record) <= reportedByIndex || len(record) <= dateReportedIndex { + skipped++ + c.Logger.Debugw("skipping row with insufficient columns", "row", i+2) + continue + } + + reportedByEmail := strings.TrimSpace(record[reportedByIndex]) + dateReported := strings.TrimSpace(record[dateReportedIndex]) + + if reportedByEmail == "" { + skipped++ + c.Logger.Debugw("skipping row with empty email", "row", i+2) + continue + } + // parse date - try multiple formats and handle timezone + var parsedDate time.Time + dateFormats := []string{ + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + "2006-01-02T15:04:05Z", + "2006-01-02T15:04:05-07:00", + "2006-01-02T15:04:05+02:00", + "2006-01-02", + "01/02/2006 15:04:05", + "01/02/2006", + "02-01-2006 15:04:05", + "02-01-2006", + } + + dateParseError := true + for _, format := range dateFormats { + if pd, err := time.Parse(format, dateReported); err == nil { + // if the parsed date has no timezone info and the header mentions UTC+02:00, + // assume the time is in UTC+02:00 and convert to UTC + if pd.Location() == time.UTC && strings.Contains(strings.ToLower(headers[dateReportedIndex]), "utc+02:00") { + // treat as UTC+02:00 and convert to UTC + loc, _ := time.LoadLocation("Europe/Berlin") // UTC+2 (or use FixedZone) + if loc != nil { + pd = time.Date(pd.Year(), pd.Month(), pd.Day(), pd.Hour(), pd.Minute(), pd.Second(), pd.Nanosecond(), loc).UTC() + } + } + parsedDate = pd + dateParseError = false + break + } + } + + if dateParseError { + skipped++ + c.Logger.Debugw("skipping row with invalid date format", "row", i+2, "date", dateReported, "tried_formats", dateFormats) + continue + } + + c.Logger.Debugw("processing row", "row", i+2, "email", reportedByEmail, "date", parsedDate) + + // find recipient by email in this campaign + emailVO, err := vo.NewEmail(reportedByEmail) + if err != nil { + skipped++ + c.Logger.Debugw("invalid email format", "email", reportedByEmail, "row", i+2) + continue + } + + // Get campaign to check company context + companyID, _ := campaign.CompanyID.Get() + var companyPtr *uuid.UUID + if companyID != uuid.Nil { + companyPtr = &companyID + } + + recipient, err := c.RecipientService.GetByEmail(ctx, session, emailVO, companyPtr) + if err != nil { + skipped++ + c.Logger.Debugw("recipient not found for email", "email", reportedByEmail, "row", i+2) + continue + } + + recipientID := recipient.ID.MustGet() + + // check if recipient is part of this campaign + campaignRecipient, err := c.CampaignRecipientRepository.GetByCampaignAndRecipientID( + ctx, + campaignID, + &recipientID, + &repository.CampaignRecipientOption{}, + ) + if err != nil { + skipped++ + c.Logger.Debugw("recipient not part of campaign", "email", reportedByEmail, "campaignID", campaignID.String(), "row", i+2) + continue + } + + // check if already reported (to avoid duplicates) + existingEvent, err := c.CampaignRepository.GetEventsByCampaignID( + ctx, + campaignID, + &repository.CampaignEventOption{ + QueryArgs: &vo.QueryArgs{ + Limit: 1, + }, + EventTypeIDs: []string{reportedEventID.String()}, + }, + nil, + ) + + alreadyReported := false + if err == nil && existingEvent != nil { + for _, event := range existingEvent.Rows { + if event.RecipientID != nil && *event.RecipientID == recipientID { + alreadyReported = true + break + } + } + } + + if alreadyReported { + skipped++ + c.Logger.Debugw("recipient already reported", "email", reportedByEmail, "campaignID", campaignID.String()) + continue + } + + // create campaign event for reported + eventID := uuid.New() + + var campaignEvent *model.CampaignEvent + if campaign.IsAnonymous.MustGet() { + campaignEvent = &model.CampaignEvent{ + ID: &eventID, + CampaignID: campaignID, + RecipientID: nil, + IP: vo.NewEmptyOptionalString64(), + UserAgent: vo.NewEmptyOptionalString255(), + EventID: reportedEventID, + Data: vo.NewEmptyOptionalString1MB(), + } + } else { + campaignEvent = &model.CampaignEvent{ + ID: &eventID, + CampaignID: campaignID, + RecipientID: &recipientID, + IP: vo.NewEmptyOptionalString64(), + UserAgent: vo.NewEmptyOptionalString255(), + EventID: reportedEventID, + Data: vo.NewEmptyOptionalString1MB(), + } + } + + // save the event with custom timestamp + err = c.saveReportedEvent(campaignEvent, parsedDate) + if err != nil { + c.Logger.Errorw("failed to save reported event", "error", err, "email", reportedByEmail) + skipped++ + continue + } + + // update most notable event for campaign recipient + err = c.SetNotableCampaignRecipientEvent( + ctx, + campaignRecipient, + data.EVENT_CAMPAIGN_RECIPIENT_REPORTED, + ) + if err != nil { + c.Logger.Errorw("failed to update notable event", "error", err) + } + + processed++ + } + + ae.Details["processed"] = processed + ae.Details["skipped"] = skipped + c.AuditLogAuthorized(ae) + + return processed, skipped, nil +} + +// saveReportedEvent saves a reported event with custom timestamp +func (c *Campaign) saveReportedEvent( + campaignEvent *model.CampaignEvent, + customTime time.Time, +) error { + row := map[string]any{ + "id": campaignEvent.ID.String(), + "event_id": campaignEvent.EventID.String(), + "campaign_id": campaignEvent.CampaignID.String(), + "ip_address": campaignEvent.IP.String(), + "user_agent": campaignEvent.UserAgent.String(), + "data": campaignEvent.Data.String(), + "created_at": customTime, + "updated_at": time.Now(), + } + if campaignEvent.RecipientID != nil { + row["recipient_id"] = campaignEvent.RecipientID.String() + } + + res := c.CampaignRepository.DB.Model(&database.CampaignEvent{}).Create(row) + if res.Error != nil { + return res.Error + } + return nil +} diff --git a/backend/testfiles/reporters.csv b/backend/testfiles/reporters.csv new file mode 100644 index 0000000..9f51e00 --- /dev/null +++ b/backend/testfiles/reporters.csv @@ -0,0 +1,2 @@ +Reported by,Date reported(UTC+02:00) +alice@black-boat.test,2025-09-17T20:11:24 diff --git a/frontend/src/lib/components/CampaignTrendChart.svelte b/frontend/src/lib/components/CampaignTrendChart.svelte index be3bae4..2c22f40 100644 --- a/frontend/src/lib/components/CampaignTrendChart.svelte +++ b/frontend/src/lib/components/CampaignTrendChart.svelte @@ -85,8 +85,10 @@ openRate: true, clickRate: true, submissionRate: true, + reportRate: true, 'mavg-clickRate': true, - 'mavg-submissionRate': true + 'mavg-submissionRate': true, + 'mavg-reportRate': true }; // Responsive margins based on container width @@ -138,7 +140,8 @@ const metrics = [ { key: 'openRate', label: 'Read Rate', color: '#4cb5b5', suffix: '%' }, { key: 'clickRate', label: 'Click Rate', color: '#f96dcf', suffix: '%' }, - { key: 'submissionRate', label: 'Submission Rate', color: '#f42e41', suffix: '%' } + { key: 'submissionRate', label: 'Submission Rate', color: '#f42e41', suffix: '%' }, + { key: 'reportRate', label: 'Report Rate', color: '#1e40af', suffix: '%' } ]; // Toggle metric visibility @@ -159,7 +162,8 @@ n, openRate: avg(slice, 'openRate'), clickRate: avg(slice, 'clickRate'), - submissionRate: avg(slice, 'submissionRate') + submissionRate: avg(slice, 'submissionRate'), + reportRate: avg(slice, 'reportRate') }; })(); @@ -205,6 +209,7 @@ clickRate: Math.round((stat.clickRate || 0) * (stat.clickRate > 1 ? 1 : 100) * 10) / 10, submissionRate: Math.round((stat.submissionRate || 0) * (stat.submissionRate > 1 ? 1 : 100) * 10) / 10, + reportRate: Math.round((stat.reportRate || 0) * (stat.reportRate > 1 ? 1 : 100) * 10) / 10, totalRecipients: stat.totalRecipients })); } @@ -247,8 +252,8 @@ createLine(svg, metric); } }); - // Only draw moving average for clickRate and submissionRate, using user-selected N - ['clickRate', 'submissionRate'].forEach((metricKey) => { + // Only draw moving average for clickRate, submissionRate, and reportRate, using user-selected N + ['clickRate', 'submissionRate', 'reportRate'].forEach((metricKey) => { const metric = metrics.find((m) => m.key === metricKey); if (metric && visibleMetrics[`mavg-${metricKey}`]) { createMovingAverageLine(svg, metric, movingAvgN); @@ -309,6 +314,8 @@ avgColor = '#93c5fd'; // light blue } else if (metric.key === 'submissionRate') { avgColor = '#ff6a91'; // lighter red, closer to #f42e41 + } else if (metric.key === 'reportRate') { + avgColor = '#60a5fa'; // lighter blue for report rate } path.setAttribute('stroke', avgColor); path.setAttribute('stroke-width', '1.2'); @@ -545,18 +552,28 @@ strokeDasharray: null, opacity: 1 }); - if (metric.key === 'clickRate' || metric.key === 'submissionRate') { + if ( + metric.key === 'clickRate' || + metric.key === 'submissionRate' || + metric.key === 'reportRate' + ) { // Use a lighter version of the main color for moving averages let avgColor = metric.color; + let avgLabel = ''; if (metric.key === 'clickRate') { avgColor = '#eea5fa'; // before-page-visited, lighter pink + avgLabel = 'Click MA'; } else if (metric.key === 'submissionRate') { avgColor = '#ff6a91'; // lighter red, closer to #f42e41 + avgLabel = 'Submit MA'; + } else if (metric.key === 'reportRate') { + avgColor = '#60a5fa'; // lighter blue for report rate + avgLabel = 'Report MA'; } legendItems.push({ type: 'mavg', key: metric.key, - label: metric.key === 'clickRate' ? 'Click MA' : 'Submit MA', + label: avgLabel, color: avgColor, class: `legend-line legend-mavg legend-mavg-${metric.key}`, labelClass: `legend-label legend-mavg legend-mavg-${metric.key}`, @@ -863,7 +880,7 @@
-+ CSV format: "Reported by" (email), "Date reported(UTC+02:00)" +
+