@@ -31,7 +31,8 @@ import (
3131)
3232
3333// RetentionCleanupJob purges old email logs, audit events, and webhook
34- // delivery records based on platform retention settings.
34+ // delivery records based on platform retention settings. It also scrubs email
35+ // bodies and attachments off records that outlive their (shorter) content windows.
3536type RetentionCleanupJob struct {
3637 emailRepo * repositories.EmailRepository
3738 eventRepo * repositories.EventRepository
@@ -153,6 +154,32 @@ func (j *RetentionCleanupJob) deleteRawKeys(keys []string) int {
153154func (j * RetentionCleanupJob ) Name () string { return "retention-cleanup" }
154155func (j * RetentionCleanupJob ) Schedule () string { return "0 3 * * *" } // daily at 03:00 UTC
155156
157+ // capDays caps a content window at the record window. A value of 0 means "keep
158+ // forever" on either side, so it is never treated as a cap or capped.
159+ func capDays (v , limit int ) int {
160+ if limit <= 0 || v <= 0 {
161+ return v
162+ }
163+ if v > limit {
164+ return limit
165+ }
166+ return v
167+ }
168+
169+ // minDays returns the shorter of two windows, treating 0 ("forever") as no bound.
170+ func minDays (a , b int ) int {
171+ if a <= 0 {
172+ return b
173+ }
174+ if b <= 0 {
175+ return a
176+ }
177+ if a < b {
178+ return a
179+ }
180+ return b
181+ }
182+
156183func (j * RetentionCleanupJob ) Run (_ context.Context , _ * asynq.Client ) error {
157184 // Clean up email logs (and any blob-stored attachments).
158185 emailRetention := j .settings .RetentionDays ()
@@ -201,6 +228,82 @@ func (j *RetentionCleanupJob) Run(_ context.Context, _ *asynq.Client) error {
201228 }
202229 }
203230
231+ // Content windows can never usefully exceed the record window — the row (and
232+ // all its content) is deleted first — so cap them at the email log retention.
233+ bodyRetention := capDays (j .settings .EmailBodyRetentionDays (), emailRetention )
234+ attachmentRetention := capDays (j .settings .EmailAttachmentRetentionDays (), emailRetention )
235+
236+ // Scrub attachments off records that outlive the attachment window (keeps the row).
237+ if attachmentRetention > 0 {
238+ before := time .Now ().AddDate (0 , 0 , - attachmentRetention )
239+
240+ if j .blobStore != nil {
241+ if jsons , err := j .emailRepo .AttachmentsJSONOlderThan (before ); err == nil {
242+ j .deleteOutboundAttachmentBlobs (jsons )
243+ } else {
244+ logger .Error ("retention cleanup: failed to enumerate outbound attachments for scrub" , "error" , err )
245+ }
246+ }
247+ if scrubbed , err := j .emailRepo .ScrubAttachmentsOlderThan (before ); err != nil {
248+ logger .Error ("retention cleanup: failed to scrub outbound attachments" , "error" , err )
249+ } else if scrubbed > 0 {
250+ logger .Info ("retention cleanup: scrubbed outbound attachments" , "count" , scrubbed , "older_than_days" , attachmentRetention )
251+ }
252+
253+ if j .inboundEmailRepo != nil {
254+ if j .blobStore != nil {
255+ if jsons , _ , err := j .inboundEmailRepo .InboundBlobKeysOlderThan (before ); err == nil {
256+ j .deleteInboundAttachmentBlobs (jsons )
257+ } else {
258+ logger .Error ("retention cleanup: failed to enumerate inbound attachments for scrub" , "error" , err )
259+ }
260+ }
261+ if scrubbed , err := j .inboundEmailRepo .ScrubAttachmentsOlderThan (before ); err != nil {
262+ logger .Error ("retention cleanup: failed to scrub inbound attachments" , "error" , err )
263+ } else if scrubbed > 0 {
264+ logger .Info ("retention cleanup: scrubbed inbound attachments" , "count" , scrubbed , "older_than_days" , attachmentRetention )
265+ }
266+ }
267+ }
268+
269+ // Scrub bodies off records that outlive the body window (keeps the row).
270+ if bodyRetention > 0 {
271+ before := time .Now ().AddDate (0 , 0 , - bodyRetention )
272+
273+ if scrubbed , err := j .emailRepo .ScrubBodiesOlderThan (before ); err != nil {
274+ logger .Error ("retention cleanup: failed to scrub outbound bodies" , "error" , err )
275+ } else if scrubbed > 0 {
276+ logger .Info ("retention cleanup: scrubbed outbound bodies" , "count" , scrubbed , "older_than_days" , bodyRetention )
277+ }
278+
279+ if j .inboundEmailRepo != nil {
280+ if scrubbed , err := j .inboundEmailRepo .ScrubBodiesOlderThan (before ); err != nil {
281+ logger .Error ("retention cleanup: failed to scrub inbound bodies" , "error" , err )
282+ } else if scrubbed > 0 {
283+ logger .Info ("retention cleanup: scrubbed inbound bodies" , "count" , scrubbed , "older_than_days" , bodyRetention )
284+ }
285+ }
286+ }
287+
288+ // The raw .eml holds both body and attachments, so purge it at the shorter of
289+ // the two windows — it must never outlive either content type.
290+ if rawRetention := minDays (bodyRetention , attachmentRetention ); rawRetention > 0 && j .inboundEmailRepo != nil {
291+ before := time .Now ().AddDate (0 , 0 , - rawRetention )
292+
293+ if j .blobStore != nil {
294+ if _ , rawKeys , err := j .inboundEmailRepo .InboundBlobKeysOlderThan (before ); err == nil {
295+ j .deleteRawKeys (rawKeys )
296+ } else {
297+ logger .Error ("retention cleanup: failed to enumerate inbound raw blobs for scrub" , "error" , err )
298+ }
299+ }
300+ if scrubbed , err := j .inboundEmailRepo .ScrubRawOlderThan (before ); err != nil {
301+ logger .Error ("retention cleanup: failed to scrub inbound raw messages" , "error" , err )
302+ } else if scrubbed > 0 {
303+ logger .Info ("retention cleanup: scrubbed inbound raw messages" , "count" , scrubbed , "older_than_days" , rawRetention )
304+ }
305+ }
306+
204307 // Clean up audit/event logs
205308 auditRetention := j .settings .AuditLogRetentionDays ()
206309 if auditRetention > 0 {
0 commit comments